feat: 系统优化和功能完善

主要更新:
- 调整并发配置为50人(数据库连接池30,Tomcat线程150,异步线程池5/20)
- 实现无界阻塞队列(LinkedBlockingQueue)任务处理
- 实现分镜视频保存功能(保存到uploads目录)
- 统一管理页面导航栏和右上角样式
- 添加日活用户统计功能
- 优化视频拼接和保存逻辑
- 添加部署文档和快速部署指南
- 更新.gitignore排除敏感配置文件
This commit is contained in:
AIGC Developer
2025-11-07 19:09:50 +08:00
parent b5820d9be2
commit 1e71ae6a26
146 changed files with 10720 additions and 3032 deletions

View File

@@ -439,6 +439,9 @@ MIT License

View File

@@ -35,6 +35,9 @@ console.log('App.vue 加载成功')

View File

@@ -475,8 +475,7 @@ main.with-navbar {
/* 管理员页面 - 深色专业科技风全屏覆盖 */
.fullscreen-background.AdminDashboard,
.fullscreen-background.AdminOrders,
.fullscreen-background.AdminUsers {
.fullscreen-background.AdminOrders {
background:
radial-gradient(ellipse at center, rgba(0, 20, 40, 0.9) 0%, rgba(0, 10, 20, 0.95) 50%, rgba(0, 0, 0, 1) 100%),
linear-gradient(135deg, #000000 0%, #0a0a0a 50%, #1a1a1a 100%);
@@ -484,8 +483,7 @@ main.with-navbar {
}
.fullscreen-background.AdminDashboard::before,
.fullscreen-background.AdminOrders::before,
.fullscreen-background.AdminUsers::before {
.fullscreen-background.AdminOrders::before {
content: '';
position: absolute;
top: 50%;

View File

@@ -13,8 +13,9 @@ export const getMonthlyRevenue = (year = '2024') => {
}
// 获取用户转化率数据
export const getConversionRate = () => {
return api.get('/dashboard/conversion-rate')
export const getConversionRate = (year = null) => {
const params = year ? { year } : {}
return api.get('/dashboard/conversion-rate', { params })
}
// 获取最近订单数据
@@ -27,4 +28,11 @@ export const getRecentOrders = (limit = 10) => {
// 获取系统状态
export const getSystemStatus = () => {
return api.get('/dashboard/system-status')
}
// 获取日活用户趋势数据
export const getDailyActiveUsersTrend = (year = '2024', granularity = 'monthly') => {
return api.get('/analytics/daily-active-users', {
params: { year, granularity }
})
}

View File

@@ -95,17 +95,6 @@ export const imageToVideoApi = {
})
},
/**
* 取消任务
* @param {string} taskId - 任务ID
* @returns {Promise} API响应
*/
cancelTask(taskId) {
return request({
url: `/image-to-video/tasks/${taskId}/cancel`,
method: 'POST'
})
},
/**
* 轮询任务状态

View File

@@ -24,3 +24,13 @@ export const deleteMembers = (ids) => {
export const getMemberDetail = (id) => {
return api.get(`/members/${id}`)
}
// 获取所有会员等级配置
export const getMembershipLevels = () => {
return api.get('/members/levels')
}
// 更新会员等级配置
export const updateMembershipLevel = (id, data) => {
return api.put(`/members/levels/${id}`, data)
}

View File

@@ -42,9 +42,9 @@ export const createOrderPayment = (id, paymentMethod) => {
})
}
// 管理员订单API
// 管理员订单API(使用普通订单接口,后端会根据用户角色返回相应数据)
export const getAdminOrders = (params) => {
return api.get('/orders/admin', { params })
return api.get('/orders', { params })
}
// 订单统计API

View File

@@ -0,0 +1,20 @@
import api from './request'
// 积分相关API
export const getPointsInfo = () => {
return api.get('/points/info')
}
export const getPointsHistory = (params = {}) => {
return api.get('/points/history', { params })
}
export const getPointsFreezeRecords = () => {
return api.get('/points/freeze-records')
}
export const processExpiredRecords = () => {
return api.post('/points/process-expired')
}

View File

@@ -20,3 +20,10 @@ export const getStoryboardTask = async (taskId) => {
export const getUserStoryboardTasks = async (page = 0, size = 10) => {
return api.get('/storyboard-video/tasks', { params: { page, size } })
}
/**
* 开始生成视频(从分镜图生成视频)
*/
export const startVideoGeneration = async (taskId) => {
return api.post(`/storyboard-video/task/${taskId}/start-video`)
}

View File

@@ -84,17 +84,6 @@ export const textToVideoApi = {
})
},
/**
* 取消任务
* @param {string} taskId - 任务ID
* @returns {Promise} API响应
*/
cancelTask(taskId) {
return request({
url: `/text-to-video/tasks/${taskId}/cancel`,
method: 'POST'
})
},
/**
* 轮询任务状态

View File

@@ -1,10 +1,8 @@
import request from './request'
import api from './request'
// 获取我的作品列表
export const getMyWorks = (params = {}) => {
return request({
url: '/works/my-works',
method: 'GET',
return api.get('/works/my-works', {
params: {
page: params.page || 0,
size: params.size || 10
@@ -14,46 +12,29 @@ export const getMyWorks = (params = {}) => {
// 获取作品详情
export const getWorkDetail = (workId) => {
return request({
url: `/works/${workId}`,
method: 'GET'
})
return api.get(`/works/${workId}`)
}
// 删除作品
export const deleteWork = (workId) => {
return request({
url: `/works/${workId}`,
method: 'DELETE'
})
return api.delete(`/works/${workId}`)
}
// 批量删除作品
export const batchDeleteWorks = (workIds) => {
return request({
url: '/works/batch-delete',
method: 'POST',
data: {
workIds: workIds
}
return api.post('/works/batch-delete', {
workIds: workIds
})
}
// 更新作品信息
export const updateWork = (workId, data) => {
return request({
url: `/works/${workId}`,
method: 'PUT',
data: data
})
return api.put(`/works/${workId}`, data)
}
// 获取作品统计信息
export const getWorkStats = () => {
return request({
url: '/works/stats',
method: 'GET'
})
return api.get('/works/stats')
}
@@ -65,3 +46,6 @@ export const getWorkStats = () => {

View File

@@ -98,6 +98,9 @@

View File

@@ -42,10 +42,6 @@
<span>后台管理</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/admin/users">
<span>用户管理</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/admin/dashboard">
<span>数据仪表盘</span>
</el-menu-item>

View File

@@ -12,7 +12,6 @@ const OrderCreate = () => import('@/views/OrderCreate.vue')
const Payments = () => import('@/views/Payments.vue')
const PaymentCreate = () => import('@/views/PaymentCreate.vue')
const AdminOrders = () => import('@/views/AdminOrders.vue')
const AdminUsers = () => import('@/views/AdminUsers.vue')
const AdminDashboard = () => import('@/views/AdminDashboard.vue')
const Dashboard = () => import('@/views/Dashboard.vue')
const Welcome = () => import('@/views/Welcome.vue')
@@ -24,6 +23,7 @@ const TextToVideo = () => import('@/views/TextToVideo.vue')
const TextToVideoCreate = () => import('@/views/TextToVideoCreate.vue')
const ImageToVideo = () => import('@/views/ImageToVideo.vue')
const ImageToVideoCreate = () => import('@/views/ImageToVideoCreate.vue')
const ImageToVideoDetail = () => import('@/views/ImageToVideoDetail.vue')
const StoryboardVideo = () => import('@/views/StoryboardVideo.vue')
const StoryboardVideoCreate = () => import('@/views/StoryboardVideoCreate.vue')
const MemberManagement = () => import('@/views/MemberManagement.vue')
@@ -75,6 +75,12 @@ const routes = [
component: ImageToVideoCreate,
meta: { title: '图生视频创作', requiresAuth: true }
},
{
path: '/image-to-video/detail/:taskId',
name: 'ImageToVideoDetail',
component: ImageToVideoDetail,
meta: { title: '图生视频详情', requiresAuth: true }
},
{
path: '/storyboard-video',
name: 'StoryboardVideo',
@@ -164,12 +170,6 @@ const routes = [
component: AdminOrders,
meta: { title: '订单管理', requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/users',
name: 'AdminUsers',
component: AdminUsers,
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',

View File

@@ -51,13 +51,32 @@ export const useOrderStore = defineStore('orders', () => {
loading.value = true
const response = await getOrderById(id)
if (response.success) {
currentOrder.value = response.data
console.log('OrderStore: 获取订单详情响应:', response)
// axios会将响应包装在response.data中
const responseData = response?.data || response || {}
console.log('OrderStore: 解析后的响应数据:', responseData)
if (responseData.success && responseData.data) {
currentOrder.value = responseData.data
console.log('OrderStore: 设置后的订单详情:', currentOrder.value)
return { success: true, data: responseData.data }
} else if (responseData.success === false) {
console.error('OrderStore: API返回失败:', responseData.message)
return { success: false, message: responseData.message || '获取订单详情失败' }
} else {
// 如果没有success字段尝试直接使用data
if (responseData.id || responseData.orderNumber) {
currentOrder.value = responseData
return { success: true, data: responseData }
} else {
console.error('OrderStore: API返回数据格式错误:', responseData)
return { success: false, message: 'API返回数据格式错误' }
}
}
return response
} catch (error) {
console.error('Fetch order error:', error)
return { success: false, message: '获取订单详情失败' }
console.error('OrderStore: 获取订单详情异常:', error)
return { success: false, message: error.response?.data?.message || error.message || '获取订单详情失败' }
} finally {
loading.value = false
}

View File

@@ -3,7 +3,8 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<span class="logo-text">LOGO</span>
<div class="logo-icon"></div>
<span>LOGO</span>
</div>
<nav class="nav-menu">
@@ -11,7 +12,7 @@
<el-icon><Grid /></el-icon>
<span>数据仪表台</span>
</div>
<div class="nav-item" @click="goToUsers">
<div class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>会员管理</span>
</div>
@@ -19,15 +20,15 @@
<el-icon><ShoppingCart /></el-icon>
<span>订单管理</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon>
<span>API管理</span>
</div>
<div class="nav-item" @click="goToTasks">
<el-icon><Briefcase /></el-icon>
<el-icon><Document /></el-icon>
<span>生成任务记录</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</div>
@@ -35,11 +36,10 @@
<div class="sidebar-footer">
<div class="online-users">
<span>当前在线用户: </span>
<span class="online-count">87/500</span>
当前在线用户: <span class="highlight">87/500</span>
</div>
<div class="system-uptime">
<span>系统运行时间: 48小时32分</span>
系统运行时间: <span class="highlight">48小时32分</span>
</div>
</div>
</aside>
@@ -52,28 +52,30 @@
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你的想要的内容" class="search-input">
</div>
<div class="header-right">
<div class="notification-icon">
<el-icon><Bell /></el-icon>
<div class="notification-badge"></div>
<div class="header-actions">
<div class="notification-icon-wrapper">
<el-icon class="notification-icon"><Bell /></el-icon>
<span class="notification-badge"></span>
</div>
<div class="user-avatar">
<el-icon><Avatar /></el-icon>
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
</div>
</header>
<!-- 统计卡片 -->
<div class="stats-cards">
<div class="stats-cards" v-loading="loading">
<div class="stat-card">
<div class="stat-icon users">
<el-icon><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">用户总数</div>
<div class="stat-number">12,847</div>
<div class="stat-change positive">+12% 较上月同期</div>
<div class="stat-number">{{ formatNumber(stats.totalUsers) }}</div>
<div class="stat-change" :class="stats.totalUsersChange >= 0 ? 'positive' : 'negative'">
{{ stats.totalUsersChange >= 0 ? '+' : '' }}{{ stats.totalUsersChange }}% 较上月同期
</div>
</div>
</div>
@@ -83,8 +85,10 @@
</div>
<div class="stat-content">
<div class="stat-title">付费用户数</div>
<div class="stat-number">3,215</div>
<div class="stat-change negative">-5% 较上月同期</div>
<div class="stat-number">{{ formatNumber(stats.paidUsers) }}</div>
<div class="stat-change" :class="stats.paidUsersChange >= 0 ? 'positive' : 'negative'">
{{ stats.paidUsersChange >= 0 ? '+' : '' }}{{ stats.paidUsersChange }}% 较上月同期
</div>
</div>
</div>
@@ -94,8 +98,10 @@
</div>
<div class="stat-content">
<div class="stat-title">今日收入</div>
<div class="stat-number">¥28,450</div>
<div class="stat-change positive">+15% 较上月同期</div>
<div class="stat-number">{{ formatCurrency(stats.todayRevenue) }}</div>
<div class="stat-change" :class="stats.todayRevenueChange >= 0 ? 'positive' : 'negative'">
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% 较上月同期
</div>
</div>
</div>
</div>
@@ -106,16 +112,14 @@
<div class="chart-card">
<div class="chart-header">
<h3>日活用户趋势</h3>
<el-select v-model="selectedYear" class="year-select">
<el-select v-model="selectedYear" @change="loadDailyActiveChart" class="year-select">
<el-option label="2025年" value="2025"></el-option>
<el-option label="2024年" value="2024"></el-option>
<el-option label="2023年" value="2023"></el-option>
</el-select>
</div>
<div class="chart-content">
<div class="chart-placeholder">
<div class="chart-title">日活用户趋势图</div>
<div class="chart-description">显示每日活跃用户数量变化趋势</div>
</div>
<div ref="dailyActiveChart" style="width: 100%; height: 100%;"></div>
</div>
</div>
@@ -123,16 +127,14 @@
<div class="chart-card">
<div class="chart-header">
<h3>用户转化率</h3>
<el-select v-model="selectedYear2" class="year-select">
<el-select v-model="selectedYear2" @change="loadConversionChart" class="year-select">
<el-option label="2025年" value="2025"></el-option>
<el-option label="2024年" value="2024"></el-option>
<el-option label="2023年" value="2023"></el-option>
</el-select>
</div>
<div class="chart-content">
<div class="chart-placeholder">
<div class="chart-title">用户转化率图</div>
<div class="chart-description">显示各月份用户转化率情况</div>
</div>
<div ref="conversionChart" style="width: 100%; height: 100%;"></div>
</div>
</div>
</div>
@@ -141,7 +143,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -157,6 +159,7 @@ import {
ArrowDown,
Money
} from '@element-plus/icons-vue'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend } from '@/api/dashboard'
const router = useRouter()
@@ -164,22 +167,311 @@ const router = useRouter()
const selectedYear = ref('2025')
const selectedYear2 = ref('2025')
// 统计数据
const stats = ref({
totalUsers: 0,
paidUsers: 0,
todayRevenue: 0,
totalUsersChange: 0,
paidUsersChange: 0,
todayRevenueChange: 0
})
const loading = ref(false)
// 图表相关
const dailyActiveChart = ref(null)
const conversionChart = ref(null)
let dailyActiveChartInstance = null
let conversionChartInstance = null
// 动态加载ECharts
const loadECharts = () => {
return new Promise((resolve, reject) => {
if (window.echarts) {
resolve(window.echarts)
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'
script.onload = () => resolve(window.echarts)
script.onerror = reject
document.head.appendChild(script)
})
}
// 导航函数
const goToUsers = () => {
router.push('/admin/users')
const goToMembers = () => {
router.push('/member-management')
}
const goToOrders = () => {
router.push('/admin/orders')
}
const goToAPI = () => {
router.push('/api-management')
}
const goToTasks = () => {
router.push('/generate-task-record')
}
const goToSettings = () => {
router.push('/system-settings')
}
// 格式化数字
const formatNumber = (num) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toLocaleString('zh-CN')
}
// 格式化货币
const formatCurrency = (amount) => {
if (amount >= 10000) {
return '¥' + (amount / 10000).toFixed(1) + '万'
}
return '¥' + amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// 加载仪表盘数据
const loadDashboardData = async () => {
loading.value = true
try {
// 获取概览数据
const overviewRes = await getDashboardOverview()
console.log('仪表盘概览数据响应:', overviewRes)
// 后端直接返回Map没有success/data包装
const data = overviewRes?.data || overviewRes || {}
console.log('解析后的数据:', data)
if (data && !data.error) {
stats.value = {
totalUsers: data.totalUsers || 0,
paidUsers: data.paidUsers || 0,
todayRevenue: data.todayRevenue || 0,
// 暂时使用固定值后续可以从API获取同比数据
// TODO: 后端需要添加计算同比变化的逻辑
totalUsersChange: 0, // 暂时设为0等待后端实现
paidUsersChange: 0, // 暂时设为0等待后端实现
todayRevenueChange: 0 // 暂时设为0等待后端实现
}
console.log('设置后的统计数据:', stats.value)
} else {
console.error('获取仪表盘数据失败:', data.error || data.message)
ElMessage.error('获取仪表盘数据失败: ' + (data.message || '未知错误'))
}
} catch (error) {
console.error('加载仪表盘数据失败:', error)
ElMessage.error('加载仪表盘数据失败: ' + (error.message || '未知错误'))
} finally {
loading.value = false
}
}
// 加载日活用户趋势图
const loadDailyActiveChart = async () => {
try {
const response = await getDailyActiveUsersTrend(selectedYear.value, 'monthly')
const data = response.data || {}
if (!dailyActiveChart.value) return
const echarts = await loadECharts()
await nextTick()
if (dailyActiveChartInstance) {
dailyActiveChartInstance.dispose()
}
dailyActiveChartInstance = echarts.init(dailyActiveChart.value)
const monthlyData = data.monthlyData || []
const months = monthlyData.map(item => `${item.month}`)
const values = monthlyData.map(item => item.avgDailyActive || item.dailyActiveUsers || 0)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: months,
axisLabel: {
color: '#6b7280'
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#6b7280',
formatter: '{value}'
}
},
series: [{
name: '日活用户',
type: 'line',
smooth: true,
data: values,
itemStyle: {
color: '#3b82f6'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: 'rgba(59, 130, 246, 0.3)'
}, {
offset: 1,
color: 'rgba(59, 130, 246, 0.1)'
}]
}
}
}]
}
dailyActiveChartInstance.setOption(option)
// 响应式调整
window.addEventListener('resize', () => {
if (dailyActiveChartInstance) {
dailyActiveChartInstance.resize()
}
})
} catch (error) {
console.error('加载日活用户趋势图失败:', error)
}
}
// 加载用户转化率图
const loadConversionChart = async () => {
try {
const response = await getConversionRate(selectedYear2.value)
const data = response.data || {}
if (!conversionChart.value) return
const echarts = await loadECharts()
await nextTick()
if (conversionChartInstance) {
conversionChartInstance.dispose()
}
conversionChartInstance = echarts.init(conversionChart.value)
const monthlyData = data.monthlyData || []
const months = monthlyData.map(item => `${item.month}`)
const conversionRates = monthlyData.map(item => item.conversionRate || 0)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: (params) => {
const item = params[0]
const monthData = monthlyData[item.dataIndex]
return `${item.name}<br/>转化率: ${item.value}%<br/>总用户: ${monthData?.totalUsers || 0}<br/>付费用户: ${monthData?.paidUsers || 0}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: months,
axisLabel: {
color: '#6b7280'
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#6b7280',
formatter: '{value}%'
}
},
series: [{
name: '转化率',
type: 'bar',
data: conversionRates,
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0,
color: '#8b5cf6'
}, {
offset: 1,
color: '#3b82f6'
}]
}
}
}]
}
conversionChartInstance.setOption(option)
// 响应式调整
window.addEventListener('resize', () => {
if (conversionChartInstance) {
conversionChartInstance.resize()
}
})
} catch (error) {
console.error('加载用户转化率图失败:', error)
}
}
// 页面加载时获取数据
onMounted(() => {
onMounted(async () => {
console.log('后台管理页面加载完成')
await loadDashboardData()
await nextTick()
await loadDailyActiveChart()
await loadConversionChart()
})
// 组件卸载时清理图表
onUnmounted(() => {
if (dailyActiveChartInstance) {
dailyActiveChartInstance.dispose()
dailyActiveChartInstance = null
}
if (conversionChartInstance) {
conversionChartInstance.dispose()
conversionChartInstance = null
}
})
</script>
@@ -194,34 +486,45 @@ onMounted(() => {
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: #ffffff;
background: white;
border-right: 1px solid #e9ecef;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.logo {
padding: 24px 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
padding: 0 20px;
margin-bottom: 32px;
}
.logo-text {
font-size: 20px;
font-weight: bold;
color: #3b82f6;
.logo-icon {
width: 24px;
height: 24px;
background: #3b82f6;
border-radius: 4px;
margin-right: 12px;
}
.logo span {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.nav-menu {
flex: 1;
padding: 20px 0;
padding: 0 16px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
margin: 4px 16px;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
@@ -242,19 +545,32 @@ onMounted(() => {
.nav-item .el-icon {
margin-right: 12px;
font-size: 16px;
font-size: 18px;
}
.nav-item span {
font-size: 14px;
font-weight: 500;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto;
}
.online-users {
margin-bottom: 8px;
font-size: 13px;
color: #6b7280;
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 5px;
line-height: 1.5;
}
.highlight {
color: #333;
font-weight: bold;
}
.online-count {
@@ -318,24 +634,29 @@ onMounted(() => {
color: #9ca3af;
}
.header-right {
.header-actions {
display: flex;
align-items: center;
gap: 16px;
gap: 20px;
}
.notification-icon {
.notification-icon-wrapper {
position: relative;
padding: 8px;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.notification-icon:hover {
.notification-icon-wrapper:hover {
background: #f3f4f6;
}
.notification-icon {
font-size: 20px;
color: #6b7280;
}
.notification-badge {
position: absolute;
top: 4px;
@@ -350,8 +671,8 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
@@ -360,7 +681,14 @@ onMounted(() => {
background: #f3f4f6;
}
.arrow-down {
.user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar .arrow-down {
font-size: 12px;
color: #6b7280;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,700 +0,0 @@
<template>
<div class="admin-users">
<!-- 页面标题 -->
<div class="page-header">
<h2>
<el-icon><User /></el-icon>
用户管理 - 管理员
</h2>
</div>
<!-- 统计面板 -->
<el-row :gutter="20" class="stats-row">
<el-col :xs="12" :sm="6">
<el-card class="stat-card clickable" @click="handleStatClick('all')">
<div class="stat-content">
<div class="stat-number">{{ stats.totalUsers || 0 }}</div>
<div class="stat-label">总用户数</div>
</div>
<el-icon class="stat-icon" color="#409EFF"><User /></el-icon>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card class="stat-card clickable" @click="handleStatClick('admin')">
<div class="stat-content">
<div class="stat-number">{{ stats.adminUsers || 0 }}</div>
<div class="stat-label">管理员</div>
</div>
<el-icon class="stat-icon" color="#67C23A"><User /></el-icon>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card class="stat-card clickable" @click="handleStatClick('user')">
<div class="stat-content">
<div class="stat-number">{{ stats.normalUsers || 0 }}</div>
<div class="stat-label">普通用户</div>
</div>
<el-icon class="stat-icon" color="#E6A23C"><Avatar /></el-icon>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card class="stat-card clickable" @click="handleStatClick('today')">
<div class="stat-content">
<div class="stat-number">{{ stats.todayUsers || 0 }}</div>
<div class="stat-label">今日注册</div>
</div>
<el-icon class="stat-icon" color="#F56C6C"><Calendar /></el-icon>
</el-card>
</el-col>
</el-row>
<!-- 筛选和搜索 -->
<el-card class="filter-card">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="8">
<el-select
v-model="filters.role"
placeholder="选择用户角色"
clearable
@change="handleFilterChange"
>
<el-option label="全部角色" value="" />
<el-option label="管理员" value="ROLE_ADMIN" />
<el-option label="普通用户" value="ROLE_USER" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-input
v-model="filters.search"
placeholder="搜索用户名或邮箱"
clearable
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-button @click="resetFilters">重置筛选</el-button>
</el-col>
</el-row>
</el-card>
<!-- 用户列表 -->
<el-card class="users-card">
<template #header>
<div class="card-header">
<span>用户列表</span>
<el-button type="primary" @click="showCreateUserDialog">
<el-icon><Plus /></el-icon>
添加用户
</el-button>
</div>
</template>
<el-table
:data="users"
v-loading="loading"
empty-text="暂无用户"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
<el-table-column prop="username" label="用户名" width="150" sortable="custom">
<template #default="{ row }">
<div class="user-info">
<el-avatar :size="32">{{ row.username.charAt(0).toUpperCase() }}</el-avatar>
<div class="user-details">
<div class="username">{{ row.username }}</div>
<div class="user-id">ID: {{ row.id }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="200" sortable="custom">
<template #default="{ row }">
<span class="email">{{ row.email }}</span>
</template>
</el-table-column>
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="getRoleType(row.role)">
{{ getRoleText(row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="注册时间" width="160" sortable="custom">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最后登录" width="160">
<template #default="{ row }">
{{ row.lastLoginAt ? formatDate(row.lastLoginAt) : '从未登录' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button size="small" @click="viewUserDetail(row)">
查看
</el-button>
<el-button size="small" type="primary" @click="editUser(row)">
编辑
</el-button>
<el-button
size="small"
type="danger"
@click="deleteUser(row)"
:disabled="row.role === 'ROLE_ADMIN'"
>
删除
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 创建/编辑用户对话框 -->
<el-dialog
v-model="userDialogVisible"
:title="isEdit ? '编辑用户' : '添加用户'"
width="600px"
>
<el-form
ref="userFormRef"
:model="userForm"
:rules="userRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="userForm.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="userForm.email"
type="email"
placeholder="请输入邮箱"
/>
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="userForm.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="角色" prop="role">
<el-radio-group v-model="userForm.role">
<el-radio value="ROLE_USER">普通用户</el-radio>
<el-radio value="ROLE_ADMIN">管理员</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitUser" :loading="submitLoading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
<!-- 用户详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="用户详情"
width="600px"
>
<div v-if="currentUser">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ currentUser.id }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ currentUser.username }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ currentUser.email }}</el-descriptions-item>
<el-descriptions-item label="角色">
<el-tag :type="getRoleType(currentUser.role)">
{{ getRoleText(currentUser.role) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatDate(currentUser.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="最后登录" v-if="currentUser.lastLoginAt">
{{ formatDate(currentUser.lastLoginAt) }}
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User,
User as Search,
User as Filter,
User as Plus,
User as Edit,
User as Delete,
User as View,
User as Refresh,
User as Download,
User as Upload,
Setting,
Bell,
User as Lock,
User as Unlock
} from '@element-plus/icons-vue'
const loading = ref(false)
const users = ref([])
const submitLoading = ref(false)
// 统计数据
const stats = ref({
totalUsers: 0,
adminUsers: 0,
normalUsers: 0,
todayUsers: 0
})
// 筛选条件
const filters = reactive({
role: '',
search: '',
todayOnly: false
})
// 分页信息
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
// 排序
const sortBy = ref('createdAt')
const sortDir = ref('desc')
// 用户对话框
const userDialogVisible = ref(false)
const isEdit = ref(false)
const userFormRef = ref()
const userForm = reactive({
username: '',
email: '',
password: '',
role: 'ROLE_USER'
})
const userRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
role: [
{ required: true, message: '请选择角色', trigger: 'change' }
]
}
// 用户详情对话框
const detailDialogVisible = ref(false)
const currentUser = ref(null)
// 获取角色类型
const getRoleType = (role) => {
return role === 'ROLE_ADMIN' ? 'danger' : 'primary'
}
// 获取角色文本
const getRoleText = (role) => {
return role === 'ROLE_ADMIN' ? '管理员' : '普通用户'
}
// 格式化日期
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 获取用户列表
const fetchUsers = async () => {
try {
loading.value = true
// 调用真实API获取用户数据
const response = await api.get('/admin/users')
const data = response.data.data || []
// 根据筛选条件过滤用户
let filteredUsers = data
// 按角色筛选
if (filters.role) {
filteredUsers = filteredUsers.filter(user => user.role === filters.role)
}
// 按搜索关键词筛选
if (filters.search) {
const searchLower = filters.search.toLowerCase()
filteredUsers = filteredUsers.filter(user =>
user.username.toLowerCase().includes(searchLower) ||
user.email.toLowerCase().includes(searchLower)
)
}
// 按今日注册筛选
if (filters.todayOnly) {
const today = new Date().toISOString().split('T')[0]
filteredUsers = filteredUsers.filter(user =>
user.createdAt && user.createdAt.startsWith(today)
)
}
users.value = filteredUsers
pagination.total = filteredUsers.length
// 更新统计数据
stats.value = {
totalUsers: data.length,
adminUsers: data.filter(user => user.role === 'ROLE_ADMIN').length,
normalUsers: data.filter(user => user.role === 'ROLE_USER').length,
todayUsers: data.filter(user => {
const today = new Date().toISOString().split('T')[0]
return user.createdAt && user.createdAt.startsWith(today)
}).length
}
} catch (error) {
console.error('Fetch users error:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
// 筛选变化
const handleFilterChange = () => {
pagination.page = 1
fetchUsers()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchUsers()
}
// 重置筛选
const resetFilters = () => {
filters.role = ''
filters.search = ''
pagination.page = 1
fetchUsers()
}
// 排序变化
const handleSortChange = ({ prop, order }) => {
if (prop) {
sortBy.value = prop
sortDir.value = order === 'ascending' ? 'asc' : 'desc'
fetchUsers()
}
}
// 分页大小变化
const handleSizeChange = (size) => {
pagination.size = size
pagination.page = 1
fetchUsers()
}
// 当前页变化
const handleCurrentChange = (page) => {
pagination.page = page
fetchUsers()
}
// 显示创建用户对话框
const showCreateUserDialog = () => {
isEdit.value = false
resetUserForm()
userDialogVisible.value = true
}
// 编辑用户
const editUser = (user) => {
isEdit.value = true
userForm.username = user.username
userForm.email = user.email
userForm.role = user.role
userForm.password = ''
userDialogVisible.value = true
}
// 重置用户表单
const resetUserForm = () => {
userForm.username = ''
userForm.email = ''
userForm.password = ''
userForm.role = 'ROLE_USER'
}
// 提交用户表单
const handleSubmitUser = async () => {
if (!userFormRef.value) return
try {
const valid = await userFormRef.value.validate()
if (!valid) return
submitLoading.value = true
// 调用真实API提交
if (isEdit.value) {
await api.put(`/admin/users/${userForm.value.id}`, userForm.value)
} else {
await api.post('/admin/users', userForm.value)
}
ElMessage.success(isEdit.value ? '用户更新成功' : '用户创建成功')
userDialogVisible.value = false
fetchUsers()
} catch (error) {
console.error('Submit user error:', error)
ElMessage.error(isEdit.value ? '用户更新失败' : '用户创建失败')
} finally {
submitLoading.value = false
}
}
// 查看用户详情
const viewUserDetail = (user) => {
currentUser.value = user
detailDialogVisible.value = true
}
// 删除用户
const deleteUser = async (user) => {
try {
await ElMessageBox.confirm(`确定要删除用户 "${user.username}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('用户删除成功')
fetchUsers()
} catch (error) {
// 用户取消
}
}
// 处理统计卡片点击事件
const handleStatClick = (type) => {
switch (type) {
case 'all':
// 显示所有用户
filters.role = ''
filters.search = ''
filters.todayOnly = false
ElMessage.info('显示所有用户')
break
case 'admin':
// 筛选管理员用户
filters.role = 'ROLE_ADMIN'
filters.search = ''
filters.todayOnly = false
ElMessage.info('筛选管理员用户')
break
case 'user':
// 筛选普通用户
filters.role = 'ROLE_USER'
filters.search = ''
filters.todayOnly = false
ElMessage.info('筛选普通用户')
break
case 'today':
// 筛选今日注册用户
filters.role = ''
filters.search = ''
filters.todayOnly = true
ElMessage.info('筛选今日注册用户')
break
}
// 重新获取用户列表
fetchUsers()
}
onMounted(() => {
fetchUsers()
})
</script>
<style scoped>
.admin-users {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.stats-row {
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.3s ease;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #303133;
margin-bottom: 4px;
}
.stat-label {
color: #909399;
font-size: 14px;
}
.stat-icon {
font-size: 2rem;
opacity: 0.8;
}
.filter-card {
margin-bottom: 20px;
}
.users-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-details {
display: flex;
flex-direction: column;
}
.username {
font-weight: 500;
color: #303133;
}
.user-id {
font-size: 12px;
color: #909399;
}
.email {
color: #606266;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
@media (max-width: 768px) {
.page-header {
text-align: center;
}
.stat-card {
margin-bottom: 16px;
}
.card-header {
flex-direction: column;
gap: 16px;
}
}
</style>

View File

@@ -1,18 +1,539 @@
<template>
<div class="api-management">
<h1>API管理页面</h1>
<p>API管理功能开发中...</p>
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon"></div>
<span>LOGO</span>
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
<el-icon><Grid /></el-icon>
<span>数据仪表台</span>
</div>
<div class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>会员管理</span>
</div>
<div class="nav-item" @click="goToOrders">
<el-icon><ShoppingCart /></el-icon>
<span>订单管理</span>
</div>
<div class="nav-item active">
<el-icon><Document /></el-icon>
<span>API管理</span>
</div>
<div class="nav-item" @click="goToTasks">
<el-icon><Document /></el-icon>
<span>生成任务记录</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="online-users">
当前在线用户: <span class="highlight">87/500</span>
</div>
<div class="system-uptime">
系统运行时间: <span class="highlight">48小时32分</span>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索栏 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你想要的内容" class="search-input" />
</div>
<div class="header-actions">
<div class="notification-icon-wrapper">
<el-icon class="notification-icon"><Bell /></el-icon>
<span class="notification-badge"></span>
</div>
<div class="user-avatar">
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
</div>
</header>
<!-- API密钥输入内容 -->
<section class="api-content">
<div class="content-header">
<h2>API管理</h2>
</div>
<div class="api-form-container">
<el-form :model="apiForm" label-width="120px" class="api-form">
<el-form-item label="API密钥">
<el-input
v-model="apiForm.apiKey"
type="password"
placeholder="请输入API密钥"
show-password
style="width: 100%; max-width: 600px;"
/>
</el-form-item>
<el-form-item label="Token过期时间">
<div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 600px;">
<el-input
v-model.number="apiForm.jwtExpirationHours"
type="number"
placeholder="请输入小时数1-720"
style="flex: 1;"
:min="1"
:max="720"
/>
<span style="color: #6b7280; font-size: 14px;">小时</span>
<span style="color: #9ca3af; font-size: 12px;" v-if="apiForm.jwtExpirationHours">
({{ formatJwtExpiration(apiForm.jwtExpirationHours) }})
</span>
</div>
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
范围1-720小时1小时-30
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveApiKey" :loading="saving">保存</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Grid,
User,
ShoppingCart,
Document,
Setting,
Search,
Bell,
ArrowDown
} from '@element-plus/icons-vue'
import api from '@/api/request'
const router = useRouter()
const saving = ref(false)
const loading = ref(false)
const apiForm = reactive({
apiKey: '',
jwtExpirationHours: 24 // 默认24小时
})
// 导航功能
const goToDashboard = () => {
router.push('/admin/dashboard')
}
const goToMembers = () => {
router.push('/member-management')
}
const goToOrders = () => {
router.push('/admin/orders')
}
const goToTasks = () => {
router.push('/generate-task-record')
}
const goToSettings = () => {
router.push('/system-settings')
}
// 格式化JWT过期时间显示
const formatJwtExpiration = (hours) => {
if (!hours) return ''
if (hours < 24) {
return `${hours}小时`
} else if (hours < 720) {
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
if (remainingHours === 0) {
return `${days}`
}
return `${days}${remainingHours}小时`
} else {
return '30天'
}
}
// 加载当前API密钥和JWT配置仅显示部分
const loadApiKey = async () => {
loading.value = true
try {
const response = await api.get('/api-key')
if (response.data?.maskedKey) {
// 不显示掩码后的密钥,只用于验证
console.log('当前API密钥已配置')
}
// 加载JWT过期时间转换为小时
if (response.data?.jwtExpiration) {
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpiration / 3600000)
} else if (response.data?.jwtExpirationHours) {
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpirationHours)
}
} catch (error) {
console.error('加载配置失败:', error)
} finally {
loading.value = false
}
}
// 保存API密钥和JWT配置
const saveApiKey = async () => {
// 检查是否有任何输入
const hasApiKey = apiForm.apiKey && apiForm.apiKey.trim() !== ''
const hasJwtExpiration = apiForm.jwtExpirationHours != null && apiForm.jwtExpirationHours > 0
// 验证输入:至少需要提供一个配置项
if (!hasApiKey && !hasJwtExpiration) {
ElMessage.warning('请至少输入API密钥或设置Token过期时间')
return
}
// 验证JWT过期时间范围
if (hasJwtExpiration && (apiForm.jwtExpirationHours < 1 || apiForm.jwtExpirationHours > 720)) {
ElMessage.warning('Token过期时间必须在1-720小时之间1小时-30天')
return
}
saving.value = true
try {
const requestData = {}
// 如果提供了API密钥添加到请求中
if (hasApiKey) {
requestData.apiKey = apiForm.apiKey.trim()
}
// 如果提供了JWT过期时间转换为毫秒并添加到请求中
if (hasJwtExpiration) {
requestData.jwtExpiration = apiForm.jwtExpirationHours * 3600000 // 转换为毫秒
}
const response = await api.put('/api-key', requestData)
if (response.data?.success) {
ElMessage.success(response.data.message || '配置保存成功,请重启应用以使配置生效')
// 清空API密钥输入框保留JWT过期时间
apiForm.apiKey = ''
} else {
ElMessage.error(response.data?.error || '保存失败')
}
} catch (error) {
console.error('保存配置失败:', error)
ElMessage.error('保存失败: ' + (error.response?.data?.message || error.message || '未知错误'))
} finally {
saving.value = false
}
}
// 重置表单
const resetForm = () => {
apiForm.apiKey = ''
// 重新加载JWT过期时间
loadApiKey()
}
// 页面加载时获取当前API密钥状态
onMounted(() => {
loadApiKey()
})
</script>
<style scoped>
.api-management {
padding: 20px;
display: flex;
min-height: 100vh;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: white;
border-right: 1px solid #e9ecef;
display: flex;
flex-direction: column;
padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.logo {
display: flex;
align-items: center;
padding: 0 20px;
margin-bottom: 32px;
}
.logo-icon {
width: 24px;
height: 24px;
background: #3b82f6;
border-radius: 4px;
margin-right: 12px;
}
.logo span {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.nav-menu {
flex: 1;
padding: 0 16px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f3f4f6;
color: #374151;
}
.nav-item.active {
background: #dbeafe;
color: #3b82f6;
}
.nav-item .el-icon {
margin-right: 12px;
font-size: 18px;
}
.nav-item span {
font-size: 14px;
font-weight: 500;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto;
}
.online-users,
.system-uptime {
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight {
color: #3b82f6;
font-weight: 600;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
/* 顶部搜索栏 */
.top-header {
background: white;
border-bottom: 1px solid #e9ecef;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.notification-icon-wrapper {
position: relative;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.notification-icon-wrapper:hover {
background: #f3f4f6;
}
.notification-icon {
font-size: 20px;
color: #6b7280;
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
border: 2px solid white;
}
.user-avatar {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.user-avatar:hover {
background: #f3f4f6;
}
.user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar .arrow-down {
font-size: 12px;
color: #6b7280;
}
/* API内容区域 */
.api-content {
padding: 24px;
flex: 1;
background: white;
margin: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.content-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.content-header h2 {
font-size: 24px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.api-form-container {
max-width: 800px;
}
.api-form {
background: #f9fafb;
padding: 32px;
border-radius: 8px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.api-management {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
}
.nav-menu {
display: flex;
overflow-x: auto;
padding: 0 16px;
}
.nav-item {
white-space: nowrap;
margin-right: 16px;
margin-bottom: 0;
}
.sidebar-footer {
display: none;
}
.search-input {
width: 200px;
}
.api-content {
padding: 16px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -57,11 +57,30 @@
</div>
<div class="works-grid">
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
<div class="work-thumbnail">
<img :src="work.cover" :alt="work.title" />
<!-- 优先使用首帧作为封面如果没有则使用视频 -->
<img
v-if="work.firstFrameUrl"
:src="work.firstFrameUrl"
:alt="work.title || work.prompt"
class="work-image-thumbnail"
/>
<video
v-else-if="work.resultUrl"
:src="work.resultUrl"
class="work-video-thumbnail"
preload="metadata"
muted
@mouseenter="playPreview($event)"
@mouseleave="pausePreview($event)"
></video>
<!-- 如果都没有显示占位符 -->
<div v-else class="work-placeholder">
<div class="play-icon"></div>
</div>
<div class="work-overlay">
<div class="overlay-text">{{ work.text }}</div>
<div class="overlay-text">{{ work.prompt || work.text || '图生视频' }}</div>
</div>
<!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)">
@@ -72,8 +91,8 @@
</div>
</div>
<div class="work-info">
<div class="work-title">{{ work.title }}</div>
<div class="work-meta">{{ work.id }} · {{ work.size }}</div>
<div class="work-title">{{ work.prompt || work.title || '图生视频' }}</div>
<div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
</div>
<div class="work-actions" v-if="index === 0">
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button>
@@ -149,6 +168,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
const router = useRouter()
@@ -157,35 +177,7 @@ const detailDialogVisible = ref(false)
const selectedItem = ref(null)
// 已发布作品数据
const publishedWorks = ref([
{
id: '2995000000001',
title: '图生视频作品 #1',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '图生视频',
createTime: '2025/01/15 14:30'
},
{
id: '2995000000002',
title: '图生视频作品 #2',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '图生视频',
createTime: '2025/01/14 16:45'
},
{
id: '2995000000003',
title: '图生视频作品 #3',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '图生视频',
createTime: '2025/01/13 09:20'
}
])
const publishedWorks = ref([])
// 导航函数
const goToProfile = () => {
@@ -235,8 +227,63 @@ const createSimilar = () => {
router.push('/image-to-video/create')
}
// 格式化文件大小
const formatSize = (work) => {
if (work.size) return work.size
return '未知大小'
}
// 播放预览(鼠标悬停时)
const playPreview = (event) => {
const video = event.target
if (video && video.tagName === 'VIDEO') {
video.currentTime = 0
video.play().catch(() => {
// 忽略自动播放失败
})
}
}
// 暂停预览(鼠标离开时)
const pausePreview = (event) => {
const video = event.target
if (video && video.tagName === 'VIDEO') {
video.pause()
video.currentTime = 0
}
}
// 加载任务列表
const loadTasks = async () => {
try {
const response = await imageToVideoApi.getTasks(0, 20)
if (response.data && response.data.success && response.data.data) {
// 只显示已完成的任务
publishedWorks.value = response.data.data
.filter(task => task.status === 'COMPLETED' && (task.resultUrl || task.firstFrameUrl))
.map(task => ({
taskId: task.taskId,
prompt: task.prompt,
resultUrl: task.resultUrl,
firstFrameUrl: task.firstFrameUrl,
status: task.status,
createdAt: task.createdAt,
id: task.taskId,
title: task.prompt || '图生视频',
text: task.prompt || '图生视频',
category: '图生视频',
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : ''
}))
}
} catch (error) {
console.error('加载任务列表失败:', error)
ElMessage.error('加载任务列表失败')
}
}
onMounted(() => {
// 页面初始化
// 页面初始化时加载任务列表
loadTasks()
})
</script>
@@ -461,12 +508,45 @@ onMounted(() => {
overflow: hidden;
}
.work-thumbnail img {
.work-thumbnail img,
.work-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
}
.work-image-thumbnail {
display: block;
background: #000;
}
.work-video-thumbnail {
display: block;
background: #000;
}
.work-placeholder {
width: 100%;
height: 100%;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.play-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
backdrop-filter: blur(8px);
}
/* 鼠标悬停时显示的做同款按钮 */
.hover-create-btn {
position: absolute;

View File

@@ -91,10 +91,7 @@
<div class="setting-item">
<label>时长</label>
<select v-model="duration" class="setting-select">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
<option value="30">30s</option>
</select>
</div>
@@ -154,7 +151,7 @@
<!-- 视频播放区域 -->
<div class="video-player-container">
<div class="video-player">
<div class="video-player" :style="getVideoPlayerStyle()">
<video
v-if="currentTask.resultUrl"
:src="currentTask.resultUrl"
@@ -219,10 +216,6 @@
</div>
</div>
<!-- 任务控制 -->
<div class="task-controls" v-if="inProgress">
<button class="cancel-btn" @click="cancelTask">取消任务</button>
</div>
</div>
<!-- 默认提示 -->
@@ -285,7 +278,7 @@ const userStore = useUserStore()
// 表单数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const duration = ref('5')
const duration = ref('10')
const hdMode = ref(false)
const inProgress = ref(false)
@@ -331,7 +324,7 @@ const goToTextToVideo = () => {
}
const goToStoryboard = () => {
alert('分镜视频功能开发中')
router.push('/storyboard-video/create')
}
// 用户菜单相关方法
@@ -373,9 +366,10 @@ const uploadFirstFrame = () => {
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
// 验证文件大小最大10MB
if (file.size > 10 * 1024 * 1024) {
ElMessage.error('图片文件大小不能超过10MB')
// 验证文件大小最大100MB,与后端配置保持一致
const maxFileSize = 100 * 1024 * 1024 // 100MB
if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
return
}
@@ -403,9 +397,10 @@ const uploadLastFrame = () => {
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
// 验证文件大小最大10MB
if (file.size > 10 * 1024 * 1024) {
ElMessage.error('图片文件大小不能超过10MB')
// 验证文件大小最大100MB,与后端配置保持一致
const maxFileSize = 100 * 1024 * 1024 // 100MB
if (file.size > maxFileSize) {
ElMessage.error('图片文件大小不能超过100MB')
return
}
@@ -519,6 +514,13 @@ const startPollingTask = () => {
if (progressData && progressData.status) {
taskStatus.value = progressData.status
}
// 更新resultUrl如果API返回了且不为空
if (progressData && progressData.resultUrl && progressData.resultUrl.trim()) {
if (currentTask.value) {
currentTask.value.resultUrl = progressData.resultUrl
console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
}
}
console.log('任务进度:', progressData)
},
// 完成回调
@@ -526,6 +528,15 @@ const startPollingTask = () => {
inProgress.value = false
taskProgress.value = 100
taskStatus.value = 'COMPLETED'
// 更新currentTask的resultUrl
if (taskData && taskData.resultUrl && taskData.resultUrl.trim()) {
if (currentTask.value) {
currentTask.value.resultUrl = taskData.resultUrl
console.log('任务完成resultUrl已更新:', taskData.resultUrl.substring(0, 50) + '...')
}
} else if (currentTask.value && !currentTask.value.resultUrl) {
console.warn('任务完成但未获取到resultUrl')
}
ElMessage.success('视频生成完成!')
// 可以在这里跳转到结果页面或显示结果
@@ -541,32 +552,6 @@ const startPollingTask = () => {
)
}
// 取消任务
const cancelTask = async () => {
if (!currentTask.value) return
try {
const response = await imageToVideoApi.cancelTask(currentTask.value.taskId)
if (response.data && response.data.success) {
inProgress.value = false
taskStatus.value = 'CANCELLED'
ElMessage.success('任务已取消')
// 停止轮询
if (stopPolling.value) {
stopPolling.value()
stopPolling.value = null
}
} else {
ElMessage.error(response.data?.message || '取消失败')
}
} catch (error) {
console.error('取消任务失败:', error)
ElMessage.error('取消任务失败')
}
}
// 获取状态文本
const getStatusText = (status) => {
@@ -604,6 +589,21 @@ const formatDate = (dateString) => {
return `${year}${month}${day}${hours}:${minutes}`
}
// 根据aspectRatio获取视频播放器样式
const getVideoPlayerStyle = () => {
// 获取当前任务的aspectRatio如果没有则使用默认值
const ratio = currentTask.value?.aspectRatio || aspectRatio.value || '16:9'
// 将比例字符串转换为数字(如 "16:9" -> 16/9 = 1.777...
const [width, height] = ratio.split(':').map(Number)
const aspectRatioValue = width / height
return {
aspectRatio: `${width} / ${height}`,
maxHeight: '70vh' // 限制最大高度,避免视频过大
}
}
// 优化提示词
const optimizePromptHandler = async () => {
if (!inputText.value.trim()) {
@@ -1404,22 +1404,6 @@ onUnmounted(() => {
gap: 12px;
}
.cancel-btn {
background: #ef4444;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: #dc2626;
transform: translateY(-1px);
}
/* 任务描述样式 */
.task-description {
@@ -1528,10 +1512,13 @@ onUnmounted(() => {
.video-player {
position: relative;
width: 100%;
height: 100%;
/* height 由 aspect-ratio 动态计算 */
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.result-video {
@@ -1539,6 +1526,7 @@ onUnmounted(() => {
height: 100%;
object-fit: contain;
border-radius: 12px;
display: block;
}
.no-video-placeholder {

View File

@@ -147,14 +147,16 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { imageToVideoApi } from '@/api/imageToVideo'
import {
User, Setting, Bell, Document, User as Picture, User as VideoPlay, User as VideoPause,
User as FullScreen, User as Share, User as Download, User as Delete, User as ArrowUp, User as ArrowDown
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const videoRef = ref(null)
// 视频播放状态
@@ -162,17 +164,20 @@ const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const showControls = ref(true)
const loading = ref(true)
// 详情数据
const detailInput = ref('')
const videoData = ref({
id: '2995697841305810',
videoUrl: '/images/backgrounds/welcome.jpg', // 临时使用图片实际应该是视频URL
description: '图1在图2中奔跑视频',
createTime: '2025/10/17 13:41',
id: '',
videoUrl: '',
description: '',
createTime: '',
duration: 5,
resolution: '1080p',
aspectRatio: '16:9'
aspectRatio: '16:9',
status: 'PROCESSING',
progress: 0
})
const thumbnails = ref([
@@ -254,7 +259,54 @@ const resetControlsTimer = () => {
}, 3000)
}
// 加载任务详情
const loadTaskDetail = async () => {
const taskId = route.params.taskId
if (!taskId) {
ElMessage.error('任务ID不存在')
router.push('/image-to-video')
return
}
try {
loading.value = true
const response = await imageToVideoApi.getTaskDetail(taskId)
if (response.data && response.data.success && response.data.data) {
const task = response.data.data
videoData.value = {
id: task.taskId || taskId,
videoUrl: task.resultUrl || '',
description: task.prompt || '',
createTime: task.createdAt || new Date().toISOString(),
duration: task.duration || 5,
resolution: task.hdMode ? '1080p' : '720p',
aspectRatio: task.aspectRatio || '16:9',
status: task.status || 'PROCESSING',
progress: task.progress || 0
}
// 如果任务已完成且有视频URL设置视频源
if (task.status === 'COMPLETED' && task.resultUrl) {
videoData.value.videoUrl = task.resultUrl
}
} else {
ElMessage.error(response.data?.message || '获取任务详情失败')
router.push('/image-to-video')
}
} catch (error) {
console.error('加载任务详情失败:', error)
ElMessage.error('加载任务详情失败,请稍后重试')
router.push('/image-to-video')
} finally {
loading.value = false
}
}
onMounted(() => {
// 加载任务详情
loadTaskDetail()
// 监听鼠标移动来显示/隐藏控制栏
document.addEventListener('mousemove', resetControlsTimer)
resetControlsTimer()

View File

@@ -48,13 +48,16 @@
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" placeholder="搜索你想要的内容" class="search-input" />
<input type="text" placeholder="搜索你想要的内容" class="search-input" />
</div>
<div class="header-actions">
<el-icon class="notification-icon"><Bell /></el-icon>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
<div class="notification-icon-wrapper">
<el-icon class="notification-icon"><Bell /></el-icon>
<span class="notification-badge"></span>
</div>
<div class="user-avatar">
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
</div>
</header>
@@ -97,7 +100,6 @@
<th>剩余资源点</th>
<th>到期时间</th>
<th>编辑</th>
<th>删除</th>
</tr>
</thead>
<tbody>
@@ -118,10 +120,8 @@
<td>{{ member.points.toLocaleString() }}</td>
<td>{{ member.expiryDate }}</td>
<td>
<button class="action-btn edit-btn" @click="editMember(member)">编辑</button>
</td>
<td>
<button class="action-btn delete-btn" @click="deleteMember(member)">删除</button>
<el-link type="primary" class="action-link" @click="editMember(member)">编辑</el-link>
<el-link type="danger" class="action-link" @click="deleteMember(member)">删除</el-link>
</td>
</tr>
</tbody>
@@ -131,7 +131,7 @@
<!-- 分页 -->
<div class="pagination-container">
<div class="pagination">
<button class="page-btn" @click="prevPage" :disabled="currentPage === 1"></button>
<el-icon class="page-arrow" @click="prevPage" :class="{ disabled: currentPage === 1 }"><ArrowLeft /></el-icon>
<button
v-for="page in visiblePages"
:key="page"
@@ -140,7 +140,16 @@
@click="goToPage(page)">
{{ page }}
</button>
<button class="page-btn" @click="nextPage" :disabled="currentPage === totalPages"></button>
<template v-if="totalPages > 7 && currentPage < totalPages - 2">
<span class="page-ellipsis">...</span>
<button
class="page-btn"
:class="{ active: totalPages === currentPage }"
@click="goToPage(totalPages)">
{{ totalPages }}
</button>
</template>
<el-icon class="page-arrow" @click="nextPage" :class="{ disabled: currentPage === totalPages }"><ArrowRight /></el-icon>
</div>
</div>
</section>
@@ -205,11 +214,12 @@ import {
ShoppingCart,
Document,
Setting,
User as Search,
Search,
Bell,
User as ArrowDown,
User as Edit,
User as Delete
ArrowDown,
ArrowLeft,
ArrowRight,
Delete
} from '@element-plus/icons-vue'
import * as memberAPI from '@/api/members'
@@ -257,11 +267,11 @@ const memberList = ref([])
// 导航功能
const goToDashboard = () => {
router.push('/')
router.push('/admin/dashboard')
}
const goToOrders = () => {
router.push('/orders')
router.push('/admin/orders')
}
const goToAPI = () => {
@@ -287,11 +297,32 @@ const totalPages = computed(() => {
const visiblePages = computed(() => {
const pages = []
const start = Math.max(1, currentPage.value - 2)
const end = Math.min(totalPages.value, start + 4)
const total = totalPages.value
const current = currentPage.value
for (let i = start; i <= end; i++) {
pages.push(i)
if (total <= 7) {
// 如果总页数少于等于7显示所有页码
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 如果总页数大于7显示部分页码
if (current <= 3) {
// 当前页在前3页
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
} else if (current >= total - 2) {
// 当前页在后3页
for (let i = total - 4; i <= total; i++) {
pages.push(i)
}
} else {
// 当前页在中间
for (let i = current - 2; i <= current + 2; i++) {
pages.push(i)
}
}
}
return pages
})
@@ -466,22 +497,29 @@ const loadMembers = async () => {
level: selectedLevel.value === 'all' ? '' : selectedLevel.value
})
// 处理API响应数据
if (response && response.list) {
memberList.value = response.list.map(member => ({
console.log('获取会员列表响应:', response)
// 处理API响应数据 - axios会将数据包装在response.data中
const data = response?.data || response || {}
console.log('解析后的数据:', data)
if (data && data.list) {
memberList.value = data.list.map(member => ({
id: member.id,
username: member.username,
level: getMembershipLevel(member.membership),
points: member.points,
points: member.points || 0,
expiryDate: getMembershipExpiry(member.membership)
}))
totalMembers.value = response.total || 0
totalMembers.value = data.total || 0
console.log('设置后的会员列表:', memberList.value)
} else {
console.error('API返回数据格式错误:', data)
ElMessage.error('API返回数据格式错误')
}
} catch (error) {
console.error('加载会员数据失败:', error)
ElMessage.error('加载会员数据失败')
ElMessage.error('加载会员数据失败: ' + (error.message || '未知错误'))
}
}
@@ -507,24 +545,25 @@ onMounted(() => {
.member-management {
display: flex;
min-height: 100vh;
background: #f8fafc;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 左侧导航栏 */
.sidebar {
width: 320px;
width: 240px;
background: white;
border-right: 1px solid #e2e8f0;
border-right: 1px solid #e9ecef;
display: flex;
flex-direction: column;
padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.logo {
display: flex;
align-items: center;
padding: 0 28px;
padding: 0 20px;
margin-bottom: 32px;
}
@@ -544,58 +583,60 @@ onMounted(() => {
.nav-menu {
flex: 1;
padding: 0 24px;
padding: 0 16px;
}
.nav-item {
display: flex;
align-items: center;
padding: 18px 24px;
margin-bottom: 6px;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 16px;
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
background: #f3f4f6;
color: #374151;
}
.nav-item.active {
background: #eff6ff;
background: #dbeafe;
color: #3b82f6;
}
.nav-item .el-icon {
margin-right: 16px;
font-size: 22px;
margin-right: 12px;
font-size: 18px;
}
.nav-item span {
font-size: 16px;
font-size: 14px;
font-weight: 500;
}
.sidebar-footer {
padding: 0 32px 20px;
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto;
}
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 10px;
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight {
color: #3b82f6;
font-weight: 600;
font-size: 15px;
}
/* 主内容区域 */
@@ -603,17 +644,18 @@ onMounted(() => {
flex: 1;
display: flex;
flex-direction: column;
background: #f8fafc;
background: #f8f9fa;
}
/* 顶部搜索栏 */
.top-header {
background: white;
border-bottom: 1px solid #e2e8f0;
border-bottom: 1px solid #e9ecef;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
@@ -625,46 +667,77 @@ onMounted(() => {
.search-icon {
position: absolute;
left: 12px;
color: #94a3b8;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 8px 12px 8px 40px;
border: 1px solid #e2e8f0;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: #f8fafc;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
background: white;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #94a3b8;
color: #9ca3af;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
gap: 20px;
}
.notification-icon,
.help-icon {
font-size: 20px;
color: #64748b;
.notification-icon-wrapper {
position: relative;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.notification-icon-wrapper:hover {
background: #f3f4f6;
}
.notification-icon {
font-size: 20px;
color: #6b7280;
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
border: 2px solid white;
}
.user-avatar {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.user-avatar:hover {
background: #f3f4f6;
}
.user-avatar img {
@@ -674,10 +747,18 @@ onMounted(() => {
object-fit: cover;
}
.user-avatar .arrow-down {
font-size: 12px;
color: #6b7280;
}
/* 会员内容区域 */
.member-content {
padding: 24px;
flex: 1;
background: white;
margin: 24px;
border-radius: 8px;
}
.content-header {
@@ -719,7 +800,7 @@ onMounted(() => {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
margin-bottom: 24px;
}
@@ -730,7 +811,7 @@ onMounted(() => {
}
.member-table thead {
background: #f8fafc;
background: #f9fafb;
}
.member-table th {
@@ -772,53 +853,43 @@ onMounted(() => {
}
.level-tag.professional {
background: #ec4899;
background: #8b5cf6;
color: white;
}
.level-tag.standard {
background: #3b82f6;
color: white;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
.action-link {
margin-right: 12px;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s ease;
text-decoration: none;
}
.edit-btn {
color: #3b82f6;
}
.edit-btn:hover {
background: #eff6ff;
}
.delete-btn {
color: #dc2626;
}
.delete-btn:hover {
background: #fef2f2;
.action-link:last-child {
margin-right: 0;
}
.pagination-container {
display: flex;
justify-content: center;
justify-content: flex-end;
margin-top: 24px;
}
.pagination {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
}
.page-btn {
padding: 8px 12px;
.page-arrow {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d1d5db;
background: white;
color: #374151;
@@ -828,6 +899,32 @@ onMounted(() => {
transition: all 0.2s ease;
}
.page-arrow:hover:not(.disabled) {
background: #f3f4f6;
border-color: #9ca3af;
}
.page-arrow.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn {
min-width: 32px;
height: 32px;
padding: 0 12px;
border: 1px solid #d1d5db;
background: white;
color: #374151;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
background: #f3f4f6;
border-color: #9ca3af;
@@ -844,6 +941,12 @@ onMounted(() => {
cursor: not-allowed;
}
.page-ellipsis {
padding: 0 8px;
color: #6b7280;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.member-management {

View File

@@ -51,7 +51,7 @@
<div class="header-right">
<div class="points">
<el-icon><Star /></el-icon>
<span>25 | 首购优惠</span>
<span>{{ userInfo.points - (userInfo.frozenPoints || 0) }} | 首购优惠</span>
</div>
<div class="notifications">
<el-icon><Bell /></el-icon>
@@ -67,12 +67,13 @@
<section class="profile-section">
<div class="profile-info">
<div class="avatar">
<div class="avatar-icon"></div>
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="avatar" class="avatar-image" />
<div v-else class="avatar-icon"></div>
</div>
<div class="user-details">
<h2 class="username">mingzi_FBx7foZYDS7inLQb</h2>
<p class="profile-status">还没有设置个人简介,点击填写</p>
<p class="user-id">ID 2994509784706419</p>
<h2 class="username">{{ userInfo.nickname || userInfo.username || '未设置用户名' }}</h2>
<p class="profile-status" v-if="userInfo.bio">{{ userInfo.bio }}</p>
<p class="user-id">ID {{ userInfo.id || '加载中...' }}</p>
</div>
</div>
</section>
@@ -166,6 +167,7 @@ import {
Film
} from '@element-plus/icons-vue'
import { getMyWorks } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
const router = useRouter()
const userStore = useUserStore()
@@ -174,6 +176,18 @@ const userStore = useUserStore()
const showUserMenu = ref(false)
const userStatusRef = ref(null)
// 用户信息
const userInfo = ref({
username: '',
nickname: '',
bio: '',
avatar: '',
id: '',
points: 0,
frozenPoints: 0
})
const userLoading = ref(false)
// 视频数据
const videos = ref([])
const loading = ref(false)
@@ -288,6 +302,37 @@ const transformWorkData = (work) => {
}
}
// 加载用户信息
const loadUserInfo = async () => {
userLoading.value = true
try {
const response = await getCurrentUser()
console.log('获取用户信息响应:', response)
if (response && response.data && response.data.success && response.data.data) {
const user = response.data.data
console.log('用户数据:', user)
userInfo.value = {
username: user.username || '',
nickname: user.nickname || user.username || '',
bio: user.bio || '',
avatar: user.avatar || '',
id: user.id ? String(user.id) : '',
points: user.points || 0,
frozenPoints: user.frozenPoints || 0
}
console.log('设置后的用户信息:', userInfo.value)
} else {
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
ElMessage.error('获取用户信息失败')
}
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败: ' + (error.message || '未知错误'))
} finally {
userLoading.value = false
}
}
// 加载用户作品列表
const loadVideos = async () => {
loading.value = true
@@ -296,21 +341,31 @@ const loadVideos = async () => {
page: 0,
size: 6 // 只加载前6个作品
})
console.log('获取作品列表响应:', response)
if (response.data.success) {
if (response && response.data && response.data.success) {
const data = response.data.data || []
console.log('作品数据:', data)
// 转换数据格式
videos.value = data.map(transformWorkData)
console.log('转换后的作品列表:', videos.value)
} else {
console.error('获取作品列表失败:', response.data.message)
console.error('获取作品列表失败:', response?.data?.message || '未知错误')
}
} catch (error) {
console.error('加载作品列表失败:', error)
ElMessage.error('加载作品列表失败: ' + (error.message || '未知错误'))
} finally {
loading.value = false
}
}
// 编辑个人资料
const editProfile = () => {
// TODO: 可以跳转到编辑页面或打开编辑对话框
ElMessage.info('个人简介编辑功能待实现')
}
// 点击外部关闭菜单
const handleClickOutside = (event) => {
const userStatus = event.target.closest('.user-status')
@@ -343,6 +398,7 @@ const onVideoLoaded = (event) => {
onMounted(() => {
document.addEventListener('click', handleClickOutside)
loadUserInfo()
loadVideos()
})
@@ -642,6 +698,13 @@ onUnmounted(() => {
border-radius: 2px;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-details {
flex: 1;
}

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,7 @@
<div class="package-header">
<h4 class="package-title">免费版</h4>
</div>
<div class="package-price">$0/</div>
<div class="package-price">${{ membershipPrices.free }}/</div>
<button class="package-button current">当前套餐</button>
<div class="package-features">
<div class="feature-item">
@@ -127,7 +127,7 @@
<h4 class="package-title">标准版</h4>
<div class="discount-tag">首购低至8.5</div>
</div>
<div class="package-price">$59/</div>
<div class="package-price">${{ membershipPrices.standard }}/</div>
<div class="points-box">每月200积分</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">立即订阅</button>
<div class="package-features">
@@ -152,7 +152,7 @@
<h4 class="package-title">专业版</h4>
<div class="value-tag">超值之选</div>
</div>
<div class="package-price">$259/</div>
<div class="package-price">${{ membershipPrices.premium }}/</div>
<div class="points-box">每月1000积分</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">立即订阅</button>
<div class="package-features">
@@ -180,46 +180,55 @@
</main>
</div>
<!-- 订单详情模态框 -->
<!-- 积分详情模态框 -->
<el-dialog
v-model="orderDialogVisible"
title="订单详情"
v-model="pointsHistoryDialogVisible"
title="积分使用情况"
width="80%"
class="order-dialog"
class="points-history-dialog"
:modal="true"
:close-on-click-modal="true"
:close-on-press-escape="true"
@close="handleOrderDialogClose"
@close="handlePointsHistoryDialogClose"
>
<div class="order-content">
<div class="order-summary">
<h3>账户订单总览</h3>
<div class="points-history-content">
<div class="points-summary">
<h3>积分使用总览</h3>
<div class="summary-stats">
<div class="stat-item">
<span class="stat-label">订单数</span>
<span class="stat-value">{{ orders.length }}</span>
<span class="stat-label">充值</span>
<span class="stat-value positive">+{{ totalRecharge || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">金额</span>
<span class="stat-value">¥{{ totalAmount }}</span>
<span class="stat-label">消耗</span>
<span class="stat-value negative">{{ totalConsume || 0 }}</span>
</div>
<div class="stat-item">
<span class="stat-label">当前积分</span>
<span class="stat-value current">{{ userInfo.points || 0 }}</span>
</div>
</div>
</div>
<div class="orders-list">
<div class="order-item" v-for="order in orders" :key="order.id">
<div class="order-header">
<span class="order-id">订单号{{ order.id }}</span>
<span class="order-status" :class="order.status">{{ order.statusText }}</span>
<div class="points-history-list" v-loading="pointsHistoryLoading">
<div v-if="pointsHistory.length === 0 && !pointsHistoryLoading" class="empty-history">
<p>暂无积分使用记录</p>
</div>
<div class="history-item" v-for="(item, index) in pointsHistory" :key="index">
<div class="history-header">
<span class="history-type" :class="item.type === '充值' ? 'recharge' : 'consume'">
{{ item.type }}
</span>
<span class="history-points" :class="item.points > 0 ? 'positive' : 'negative'">
{{ item.points > 0 ? '+' : '' }}{{ item.points }}
</span>
</div>
<div class="order-details">
<div class="order-info">
<p><strong>创建时间</strong>{{ order.createdAt }}</p>
<p><strong>订单类型</strong>{{ order.type }}</p>
<p><strong>金额</strong>¥{{ order.amount }}</p>
</div>
<div class="order-actions">
<el-button type="primary" size="small" @click="viewOrderDetail(order)">查看详情</el-button>
<div class="history-details">
<div class="history-info">
<p><strong>描述</strong>{{ item.description }}</p>
<p><strong>时间</strong>{{ formatDateTime(item.time) }}</p>
<p v-if="item.orderNumber"><strong>订单号</strong>{{ item.orderNumber }}</p>
<p v-if="item.taskId"><strong>任务ID</strong>{{ item.taskId }}</p>
</div>
</div>
</div>
@@ -245,6 +254,8 @@ import PaymentModal from '@/components/PaymentModal.vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
import { getPointsHistory } from '@/api/points'
import { getMembershipLevels } from '@/api/members'
import { useUserStore } from '@/stores/user'
import {
User,
@@ -277,6 +288,13 @@ const subscriptionInfo = ref({
paidAt: null
})
// 会员等级价格配置
const membershipPrices = ref({
free: 0,
standard: 59,
premium: 259
})
// 加载用户订阅信息
const loadUserSubscriptionInfo = async () => {
try {
@@ -337,6 +355,8 @@ const loadUserSubscriptionInfo = async () => {
console.log('用户信息加载成功:', userInfo.value)
console.log('订阅信息加载成功:', subscriptionInfo.value)
console.log('后端返回的 currentPlan:', data.currentPlan)
console.log('设置后的 subscriptionInfo.currentPlan:', subscriptionInfo.value.currentPlan)
} else {
// 如果响应结构不同尝试直接使用response.data
console.warn('响应格式不符合预期尝试直接使用response.data')
@@ -356,6 +376,8 @@ const loadUserSubscriptionInfo = async () => {
paidAt: data.paidAt || null
}
console.log('用户信息加载成功(备用路径):', userInfo.value)
console.log('后端返回的 currentPlan备用路径:', data.currentPlan)
console.log('设置后的 subscriptionInfo.currentPlan备用路径:', subscriptionInfo.value.currentPlan)
} else {
console.error('获取用户订阅信息失败: 响应数据为空或格式不正确')
console.error('完整响应:', JSON.stringify(response.data, null, 2))
@@ -387,8 +409,41 @@ const loadUserSubscriptionInfo = async () => {
}
}
// 加载会员等级价格配置
const loadMembershipPrices = async () => {
try {
const response = await getMembershipLevels()
const levels = response.data?.data || response.data || []
// 映射后端数据到前端价格配置
levels.forEach(level => {
const name = (level.name || level.displayName || '').toLowerCase()
if (name.includes('免费') || name.includes('free')) {
membershipPrices.value.free = level.price || 0
} else if (name.includes('标准') || name.includes('standard')) {
membershipPrices.value.standard = level.price || 59
} else if (name.includes('专业') || name.includes('premium') || name.includes('professional')) {
membershipPrices.value.premium = level.price || 259
}
})
console.log('会员等级价格配置加载成功:', membershipPrices.value)
} catch (error) {
console.error('加载会员等级价格配置失败:', error)
// 使用默认值
membershipPrices.value = {
free: 0,
standard: 59,
premium: 259
}
}
}
// 组件挂载时加载数据
onMounted(async () => {
// 先加载会员等级价格配置(不需要登录)
await loadMembershipPrices()
// 确保用户store已初始化
if (!userStore.initialized) {
await userStore.init()
@@ -436,55 +491,72 @@ const goToStoryboardVideo = () => {
router.push('/storyboard-video/create')
}
// 订单模态框相关
const orderDialogVisible = ref(false)
// 积分历史模态框相关
const pointsHistoryDialogVisible = ref(false)
const pointsHistoryLoading = ref(false)
const pointsHistory = ref([])
const paymentModalVisible = ref(false)
const currentPaymentData = ref({})
const orders = ref([
{
id: 'ORD-2024-001',
status: 'completed',
statusText: '已完成',
createdAt: '2024-01-15 10:30:00',
type: '标准版订阅',
amount: 59.00
},
{
id: 'ORD-2024-002',
status: 'pending',
statusText: '待支付',
createdAt: '2024-01-20 14:20:00',
type: '专业版订阅',
amount: 259.00
},
{
id: 'ORD-2024-003',
status: 'completed',
statusText: '已完成',
createdAt: '2024-01-25 09:15:00',
type: '积分充值',
amount: 100.00
}
])
// 计算总金额
const totalAmount = computed(() => {
return orders.value.reduce((sum, order) => sum + order.amount, 0).toFixed(2)
// 计算总充值和总消耗
const totalRecharge = computed(() => {
return pointsHistory.value
.filter(item => item.type === '充值')
.reduce((sum, item) => sum + (item.points || 0), 0)
})
// 显示订单详情模态框
const goToOrderDetails = () => {
orderDialogVisible.value = true
const totalConsume = computed(() => {
return Math.abs(pointsHistory.value
.filter(item => item.type === '消耗')
.reduce((sum, item) => sum + (item.points || 0), 0))
})
// 显示积分详情模态框
const goToOrderDetails = async () => {
pointsHistoryDialogVisible.value = true
await loadPointsHistory()
}
// 关闭订单模态框
const handleOrderDialogClose = () => {
orderDialogVisible.value = false
// 加载积分使用历史
const loadPointsHistory = async () => {
pointsHistoryLoading.value = true
try {
const response = await getPointsHistory({
page: 0,
size: 100 // 加载最近100条记录
})
if (response.data.success) {
pointsHistory.value = response.data.data || []
} else {
console.error('获取积分使用历史失败:', response.data.message)
ElMessage.error('获取积分使用历史失败')
}
} catch (error) {
console.error('加载积分使用历史失败:', error)
ElMessage.error('加载积分使用历史失败')
} finally {
pointsHistoryLoading.value = false
}
}
// 查看订单详情
const viewOrderDetail = (order) => {
// 这里可以添加查看订单详情的逻辑
// 关闭积分历史模态框
const handlePointsHistoryDialogClose = () => {
pointsHistoryDialogVisible.value = false
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return ''
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 跳转到我的作品页面
@@ -1067,25 +1139,37 @@ const createSubscriptionOrder = async (planType, planInfo) => {
.top-merged-card .row-bottom { grid-template-columns: 1fr; gap: 12px; }
}
/* 订单详情模态框样式 */
.order-dialog {
/* 积分详情模态框样式 */
.points-history-dialog {
background: #1a1a1a;
color: white;
}
.order-content {
background: #1a1a1a;
color: white;
/* 修改对话框边框为淡蓝色 */
.points-history-dialog :deep(.el-dialog) {
border: 1px solid #87ceeb !important; /* 淡蓝色边框 */
}
.order-summary {
.points-history-dialog :deep(.el-dialog__header) {
border-bottom: 1px solid #87ceeb !important; /* 标题下方边框 */
}
.points-history-content {
background: #1a1a1a;
color: white;
border: 1px solid #87ceeb; /* 淡蓝色边框 */
border-radius: 4px;
padding: 20px;
}
.points-summary {
margin-bottom: 30px;
padding: 20px;
background: #2a2a2a;
border-radius: 8px;
}
.order-summary h3 {
.points-summary h3 {
color: white;
margin: 0 0 15px 0;
font-size: 18px;
@@ -1108,66 +1192,100 @@ const createSubscriptionOrder = async (planType, planInfo) => {
}
.stat-value {
color: #60a5fa;
font-size: 16px;
font-size: 18px;
font-weight: 600;
}
.orders-list {
.stat-value.positive {
color: #10b981;
}
.stat-value.negative {
color: #ef4444;
}
.stat-value.current {
color: #60a5fa;
}
.points-history-list {
display: flex;
flex-direction: column;
gap: 15px;
max-height: 600px;
overflow-y: auto;
}
.order-item {
.empty-history {
text-align: center;
padding: 40px;
color: #9ca3af;
}
.history-item {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
border: 1px solid #333;
transition: all 0.3s ease;
}
.order-header {
.history-item:hover {
border-color: #444;
transform: translateY(-2px);
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.order-id {
color: white;
font-weight: 600;
}
.order-status {
.history-type {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.order-status.completed {
.history-type.recharge {
background: #10b981;
color: white;
}
.order-status.pending {
background: #f59e0b;
.history-type.consume {
background: #ef4444;
color: white;
}
.order-details {
.history-points {
font-size: 18px;
font-weight: 600;
}
.history-points.positive {
color: #10b981;
}
.history-points.negative {
color: #ef4444;
}
.history-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-info p {
.history-info p {
margin: 5px 0;
color: #d1d5db;
font-size: 14px;
}
.order-info strong {
.history-info strong {
color: white;
}
</style>

View File

@@ -51,10 +51,13 @@
<input type="text" placeholder="搜索你想要的内容" class="search-input">
</div>
<div class="header-actions">
<el-icon class="notification-icon"><Bell /></el-icon>
<div class="notification-icon-wrapper">
<el-icon class="notification-icon"><Bell /></el-icon>
<span class="notification-badge"></span>
</div>
<div class="user-avatar">
<div class="avatar-placeholder"></div>
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
<img src="/images/backgrounds/welcome.jpg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
</div>
</header>
@@ -89,8 +92,8 @@
<h3>{{ level.name }}</h3>
</div>
<div class="card-body">
<p class="price">${{ level.price }}/</p>
<p class="description">{{ level.description }}</p>
<p class="price">${{ level.price || 0 }}/</p>
<p class="description">{{ level.description || `包含${level.resourcePoints || 0}资源点/月` }}</p>
</div>
<div class="card-footer">
<el-button type="primary" @click="editLevel(level)">编辑</el-button>
@@ -374,7 +377,7 @@
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -390,6 +393,7 @@ import {
Refresh
} from '@element-plus/icons-vue'
import cleanupApi from '@/api/cleanup'
import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
const router = useRouter()
@@ -397,11 +401,8 @@ const router = useRouter()
const activeTab = ref('membership')
// 会员收费标准相关
const membershipLevels = ref([
{ id: 1, name: '免费版会员', price: '0', resourcePoints: 200, description: '包含200资源点/月' },
{ id: 2, name: '标准版会员', price: '50', resourcePoints: 500, description: '包含500资源点/月' },
{ id: 3, name: '专业版会员', price: '250', resourcePoints: 2000, description: '包含2000资源点/月' }
])
const membershipLevels = ref([])
const loadingLevels = ref(false)
const editDialogVisible = ref(false)
const editFormRef = ref(null)
@@ -473,7 +474,12 @@ const goToSettings = () => {
}
const editLevel = (level) => {
Object.assign(editForm, level)
// 映射后端数据到前端表单
editForm.id = level.id
editForm.level = level.name || level.displayName
editForm.price = level.price ? String(level.price) : '0'
editForm.resourcePoints = level.pointsBonus || level.resourcePoints || 0
editForm.validityPeriod = 'monthly' // 默认月付
editDialogVisible.value = true
}
@@ -491,13 +497,96 @@ const handlePriceInput = (value) => {
const saveEdit = async () => {
const valid = await editFormRef.value.validate()
if (valid) {
if (!valid) return
try {
// 调用后端API更新会员等级配置
const updateData = {
price: parseFloat(editForm.price),
resourcePoints: parseInt(editForm.resourcePoints),
pointsBonus: parseInt(editForm.resourcePoints),
description: `包含${editForm.resourcePoints}资源点/月`
}
await updateMembershipLevel(editForm.id, updateData)
// 更新本地数据
const index = membershipLevels.value.findIndex(level => level.id === editForm.id)
if (index !== -1) {
Object.assign(membershipLevels.value[index], editForm)
ElMessage.success('会员等级更新成功')
editDialogVisible.value = false
membershipLevels.value[index].price = parseFloat(editForm.price)
membershipLevels.value[index].pointsBonus = parseInt(editForm.resourcePoints)
membershipLevels.value[index].resourcePoints = parseInt(editForm.resourcePoints)
membershipLevels.value[index].description = `包含${editForm.resourcePoints}资源点/月`
}
ElMessage.success('会员等级更新成功')
editDialogVisible.value = false
// 重新加载会员等级配置
await loadMembershipLevels()
} catch (error) {
console.error('更新会员等级失败:', error)
ElMessage.error('更新会员等级失败: ' + (error.response?.data?.message || error.message))
}
}
// 加载会员等级配置
const loadMembershipLevels = async () => {
loadingLevels.value = true
try {
const response = await getMembershipLevels()
console.log('会员等级配置响应:', response)
// 检查响应结构
let levels = []
if (response.data) {
if (response.data.success && response.data.data) {
levels = response.data.data
} else if (Array.isArray(response.data)) {
levels = response.data
} else if (response.data.data && Array.isArray(response.data.data)) {
levels = response.data.data
}
}
console.log('解析后的会员等级数据:', levels)
// 映射后端数据到前端显示格式
if (levels.length > 0) {
membershipLevels.value = levels.map(level => ({
id: level.id,
name: level.displayName || level.name,
price: level.price || 0,
resourcePoints: level.pointsBonus || 0,
pointsBonus: level.pointsBonus || 0,
description: level.description || `包含${level.pointsBonus || 0}资源点/月`
}))
console.log('会员等级配置加载成功:', membershipLevels.value)
} else {
// 如果没有数据,使用默认值
console.warn('数据库中没有会员等级数据,使用默认值')
membershipLevels.value = [
{ id: 1, name: '免费版会员', price: 0, resourcePoints: 200, description: '包含200资源点/月' },
{ id: 2, name: '标准版会员', price: 59, resourcePoints: 500, description: '包含500资源点/月' },
{ id: 3, name: '专业版会员', price: 250, resourcePoints: 2000, description: '包含2000资源点/月' }
]
}
} catch (error) {
console.error('加载会员等级配置失败:', error)
console.error('错误详情:', error.response?.data || error.message)
// 显示更详细的错误信息
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || '未知错误'
ElMessage.warning(`加载会员等级配置失败: ${errorMessage},使用默认配置`)
// 使用默认值,确保页面可以正常显示
membershipLevels.value = [
{ id: 1, name: '免费版会员', price: 0, resourcePoints: 200, description: '包含200资源点/月' },
{ id: 2, name: '标准版会员', price: 59, resourcePoints: 500, description: '包含500资源点/月' },
{ id: 3, name: '专业版会员', price: 250, resourcePoints: 2000, description: '包含2000资源点/月' }
]
} finally {
loadingLevels.value = false
}
}
@@ -613,8 +702,11 @@ const saveCleanupConfig = async () => {
}
}
// 页面加载时获取统计信息
refreshStats()
// 页面加载时获取统计信息和会员等级配置
onMounted(() => {
refreshStats()
loadMembershipLevels()
})
</script>
<style scoped>
@@ -627,19 +719,31 @@ refreshStats()
/* 左侧导航栏 */
.sidebar {
width: 320px;
width: 240px;
background: white;
border-right: 1px solid #e2e8f0;
border-right: 1px solid #e9ecef;
display: flex;
flex-direction: column;
padding: 24px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
.logo {
display: flex;
align-items: center;
padding: 0 28px;
padding: 0 20px;
margin-bottom: 32px;
}
.logo-icon {
width: 24px;
height: 24px;
background: #3b82f6;
border-radius: 4px;
margin-right: 12px;
}
.logo span {
font-size: 18px;
font-weight: 600;
color: #1e293b;
@@ -647,43 +751,46 @@ refreshStats()
.nav-menu {
flex: 1;
padding: 0 24px;
padding: 0 16px;
}
.nav-item {
display: flex;
align-items: center;
padding: 18px 24px;
margin-bottom: 6px;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #64748b;
font-size: 16px;
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
background: #f3f4f6;
color: #374151;
}
.nav-item.active {
background: #eff6ff;
background: #dbeafe;
color: #3b82f6;
}
.nav-item .el-icon {
margin-right: 16px;
font-size: 22px;
margin-right: 12px;
font-size: 18px;
}
.nav-item span {
font-size: 16px;
font-size: 14px;
font-weight: 500;
}
.sidebar-footer {
padding: 0 32px 20px;
padding: 20px;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
margin-top: auto;
}
@@ -691,13 +798,8 @@ refreshStats()
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 10px;
line-height: 1.5;
}
.online-users,
.system-uptime {
margin-bottom: 5px;
line-height: 1.5;
}
.highlight {
@@ -748,38 +850,60 @@ refreshStats()
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.notification-icon-wrapper {
position: relative;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.notification-icon-wrapper:hover {
background: #f3f4f6;
}
.notification-icon {
font-size: 20px;
color: #606266;
margin-right: 20px;
cursor: pointer;
color: #6b7280;
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
}
.user-avatar {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s ease;
}
.avatar-placeholder {
.user-avatar:hover {
background: #f3f4f6;
}
.user-avatar img {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
font-weight: bold;
object-fit: cover;
}
.dropdown-icon {
.user-avatar .arrow-down {
font-size: 12px;
color: #909399;
color: #6b7280;
}
.content-section {

View File

@@ -57,11 +57,24 @@
</div>
<div class="works-grid">
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.id" @click="openDetail(work)">
<div class="work-item" v-for="(work, index) in publishedWorks" :key="work.taskId || work.id" @click="openDetail(work)">
<div class="work-thumbnail">
<img :src="work.cover" :alt="work.title" />
<!-- 使用video元素显示视频浏览器会自动使用首帧作为封面 -->
<video
v-if="work.resultUrl"
:src="work.resultUrl"
class="work-video-thumbnail"
preload="metadata"
muted
@mouseenter="playPreview($event)"
@mouseleave="pausePreview($event)"
></video>
<!-- 如果没有视频URL显示占位符 -->
<div v-else class="work-placeholder">
<div class="play-icon"></div>
</div>
<div class="work-overlay">
<div class="overlay-text">{{ work.text }}</div>
<div class="overlay-text">{{ work.prompt || work.text || '文生视频' }}</div>
</div>
<!-- 鼠标悬停时显示的做同款按钮 -->
<div class="hover-create-btn" @click.stop="goToCreate(work)">
@@ -72,8 +85,8 @@
</div>
</div>
<div class="work-info">
<div class="work-title">{{ work.title }}</div>
<div class="work-meta">{{ work.id }} · {{ work.size }}</div>
<div class="work-title">{{ work.prompt || work.title || '文生视频' }}</div>
<div class="work-meta">{{ work.taskId || work.id }} · {{ formatSize(work) }}</div>
</div>
<div class="work-actions" v-if="index === 0">
<el-button type="primary" class="create-similar-btn" @click.stop="goToCreate(work)">做同款</el-button>
@@ -149,6 +162,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElIcon, ElButton, ElTag, ElMessage, ElDialog } from 'element-plus'
import { User, Document, VideoPlay, Picture, Film, Compass } from '@element-plus/icons-vue'
import { textToVideoApi } from '@/api/textToVideo'
const router = useRouter()
@@ -157,35 +171,7 @@ const detailDialogVisible = ref(false)
const selectedItem = ref(null)
// 已发布作品数据
const publishedWorks = ref([
{
id: '2995000000001',
title: '文生视频作品 #1',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '文生视频',
createTime: '2025/01/15 14:30'
},
{
id: '2995000000002',
title: '文生视频作品 #2',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '文生视频',
createTime: '2025/01/14 16:45'
},
{
id: '2995000000003',
title: '文生视频作品 #3',
cover: '/images/backgrounds/welcome.jpg',
text: 'What Does it Mean To You',
size: '9 MB',
category: '文生视频',
createTime: '2025/01/13 09:20'
}
])
const publishedWorks = ref([])
// 导航函数
const goToProfile = () => {
@@ -235,8 +221,62 @@ const createSimilar = () => {
router.push('/text-to-video/create')
}
// 格式化文件大小
const formatSize = (work) => {
if (work.size) return work.size
return '未知大小'
}
// 播放预览(鼠标悬停时)
const playPreview = (event) => {
const video = event.target
if (video && video.tagName === 'VIDEO') {
video.currentTime = 0
video.play().catch(() => {
// 忽略自动播放失败
})
}
}
// 暂停预览(鼠标离开时)
const pausePreview = (event) => {
const video = event.target
if (video && video.tagName === 'VIDEO') {
video.pause()
video.currentTime = 0
}
}
// 加载任务列表
const loadTasks = async () => {
try {
const response = await textToVideoApi.getTasks(0, 20)
if (response.data && response.data.success && response.data.data) {
// 只显示已完成的任务
publishedWorks.value = response.data.data
.filter(task => task.status === 'COMPLETED' && task.resultUrl)
.map(task => ({
taskId: task.taskId,
prompt: task.prompt,
resultUrl: task.resultUrl,
status: task.status,
createdAt: task.createdAt,
id: task.taskId,
title: task.prompt || '文生视频',
text: task.prompt || '文生视频',
category: '文生视频',
createTime: task.createdAt ? new Date(task.createdAt).toLocaleString('zh-CN') : ''
}))
}
} catch (error) {
console.error('加载任务列表失败:', error)
ElMessage.error('加载任务列表失败')
}
}
onMounted(() => {
// 页面初始化
// 页面初始化时加载任务列表
loadTasks()
})
</script>
@@ -462,12 +502,40 @@ onMounted(() => {
overflow: hidden;
}
.work-thumbnail img {
.work-thumbnail img,
.work-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
}
.work-video-thumbnail {
display: block;
background: #000;
}
.work-placeholder {
width: 100%;
height: 100%;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.play-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 24px;
backdrop-filter: blur(8px);
}
/* 鼠标悬停时显示的做同款按钮 */
.hover-create-btn {
position: absolute;

View File

@@ -64,7 +64,6 @@
<select v-model="duration" class="setting-select">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
</select>
</div>
@@ -189,10 +188,6 @@
</div>
</div>
<!-- 任务控制 -->
<div class="task-controls" v-if="inProgress">
<button class="cancel-btn" @click="cancelTask">取消任务</button>
</div>
</div>
<!-- 初始状态 -->
@@ -394,10 +389,10 @@ const startPollingTask = () => {
if (progressData && progressData.status) {
taskStatus.value = progressData.status
}
// 更新resultUrl如果存在
if (progressData && progressData.resultUrl && currentTask.value) {
// 更新resultUrl如果存在且不为空
if (progressData && progressData.resultUrl && progressData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = progressData.resultUrl
console.log('更新resultUrl:', progressData.resultUrl)
console.log('更新resultUrl:', progressData.resultUrl.substring(0, 50) + '...')
}
console.log('任务进度:', progressData)
},
@@ -407,9 +402,11 @@ const startPollingTask = () => {
taskProgress.value = 100
taskStatus.value = 'COMPLETED'
// 更新currentTask的resultUrl
if (taskData && taskData.resultUrl && currentTask.value) {
if (taskData && taskData.resultUrl && taskData.resultUrl.trim() && currentTask.value) {
currentTask.value.resultUrl = taskData.resultUrl
console.log('任务完成resultUrl已更新:', taskData.resultUrl)
console.log('任务完成resultUrl已更新:', taskData.resultUrl.substring(0, 50) + '...')
} else if (currentTask.value && !currentTask.value.resultUrl) {
console.warn('任务完成但未获取到resultUrl')
}
ElMessage.success('视频生成完成!')
@@ -426,32 +423,6 @@ const startPollingTask = () => {
)
}
// 取消任务
const cancelTask = async () => {
if (!currentTask.value) return
try {
const response = await textToVideoApi.cancelTask(currentTask.value.taskId)
if (response.data && response.data.success) {
inProgress.value = false
taskStatus.value = 'CANCELLED'
ElMessage.success('任务已取消')
// 停止轮询
if (stopPolling.value) {
stopPolling.value()
stopPolling.value = null
}
} else {
ElMessage.error(response.data?.message || '取消失败')
}
} catch (error) {
console.error('取消任务失败:', error)
ElMessage.error('取消任务失败')
}
}
// 获取状态文本
const getStatusText = (status) => {
@@ -1078,6 +1049,14 @@ onUnmounted(() => {
.right-panel {
padding: 24px;
}
.video-preview-container {
max-height: 65vh;
}
.video-player-container {
max-height: calc(65vh - 100px);
}
}
@media (max-width: 1024px) {
@@ -1095,6 +1074,18 @@ onUnmounted(() => {
.right-panel {
padding: 20px;
}
.video-preview-container {
max-height: 60vh;
}
.video-player-container {
max-height: calc(60vh - 100px);
}
.completed-container {
max-height: 60vh;
}
}
@media (max-width: 768px) {
@@ -1123,6 +1114,21 @@ onUnmounted(() => {
.tab {
text-align: left;
}
.video-preview-container {
max-height: 50vh;
padding: 12px;
}
.video-player-container {
max-height: calc(50vh - 80px);
}
.completed-container {
max-height: 50vh;
padding: 12px;
}
}
/* 任务状态样式 */
@@ -1216,21 +1222,6 @@ onUnmounted(() => {
margin-top: 15px;
}
.cancel-btn {
background: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-btn:hover {
background: #dc2626;
transform: translateY(-1px);
}
/* 任务描述样式 */
.task-description {
@@ -1252,11 +1243,14 @@ onUnmounted(() => {
border: 2px solid #2a2a2a;
border-radius: 12px;
min-height: 300px;
max-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
margin: 15px 0;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
}
/* 生成中状态 */
@@ -1299,9 +1293,13 @@ onUnmounted(() => {
.completed-container {
width: 100%;
height: 100%;
max-height: 70vh;
display: flex;
flex-direction: column;
padding: 0;
padding: 20px;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
}
/* 任务信息头部 */
@@ -1334,22 +1332,36 @@ onUnmounted(() => {
flex: 1;
position: relative;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
max-height: calc(70vh - 100px);
width: 100%;
}
.video-player {
position: relative;
width: 100%;
height: 100%;
max-width: 100%;
height: auto;
max-height: 100%;
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.result-video {
width: 100%;
height: 100%;
height: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain;
border-radius: 12px;
display: block;
}
.no-video-placeholder {