first commit
This commit is contained in:
1056
src/pages/admin/AdminLayout.vue
Executable file
1056
src/pages/admin/AdminLayout.vue
Executable file
File diff suppressed because it is too large
Load Diff
926
src/pages/admin/CategoryManagement.vue
Executable file
926
src/pages/admin/CategoryManagement.vue
Executable file
@@ -0,0 +1,926 @@
|
||||
<template>
|
||||
<div class="category-management">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">分类管理</h1>
|
||||
<p class="page-description">管理视频分类,支持增删改查操作</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<i class="icon icon-plus"></i>
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="icon icon-search"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
type="text"
|
||||
placeholder="搜索分类名称..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button v-if="searchQuery" @click="clearSearch" class="clear-btn">
|
||||
<i class="icon icon-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<div class="categories-section">
|
||||
<Loading v-if="isLoading" />
|
||||
|
||||
<div v-else-if="categories.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-folder"></i>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无分类</h3>
|
||||
<p class="empty-description">还没有创建任何分类,点击上方按钮创建第一个分类</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<i class="icon icon-plus"></i>
|
||||
创建分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="categories-grid">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
>
|
||||
<div class="category-header">
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ category.name }}</h3>
|
||||
<p class="category-description">{{ category.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<button @click="moveCategoryUp(category)" class="action-btn sort-btn" title="上移" :disabled="isFirstCategory(category)">
|
||||
<i class="icon icon-chevron-up"></i>
|
||||
<span class="btn-text">上移</span>
|
||||
</button>
|
||||
<button @click="moveCategoryDown(category)" class="action-btn sort-btn" title="下移" :disabled="isLastCategory(category)">
|
||||
<i class="icon icon-chevron-down"></i>
|
||||
<span class="btn-text">下移</span>
|
||||
</button>
|
||||
<button @click="editCategory(category)" class="action-btn edit-btn" title="编辑">
|
||||
<i class="icon icon-edit"></i>
|
||||
<span class="btn-text">编辑</span>
|
||||
</button>
|
||||
<button @click="deleteCategory(category)" class="action-btn danger delete-btn" title="删除">
|
||||
<i class="icon icon-trash-2"></i>
|
||||
<span class="btn-text">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-stats">
|
||||
<div class="stat-item">
|
||||
<i class="icon icon-video"></i>
|
||||
<span class="stat-label">视频数量</span>
|
||||
<span class="stat-value">{{ category.videoCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<i class="icon icon-calendar"></i>
|
||||
<span class="stat-label">创建时间</span>
|
||||
<span class="stat-value">{{ formatDate(category.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-section" v-if="totalPages > 1">
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total="totalCategories"
|
||||
:page-size="pageSize"
|
||||
@page-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑分类模态框 -->
|
||||
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ showCreateModal ? '新建分类' : '编辑分类' }}</h3>
|
||||
<button @click="closeModal" class="close-btn">
|
||||
<i class="icon icon-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<form @submit.prevent="submitForm" class="category-form">
|
||||
<div class="form-group">
|
||||
<label for="categoryName" class="form-label">分类名称 *</label>
|
||||
<input
|
||||
id="categoryName"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入分类名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="categoryDescription" class="form-label">分类描述</label>
|
||||
<textarea
|
||||
id="categoryDescription"
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
placeholder="请输入分类描述(可选)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="closeModal" class="btn btn-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||
<i v-if="isSubmitting" class="icon icon-loader spinning"></i>
|
||||
{{ showCreateModal ? '创建' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import { categoryApi } from '@/api'
|
||||
import { eventBus, EVENTS } from '@/utils/eventBus'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
videoCount?: number
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const categories = ref<Category[]>([])
|
||||
const totalCategories = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const editingCategory = ref<Category | null>(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(totalCategories.value / pageSize.value))
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}
|
||||
|
||||
const response = await categoryApi.getCategories(params)
|
||||
if (response.code === 200) {
|
||||
categories.value = response.data || []
|
||||
totalCategories.value = response.data.length
|
||||
} else {
|
||||
throw new Error(response.message || '获取分类列表失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载分类列表失败:', error)
|
||||
ElMessage.error('加载分类列表失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
let searchTimeout: NodeJS.Timeout
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
// 编辑分类
|
||||
const editCategory = (category: Category) => {
|
||||
editingCategory.value = category
|
||||
formData.value = {
|
||||
name: category.name,
|
||||
description: category.description || ''
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
const deleteCategory = async (category: Category) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除分类「${category.name}」吗?删除后该分类下的视频将变为未分类状态。`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}
|
||||
)
|
||||
|
||||
const response = await categoryApi.deleteCategory(category.id)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类已删除')
|
||||
loadCategories()
|
||||
} else {
|
||||
throw new Error(response.message || '删除分类失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除分类失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingCategory.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
|
||||
if (showCreateModal.value) {
|
||||
// 创建分类
|
||||
const response = await categoryApi.createCategory(formData.value)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
throw new Error(response.message || '创建分类失败')
|
||||
}
|
||||
} else {
|
||||
// 更新分类
|
||||
const response = await categoryApi.updateCategory(editingCategory.value!.id, formData.value)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类更新成功')
|
||||
} else {
|
||||
throw new Error(response.message || '更新分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
closeModal()
|
||||
loadCategories()
|
||||
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为第一个分类
|
||||
const isFirstCategory = (category: Category): boolean => {
|
||||
return categories.value.indexOf(category) === 0
|
||||
}
|
||||
|
||||
// 判断是否为最后一个分类
|
||||
const isLastCategory = (category: Category): boolean => {
|
||||
return categories.value.indexOf(category) === categories.value.length - 1
|
||||
}
|
||||
|
||||
// 上移分类
|
||||
const moveCategoryUp = async (category: Category) => {
|
||||
const currentIndex = categories.value.indexOf(category)
|
||||
if (currentIndex <= 0) {
|
||||
ElMessage.warning('已经是第一个分类,无法继续上移')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加加载状态
|
||||
const loadingMessage = ElMessage({
|
||||
message: '正在调整分类顺序...',
|
||||
type: 'info',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await categoryApi.updateCategoryOrder(category.id, 'up')
|
||||
loadingMessage.close()
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`「${category.name}」已成功上移`)
|
||||
await loadCategories()
|
||||
// 触发分类顺序变化事件
|
||||
eventBus.emit(EVENTS.CATEGORY_ORDER_CHANGED, { categoryId: category.id, direction: 'up' })
|
||||
} else {
|
||||
throw new Error(response.message || '调整顺序失败')
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMessage.close()
|
||||
console.error('上移分类失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '调整顺序失败'
|
||||
ElMessage.error(`上移失败:${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 下移分类
|
||||
const moveCategoryDown = async (category: Category) => {
|
||||
const currentIndex = categories.value.indexOf(category)
|
||||
if (currentIndex >= categories.value.length - 1) {
|
||||
ElMessage.warning('已经是最后一个分类,无法继续下移')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加加载状态
|
||||
const loadingMessage = ElMessage({
|
||||
message: '正在调整分类顺序...',
|
||||
type: 'info',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await categoryApi.updateCategoryOrder(category.id, 'down')
|
||||
loadingMessage.close()
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`「${category.name}」已成功下移`)
|
||||
await loadCategories()
|
||||
// 触发分类顺序变化事件
|
||||
eventBus.emit(EVENTS.CATEGORY_ORDER_CHANGED, { categoryId: category.id, direction: 'down' })
|
||||
} else {
|
||||
throw new Error(response.message || '调整顺序失败')
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMessage.close()
|
||||
console.error('下移分类失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '调整顺序失败'
|
||||
ElMessage.error(`下移失败:${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-management {
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px 12px 44px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper .icon-search {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.categories-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 80px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-dark);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-primary);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.action-btn:disabled:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border-color);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.action-btn .btn-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-btn.edit-btn {
|
||||
background: linear-gradient(135deg, var(--info-color), #5A9BD4);
|
||||
color: white;
|
||||
border-color: var(--info-color);
|
||||
}
|
||||
|
||||
.action-btn.edit-btn:hover {
|
||||
background: linear-gradient(135deg, #3A7BC8, var(--info-color));
|
||||
color: white;
|
||||
border-color: #2E5984;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.delete-btn {
|
||||
background: linear-gradient(135deg, var(--error-color), #E6455A);
|
||||
color: white;
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.action-btn.delete-btn:hover {
|
||||
background: linear-gradient(135deg, #B91C3C, var(--error-color));
|
||||
color: white;
|
||||
border-color: #991B1B;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn {
|
||||
background: linear-gradient(135deg, var(--success-color), #32CD32);
|
||||
color: white;
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn:hover {
|
||||
background: linear-gradient(135deg, #1E7E1E, var(--success-color));
|
||||
color: white;
|
||||
border-color: #166316;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn:disabled {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border-color);
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.category-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-item .icon {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-color));
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--text-light);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-dark);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.category-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-management {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
969
src/pages/admin/Dashboard.vue
Executable file
969
src/pages/admin/Dashboard.vue
Executable file
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-subtitle">欢迎回来,{{ userStore.user?.username }}!这里是您的管理概览</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="refresh-btn" @click="refreshData" :disabled="isLoading">
|
||||
<i class="icon icon-refresh-cw" :class="{ spinning: isLoading }"></i>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 简化的内容区域 -->
|
||||
<div class="dashboard-content">
|
||||
<div class="content-grid">
|
||||
|
||||
<!-- 热门视频 -->
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="data-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">快速操作</h3>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<router-link to="/admin/upload" class="quick-action">
|
||||
<div class="action-icon upload">
|
||||
<i class="icon icon-upload"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">上传视频</h4>
|
||||
<p class="action-description">添加新的视频内容</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/videos" class="quick-action">
|
||||
<div class="action-icon videos">
|
||||
<i class="icon icon-video"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">视频管理</h4>
|
||||
<p class="action-description">管理所有视频</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/categories" class="quick-action">
|
||||
<div class="action-icon categories">
|
||||
<i class="icon icon-folder"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">分类管理</h4>
|
||||
<p class="action-description">管理视频分类</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/profile" class="quick-action">
|
||||
<div class="action-icon profile">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">个人资料</h4>
|
||||
<p class="action-description">修改个人信息</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
|
||||
|
||||
interface Video {
|
||||
id: string | number
|
||||
title: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
views?: number
|
||||
createdAt?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
user_id?: number
|
||||
category_id?: number
|
||||
tags?: string[]
|
||||
status?: 'published' | 'draft' | 'processing' | 'failed' | 'blocked' | 'private' | 'featured'
|
||||
file_url?: string
|
||||
file_size?: number
|
||||
likes?: number
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string | number
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
isOnline?: boolean
|
||||
videoCount?: number
|
||||
totalViews?: number
|
||||
createdAt?: string
|
||||
role?: 'admin' | 'user' | 'moderator' | 'vip'
|
||||
status?: 'active' | 'inactive' | 'banned'
|
||||
video_count?: number
|
||||
followers_count?: number
|
||||
likes_count?: number
|
||||
views_count?: number
|
||||
created_at?: string
|
||||
last_login?: string
|
||||
last_ip?: string
|
||||
device_info?: string
|
||||
}
|
||||
|
||||
interface SystemMetric {
|
||||
key: string
|
||||
name: string
|
||||
value: string
|
||||
percentage: number
|
||||
status: 'good' | 'warning' | 'danger'
|
||||
description: string
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const uploadTrendPeriod = ref('30d')
|
||||
const viewsPeriod = ref('week')
|
||||
const activeUsersChartType = ref('line')
|
||||
|
||||
|
||||
|
||||
const popularVideos = ref<Video[]>([])
|
||||
const recentUsers = ref<User[]>([])
|
||||
|
||||
const systemStatus = ref({
|
||||
overall: 'good' as 'good' | 'warning' | 'danger'
|
||||
})
|
||||
|
||||
const systemMetrics = ref<SystemMetric[]>([
|
||||
{
|
||||
key: 'cpu',
|
||||
name: 'CPU 使用率',
|
||||
value: '45%',
|
||||
percentage: 45,
|
||||
status: 'good',
|
||||
description: '服务器CPU使用情况'
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
name: '内存使用率',
|
||||
value: '68%',
|
||||
percentage: 68,
|
||||
status: 'warning',
|
||||
description: '服务器内存使用情况'
|
||||
},
|
||||
{
|
||||
key: 'disk',
|
||||
name: '磁盘使用率',
|
||||
value: '32%',
|
||||
percentage: 32,
|
||||
status: 'good',
|
||||
description: '存储空间使用情况'
|
||||
},
|
||||
{
|
||||
key: 'bandwidth',
|
||||
name: '带宽使用率',
|
||||
value: '78%',
|
||||
percentage: 78,
|
||||
status: 'warning',
|
||||
description: '网络带宽使用情况'
|
||||
}
|
||||
])
|
||||
|
||||
// Canvas 引用
|
||||
const uploadTrendChart = ref<HTMLCanvasElement>()
|
||||
const viewsChart = ref<HTMLCanvasElement>()
|
||||
const activeUsersChart = ref<HTMLCanvasElement>()
|
||||
|
||||
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`
|
||||
if (days < 365) return `${Math.floor(days / 30)}个月前`
|
||||
return `${Math.floor(days / 365)}年前`
|
||||
}
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
good: 'icon-check-circle',
|
||||
warning: 'icon-alert-triangle',
|
||||
danger: 'icon-x-circle'
|
||||
}
|
||||
return iconMap[status] || 'icon-help-circle'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string): string => {
|
||||
const textMap: Record<string, string> = {
|
||||
good: '运行正常',
|
||||
warning: '需要关注',
|
||||
danger: '存在问题'
|
||||
}
|
||||
return textMap[status] || '未知状态'
|
||||
}
|
||||
|
||||
// 绘制上传趋势图表
|
||||
const drawUploadTrendChart = () => {
|
||||
if (!uploadTrendChart.value) return
|
||||
|
||||
const canvas = uploadTrendChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// 设置画布大小
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [12, 19, 15, 25, 22, 30, 28, 35, 32, 38, 42, 45, 48, 52, 55]
|
||||
const labels = data.map((_, i) => `${i + 1}日`)
|
||||
|
||||
// 绘制图表
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
const maxValue = Math.max(...data)
|
||||
const stepX = chartWidth / (data.length - 1)
|
||||
const stepY = chartHeight / maxValue
|
||||
|
||||
// 绘制网格线
|
||||
ctx.strokeStyle = '#f1f5f9'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (chartHeight / 5) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(rect.width - padding, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 绘制折线
|
||||
ctx.strokeStyle = '#d4af37'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制数据点
|
||||
ctx.fillStyle = '#d4af37'
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制播放量图表
|
||||
const drawViewsChart = () => {
|
||||
if (!viewsChart.value) return
|
||||
|
||||
const canvas = viewsChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [1200, 1900, 1500, 2500, 2200, 3000, 2800]
|
||||
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
const maxValue = Math.max(...data)
|
||||
const barWidth = chartWidth / data.length * 0.6
|
||||
const barSpacing = chartWidth / data.length * 0.4
|
||||
|
||||
// 绘制柱状图
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + (chartWidth / data.length) * index + barSpacing / 2
|
||||
const barHeight = (value / maxValue) * chartHeight
|
||||
const y = rect.height - padding - barHeight
|
||||
|
||||
// 渐变色
|
||||
const gradient = ctx.createLinearGradient(0, y, 0, rect.height - padding)
|
||||
gradient.addColorStop(0, '#d4af37')
|
||||
gradient.addColorStop(1, 'rgba(212, 175, 55, 0.3)')
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, barWidth, barHeight)
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制用户活跃度图表
|
||||
const drawActiveUsersChart = () => {
|
||||
if (!activeUsersChart.value) return
|
||||
|
||||
const canvas = activeUsersChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [45, 52, 48, 61, 58, 67, 72, 69, 75, 82, 78, 85]
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
if (activeUsersChartType.value === 'line') {
|
||||
// 绘制折线图
|
||||
const maxValue = Math.max(...data)
|
||||
const stepX = chartWidth / (data.length - 1)
|
||||
const stepY = chartHeight / maxValue
|
||||
|
||||
ctx.strokeStyle = '#3b82f6'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
} else {
|
||||
// 绘制柱状图
|
||||
const maxValue = Math.max(...data)
|
||||
const barWidth = chartWidth / data.length * 0.6
|
||||
const barSpacing = chartWidth / data.length * 0.4
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + (chartWidth / data.length) * index + barSpacing / 2
|
||||
const barHeight = (value / maxValue) * chartHeight
|
||||
const y = rect.height - padding - barHeight
|
||||
|
||||
ctx.fillStyle = '#3b82f6'
|
||||
ctx.fillRect(x, y, barWidth, barHeight)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const [videosResponse, usersResponse] = await Promise.all([
|
||||
api.statsApi.getLatestVideos(5),
|
||||
api.statsApi.getRecentUsers({ limit: 5 })
|
||||
])
|
||||
|
||||
popularVideos.value = videosResponse.data
|
||||
recentUsers.value = usersResponse.data
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = async () => {
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
drawCharts()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
// 绘制所有图表
|
||||
const drawCharts = () => {
|
||||
drawUploadTrendChart()
|
||||
drawViewsChart()
|
||||
drawActiveUsersChart()
|
||||
}
|
||||
|
||||
// 监听图表类型变化
|
||||
watch(activeUsersChartType, () => {
|
||||
nextTick(() => {
|
||||
drawActiveUsersChart()
|
||||
})
|
||||
})
|
||||
|
||||
// 监听时间周期变化
|
||||
watch([uploadTrendPeriod, viewsPeriod], () => {
|
||||
nextTick(() => {
|
||||
drawCharts()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
drawCharts()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
setTimeout(drawCharts, 100)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #b8941f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-content {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.data-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-type-btn {
|
||||
padding: 0.5rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-type-btn:hover,
|
||||
.chart-type-btn.active {
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #d4af37;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
color: #b8941f;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.popular-videos,
|
||||
.recent-users {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.video-item,
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.video-item:hover,
|
||||
.user-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.video-rank {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 45px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.video-info,
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.video-title,
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.video-meta,
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.video-views,
|
||||
.user-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.video-actions,
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item .stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-item .stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.system-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-value.good { color: #059669; }
|
||||
.metric-value.warning { color: #d97706; }
|
||||
.metric-value.danger { color: #dc2626; }
|
||||
|
||||
.metric-bar {
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-progress {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-progress.good { background: #10b981; }
|
||||
.metric-progress.warning { background: #f59e0b; }
|
||||
.metric-progress.danger { background: #ef4444; }
|
||||
|
||||
.metric-description {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator.good {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-indicator.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-action:hover {
|
||||
background: #f1f5f9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.upload { background: linear-gradient(135deg, #d4af37, #f1c40f); }
|
||||
.action-icon.users { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
|
||||
.action-icon.analytics { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.action-icon.settings { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-item,
|
||||
.user-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
929
src/pages/admin/Login.vue
Executable file
929
src/pages/admin/Login.vue
Executable file
@@ -0,0 +1,929 @@
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-decoration">
|
||||
<div class="decoration-pattern"></div>
|
||||
<div class="floating-elements">
|
||||
<div class="element" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录容器 -->
|
||||
<div class="login-container">
|
||||
<!-- 品牌区域 -->
|
||||
<div class="brand-section">
|
||||
<div class="brand-logo">
|
||||
<img src="/logo.svg" alt="梦回高句丽" class="logo-img" />
|
||||
</div>
|
||||
<h1 class="brand-title">梦回高句丽</h1>
|
||||
<p class="brand-subtitle">管理后台</p>
|
||||
<div class="brand-desc">
|
||||
<p>探索古代文明的数字化管理平台</p>
|
||||
<p>传承历史文化,连接过去与未来</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form-section">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">管理员登录</h2>
|
||||
<p class="form-subtitle">请使用管理员账号登录系统</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- 用户名输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-user"></i>
|
||||
用户名
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入管理员用户名"
|
||||
:class="{ error: errors.username }"
|
||||
@blur="validateUsername"
|
||||
@input="clearError('username')"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.username">
|
||||
{{ errors.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-lock"></i>
|
||||
密码
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入密码"
|
||||
:class="{ error: errors.password }"
|
||||
@blur="validatePassword"
|
||||
@input="clearError('password')"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-lock"></i>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="icon" :class="showPassword ? 'icon-eye-off' : 'icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.password">
|
||||
{{ errors.password }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-shield"></i>
|
||||
验证码
|
||||
</label>
|
||||
<div class="captcha-wrapper">
|
||||
<div class="input-wrapper captcha-input">
|
||||
<input
|
||||
v-model="loginForm.captcha"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入验证码"
|
||||
:class="{ error: errors.captcha }"
|
||||
@blur="validateCaptcha"
|
||||
@input="clearError('captcha')"
|
||||
maxlength="4"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-shield"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-image" @click="refreshCaptcha">
|
||||
<canvas ref="captchaCanvas" width="120" height="40"></canvas>
|
||||
<div class="captcha-refresh">
|
||||
<i class="icon icon-refresh-cw"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.captcha">
|
||||
{{ errors.captcha }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 记住登录 -->
|
||||
<div class="form-options">
|
||||
<label class="checkbox-wrapper">
|
||||
<input
|
||||
v-model="loginForm.remember"
|
||||
type="checkbox"
|
||||
class="checkbox-input"
|
||||
/>
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-label">记住登录状态</span>
|
||||
</label>
|
||||
<a href="#" class="forgot-link" @click.prevent="handleForgotPassword">
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ loading: isLoading }"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<i class="icon icon-loader" v-if="isLoading"></i>
|
||||
<i class="icon icon-log-in" v-else></i>
|
||||
<span>{{ isLoading ? '登录中...' : '立即登录' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice">
|
||||
<i class="icon icon-info"></i>
|
||||
<div class="notice-content">
|
||||
<p>为了您的账户安全,请妥善保管登录凭证</p>
|
||||
<p>如发现异常登录行为,请及时联系系统管理员</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚信息 -->
|
||||
<div class="login-footer">
|
||||
<div class="footer-links">
|
||||
<a href="#">使用条款</a>
|
||||
<a href="#">隐私政策</a>
|
||||
<a href="#">技术支持</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
<p>© 2024 梦回高句丽. 保留所有权利.</p>
|
||||
<p>Powered by 高句丽文化数字化平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { authApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface LoginForm {
|
||||
username: string
|
||||
password: string
|
||||
captcha: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string
|
||||
password?: string
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单数据
|
||||
const loginForm = ref<LoginForm>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 表单状态
|
||||
const isLoading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const errors = ref<FormErrors>({})
|
||||
const captchaText = ref('')
|
||||
const captchaCanvas = ref<HTMLCanvasElement>()
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
return loginForm.value.username.length >= 3 &&
|
||||
loginForm.value.password.length >= 6 &&
|
||||
loginForm.value.captcha.length === 4
|
||||
})
|
||||
|
||||
// 验证用户名
|
||||
const validateUsername = () => {
|
||||
if (!loginForm.value.username) {
|
||||
errors.value.username = '请输入用户名'
|
||||
} else if (loginForm.value.username.length < 3) {
|
||||
errors.value.username = '用户名至少3个字符'
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(loginForm.value.username)) {
|
||||
errors.value.username = '用户名只能包含字母、数字和下划线'
|
||||
} else {
|
||||
delete errors.value.username
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const validatePassword = () => {
|
||||
if (!loginForm.value.password) {
|
||||
errors.value.password = '请输入密码'
|
||||
} else if (loginForm.value.password.length < 6) {
|
||||
errors.value.password = '密码至少6个字符'
|
||||
} else {
|
||||
delete errors.value.password
|
||||
}
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
const validateCaptcha = () => {
|
||||
if (!loginForm.value.captcha) {
|
||||
errors.value.captcha = '请输入验证码'
|
||||
} else if (loginForm.value.captcha.length !== 4) {
|
||||
errors.value.captcha = '验证码为4位字符'
|
||||
} else {
|
||||
delete errors.value.captcha
|
||||
}
|
||||
}
|
||||
|
||||
// 清除错误
|
||||
const clearError = (field: keyof FormErrors) => {
|
||||
delete errors.value[field]
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
const generateCaptcha = () => {
|
||||
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
captchaText.value = result
|
||||
drawCaptcha()
|
||||
}
|
||||
|
||||
// 绘制验证码
|
||||
const drawCaptcha = () => {
|
||||
if (!captchaCanvas.value) return
|
||||
|
||||
const canvas = captchaCanvas.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
gradient.addColorStop(0, 'rgba(212, 175, 55, 0.1)')
|
||||
gradient.addColorStop(1, 'rgba(212, 175, 55, 0.2)')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制干扰线
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.strokeStyle = `rgba(212, 175, 55, ${Math.random() * 0.3 + 0.1})`
|
||||
ctx.lineWidth = Math.random() * 2 + 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
|
||||
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 绘制验证码文字
|
||||
const text = captchaText.value
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
ctx.font = `${Math.random() * 8 + 20}px Arial`
|
||||
ctx.fillStyle = `hsl(${Math.random() * 60 + 30}, 70%, ${Math.random() * 30 + 50}%)`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const x = (canvas.width / text.length) * (i + 0.5)
|
||||
const y = canvas.height / 2 + (Math.random() - 0.5) * 8
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate((Math.random() - 0.5) * 0.5)
|
||||
ctx.fillText(text[i], 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 绘制干扰点
|
||||
for (let i = 0; i < 30; i++) {
|
||||
ctx.fillStyle = `rgba(212, 175, 55, ${Math.random() * 0.5})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
Math.random() * canvas.width,
|
||||
Math.random() * canvas.height,
|
||||
Math.random() * 2 + 1,
|
||||
0,
|
||||
2 * Math.PI
|
||||
)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = () => {
|
||||
generateCaptcha()
|
||||
loginForm.value.captcha = ''
|
||||
clearError('captcha')
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
// 验证表单
|
||||
validateUsername()
|
||||
validatePassword()
|
||||
validateCaptcha()
|
||||
|
||||
// 验证验证码是否正确
|
||||
if (loginForm.value.captcha.toLowerCase() !== captchaText.value.toLowerCase()) {
|
||||
ElMessage.error('验证码错误')
|
||||
refreshCaptcha()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormValid.value) {
|
||||
ElMessage.error('请检查输入信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await authApi.login(
|
||||
loginForm.value.username,
|
||||
loginForm.value.password
|
||||
)
|
||||
|
||||
// 保存用户信息
|
||||
userStore.login(response.data.user, response.data.token)
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 跳转到管理后台首页
|
||||
router.push('/admin')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
} else if (error.response?.status === 400) {
|
||||
ElMessage.error('验证码错误')
|
||||
refreshCaptcha()
|
||||
} else {
|
||||
ElMessage.error('登录失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 忘记密码
|
||||
const handleForgotPassword = () => {
|
||||
ElMessage.info('请联系系统管理员重置密码')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果已经登录,直接跳转
|
||||
if (userStore.isLoggedIn) {
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
generateCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 25%, #334155 50%, #475569 75%, #64748b 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.decoration-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(212, 175, 55, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(212, 175, 55, 0.05) 0%, transparent 50%);
|
||||
background-size: 400px 400px;
|
||||
animation: patternMove 20s linear infinite;
|
||||
}
|
||||
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.floating-elements .element {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(212, 175, 55, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: float 15s linear infinite;
|
||||
}
|
||||
|
||||
.floating-elements .element:nth-child(1) { left: 10%; animation-delay: 0s; }
|
||||
.floating-elements .element:nth-child(2) { left: 20%; animation-delay: -2s; }
|
||||
.floating-elements .element:nth-child(3) { left: 40%; animation-delay: -4s; }
|
||||
.floating-elements .element:nth-child(4) { left: 60%; animation-delay: -6s; }
|
||||
.floating-elements .element:nth-child(5) { left: 80%; animation-delay: -8s; }
|
||||
.floating-elements .element:nth-child(6) { left: 90%; animation-delay: -10s; }
|
||||
|
||||
@keyframes patternMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(-400px, -400px); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(-100px) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
padding: 3rem;
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.05));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border-right: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #d4af37;
|
||||
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 2rem 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.brand-desc p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1rem 1rem 3rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4af37;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d4af37;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.captcha-image canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.captcha-image:hover .captcha-refresh {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.checkbox-input:checked + .checkbox-custom {
|
||||
background: #d4af37;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.checkbox-input:checked + .checkbox-custom::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #1a1a2e;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: #d4af37;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: #f1c40f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #d4af37, #f1c40f);
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-btn.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-content .icon-loader {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #93c5fd;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notice-content p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.notice-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #d4af37;
|
||||
}
|
||||
|
||||
.footer-copyright p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.admin-login-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
padding: 2rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: static;
|
||||
transform: none;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.brand-section,
|
||||
.login-form-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
573
src/pages/admin/PasswordChange.vue
Executable file
573
src/pages/admin/PasswordChange.vue
Executable file
@@ -0,0 +1,573 @@
|
||||
<template>
|
||||
<div class="password-change">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">修改密码</h1>
|
||||
<p class="page-description">为了账户安全,请定期更换密码</p>
|
||||
</div>
|
||||
|
||||
<div class="password-container">
|
||||
<div class="password-card">
|
||||
<div class="security-tips">
|
||||
<div class="tips-header">
|
||||
<i class="icon icon-shield"></i>
|
||||
<h3>密码安全提示</h3>
|
||||
</div>
|
||||
<ul class="tips-list">
|
||||
<li>密码长度至少8位字符</li>
|
||||
<li>包含大小写字母、数字和特殊字符</li>
|
||||
<li>不要使用常见的密码组合</li>
|
||||
<li>定期更换密码,建议每3个月更换一次</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="password-form">
|
||||
<form @submit.prevent="changePassword">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">当前密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="formData.currentPassword"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showCurrentPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="newPassword"
|
||||
v-model="formData.newPassword"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="checkPasswordStrength"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showNewPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="formData.newPassword" class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div
|
||||
class="strength-fill"
|
||||
:class="`strength-${passwordStrength.level}`"
|
||||
:style="{ width: passwordStrength.percentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="strength-text" :class="`strength-${passwordStrength.level}`">
|
||||
{{ passwordStrength.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认新密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="formData.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:class="{ 'error': passwordMismatch }"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showConfirmPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="passwordMismatch" class="error-message">
|
||||
两次输入的密码不一致
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="resetForm" class="btn btn-outline">
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isLoading || passwordMismatch || passwordStrength.level === 'weak'"
|
||||
>
|
||||
<i v-if="isLoading" class="icon icon-loader spinning"></i>
|
||||
<i v-else class="icon icon-lock"></i>
|
||||
{{ isLoading ? '修改中...' : '修改密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="showSuccess" class="success-toast">
|
||||
<i class="icon icon-check"></i>
|
||||
密码修改成功!请重新登录。
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="showError" class="error-toast">
|
||||
<i class="icon icon-x"></i>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface PasswordStrength {
|
||||
level: 'weak' | 'medium' | 'strong'
|
||||
percentage: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const passwordStrength = ref<PasswordStrength>({
|
||||
level: 'weak',
|
||||
percentage: 0,
|
||||
text: ''
|
||||
})
|
||||
|
||||
const passwordMismatch = computed(() => {
|
||||
return formData.confirmPassword && formData.newPassword !== formData.confirmPassword
|
||||
})
|
||||
|
||||
const checkPasswordStrength = () => {
|
||||
const password = formData.newPassword
|
||||
let score = 0
|
||||
let feedback = []
|
||||
|
||||
if (password.length >= 8) score += 1
|
||||
else feedback.push('至少8位字符')
|
||||
|
||||
if (/[a-z]/.test(password)) score += 1
|
||||
else feedback.push('包含小写字母')
|
||||
|
||||
if (/[A-Z]/.test(password)) score += 1
|
||||
else feedback.push('包含大写字母')
|
||||
|
||||
if (/\d/.test(password)) score += 1
|
||||
else feedback.push('包含数字')
|
||||
|
||||
if (/[^\w\s]/.test(password)) score += 1
|
||||
else feedback.push('包含特殊字符')
|
||||
|
||||
if (score <= 2) {
|
||||
passwordStrength.value = {
|
||||
level: 'weak',
|
||||
percentage: 33,
|
||||
text: `弱密码 - 缺少: ${feedback.join('、')}`
|
||||
}
|
||||
} else if (score <= 3) {
|
||||
passwordStrength.value = {
|
||||
level: 'medium',
|
||||
percentage: 66,
|
||||
text: `中等强度 - 建议: ${feedback.join('、')}`
|
||||
}
|
||||
} else {
|
||||
passwordStrength.value = {
|
||||
level: 'strong',
|
||||
percentage: 100,
|
||||
text: '强密码'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
if (passwordMismatch.value) {
|
||||
showErrorMessage('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordStrength.value.level === 'weak') {
|
||||
showErrorMessage('密码强度太弱,请设置更安全的密码')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 模拟验证当前密码
|
||||
if (formData.currentPassword !== 'admin123') {
|
||||
throw new Error('当前密码不正确')
|
||||
}
|
||||
|
||||
showSuccess.value = true
|
||||
resetForm()
|
||||
|
||||
// 3秒后自动跳转到登录页
|
||||
setTimeout(() => {
|
||||
// 这里应该清除登录状态并跳转到登录页
|
||||
// 密码修改成功,跳转到登录页
|
||||
router.push('/admin/login')
|
||||
}, 3000)
|
||||
|
||||
// 修改密码成功
|
||||
} catch (error: any) {
|
||||
showErrorMessage(error.message || '密码修改失败,请重试')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
errorMessage.value = message
|
||||
showError.value = true
|
||||
setTimeout(() => {
|
||||
showError.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.currentPassword = ''
|
||||
formData.newPassword = ''
|
||||
formData.confirmPassword = ''
|
||||
passwordStrength.value = {
|
||||
level: 'weak',
|
||||
percentage: 0,
|
||||
text: ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-change {
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
background: #f8fafc;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tips-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tips-header i {
|
||||
color: #3b82f6;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tips-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.tips-list li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 48px 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input.error:focus {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
transition: all 0.3s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.strength-fill.strength-weak {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.strength-fill.strength-medium {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.strength-fill.strength-strong {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.strength-text.strength-weak {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.strength-text.strength-medium {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.strength-text.strength-strong {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.success-toast,
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.password-change {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.security-tips,
|
||||
.password-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
465
src/pages/admin/UserProfile.vue
Executable file
465
src/pages/admin/UserProfile.vue
Executable file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="user-profile">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">个人资料</h1>
|
||||
</div>
|
||||
|
||||
<div class="profile-container">
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h2>{{ userInfo.username }}</h2>
|
||||
<p class="role">管理员</p>
|
||||
<p class="last-login">上次登录:{{ formatDate(userInfo.lastLogin) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-form">
|
||||
<form @submit.prevent="updateProfile">
|
||||
<div class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
class="form-input"
|
||||
placeholder="请输入邮箱地址"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">手机号码</label>
|
||||
<input
|
||||
id="phone"
|
||||
v-model="formData.phone"
|
||||
type="tel"
|
||||
class="form-input"
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="realName">真实姓名</label>
|
||||
<input
|
||||
id="realName"
|
||||
v-model="formData.realName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入真实姓名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>账户统计</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.totalVideos }}</div>
|
||||
<div class="stat-label">管理视频</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.totalCategories }}</div>
|
||||
<div class="stat-label">管理分类</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.loginCount }}</div>
|
||||
<div class="stat-label">登录次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.daysActive }}</div>
|
||||
<div class="stat-label">活跃天数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="resetForm" class="btn btn-outline">
|
||||
重置
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isLoading">
|
||||
<i v-if="isLoading" class="icon icon-loader spinning"></i>
|
||||
<i v-else class="icon icon-save"></i>
|
||||
{{ isLoading ? '保存中...' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="showSuccess" class="success-toast">
|
||||
<i class="icon icon-check"></i>
|
||||
个人资料更新成功!
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
|
||||
interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
phone: string
|
||||
realName: string
|
||||
lastLogin: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
totalVideos: number
|
||||
totalCategories: number
|
||||
loginCount: number
|
||||
daysActive: number
|
||||
}
|
||||
|
||||
const userInfo = ref<UserInfo>({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
realName: '管理员',
|
||||
lastLogin: '2024-01-15T10:30:00Z',
|
||||
createdAt: '2023-12-01T09:00:00Z'
|
||||
})
|
||||
|
||||
const userStats = ref<UserStats>({
|
||||
totalVideos: 35,
|
||||
totalCategories: 8,
|
||||
loginCount: 127,
|
||||
daysActive: 45
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
realName: ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
|
||||
const loadUserInfo = () => {
|
||||
// 模拟API调用
|
||||
formData.username = userInfo.value.username
|
||||
formData.email = userInfo.value.email
|
||||
formData.phone = userInfo.value.phone
|
||||
formData.realName = userInfo.value.realName
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 更新用户信息
|
||||
userInfo.value = {
|
||||
...userInfo.value,
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
realName: formData.realName
|
||||
}
|
||||
|
||||
showSuccess.value = true
|
||||
setTimeout(() => {
|
||||
showSuccess.value = false
|
||||
}, 3000)
|
||||
|
||||
// 更新个人资料
|
||||
} catch (error) {
|
||||
// 更新失败
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
loadUserInfo()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.profile-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.last-login {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-profile {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 24px 20px;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1069
src/pages/admin/VideoEdit.vue
Executable file
1069
src/pages/admin/VideoEdit.vue
Executable file
File diff suppressed because it is too large
Load Diff
2221
src/pages/admin/VideoManagement.vue
Executable file
2221
src/pages/admin/VideoManagement.vue
Executable file
File diff suppressed because it is too large
Load Diff
2011
src/pages/admin/VideoUpload.vue
Executable file
2011
src/pages/admin/VideoUpload.vue
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user