first commit

This commit is contained in:
2025-09-23 07:35:11 +00:00
commit a5dd3f1335
110 changed files with 46108 additions and 0 deletions

1056
src/pages/admin/AdminLayout.vue Executable file

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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>&copy; 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>

View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2011
src/pages/admin/VideoUpload.vue Executable file

File diff suppressed because it is too large Load Diff