feat: 系统优化和功能完善
主要更新: - 调整并发配置为50人(数据库连接池30,Tomcat线程150,异步线程池5/20) - 实现无界阻塞队列(LinkedBlockingQueue)任务处理 - 实现分镜视频保存功能(保存到uploads目录) - 统一管理页面导航栏和右上角样式 - 添加日活用户统计功能 - 优化视频拼接和保存逻辑 - 添加部署文档和快速部署指南 - 更新.gitignore排除敏感配置文件
This commit is contained in:
@@ -439,6 +439,9 @@ MIT License
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ console.log('App.vue 加载成功')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
20
demo/frontend/src/api/points.js
Normal file
20
demo/frontend/src/api/points.js
Normal 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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务状态
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user