feat: 系统功能更新 - 添加错误统计、数据初始化、订单调度等功能

This commit is contained in:
AIGC Developer
2025-12-20 15:24:58 +08:00
parent 0933031b59
commit 5344148a1c
70 changed files with 3649 additions and 688 deletions

View File

@@ -107,6 +107,19 @@ export const imageToVideoApi = {
})
},
/**
* 重试失败的任务
* 复用原task_id和已上传的图片重新提交至外部API
* @param {string} taskId - 任务ID
* @returns {Promise} API响应
*/
retryTask(taskId) {
return request({
url: `/image-to-video/tasks/${taskId}/retry`,
method: 'POST'
})
},
/**
* 轮询任务状态
* @param {string} taskId - 任务ID

View File

@@ -41,9 +41,10 @@ api.interceptors.request.use(
const token = localStorage.getItem('token')
if (token && token !== 'null' && token.trim() !== '') {
config.headers.Authorization = `Bearer ${token}`
console.log('请求拦截器添加Authorization头token长度:', token.length)
// 打印token前30字符用于调试
console.log('请求拦截器添加Authorization头token前30字符:', token.substring(0, 30), '请求URL:', config.url)
} else {
console.warn('请求拦截器未找到有效的token')
console.warn('请求拦截器未找到有效的token请求URL:', config.url)
}
} else {
console.log('请求拦截器登录相关请求不添加token:', config.url)
@@ -69,34 +70,47 @@ api.interceptors.response.use(
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
if (!isLoginRequest) {
// 清除无效的token并跳转到登录
// 清除无效的token并跳转到欢迎
localStorage.removeItem('token')
localStorage.removeItem('user')
// 避免重复跳转
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录')
router.push('/')
}
}
// 返回错误,让调用方知道这是认证失败
return Promise.reject(new Error('认证失败收到HTML响应'))
}
// 检查302重定向
if (response.status === 302) {
console.error('收到302重定向可能是认证失败:', response.config.url)
// 检查401未授权Token过期
if (response.status === 401) {
console.error('收到401Token已过期:', response.config.url)
// 只有非登录请求才清除token并跳转
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
if (!isLoginRequest) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
}
ElMessage.warning('登录已过期,请重新登录')
router.push('/')
}
return Promise.reject(new Error('认证失败Token已过期'))
}
// 检查302重定向
if (response.status === 302) {
console.error('收到302重定向可能是认证失败:', response.config.url)
const loginUrls = ['/auth/login', '/auth/login/email', '/auth/register', '/verification/', '/public/']
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
if (!isLoginRequest) {
localStorage.removeItem('token')
localStorage.removeItem('user')
ElMessage.warning('登录已过期,请重新登录')
router.push('/')
}
return Promise.reject(new Error('认证失败302重定向'))
}
@@ -119,9 +133,9 @@ api.interceptors.response.use(
if (!isLoginRequest) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录')
router.push('/')
}
}
return Promise.reject(error)
@@ -138,9 +152,10 @@ api.interceptors.response.use(
// 302也可能是认证失败导致的
localStorage.removeItem('token')
localStorage.removeItem('user')
if (router.currentRoute.value.path !== '/login') {
ElMessage.error('认证失败,请重新登录')
router.push('/login')
// 跳转到欢迎页
if (router.currentRoute.value.path !== '/' && router.currentRoute.value.path !== '/login') {
ElMessage.warning('登录已过期,请重新登录')
router.push('/')
}
}
break

View File

@@ -81,6 +81,18 @@ export const textToVideoApi = {
})
},
/**
* 重试失败的任务
* 复用原task_id重新提交至外部API
* @param {string} taskId - 任务ID
* @returns {Promise} API响应
*/
retryTask(taskId) {
return request({
url: `/text-to-video/tasks/${taskId}/retry`,
method: 'POST'
})
},
/**
* 轮询任务状态

View File

@@ -230,6 +230,8 @@ export default {
userAvatar: 'User Avatar',
firstFrame: 'First Frame',
promptPlaceholder: 'Describe the content you want to generate with the image',
tipWarning: '⚠️ Do not upload images of real people or anime IP',
tip1: '🎬 Supports camera movements, character actions, scene transitions, etc.',
optimizing: 'Optimizing...',
optimizePrompt: 'Optimize',
hdMode: 'HD Mode',
@@ -324,8 +326,10 @@ export default {
uploadSuccess: 'Successfully uploaded {count} images',
imageRemoved: 'Image removed',
promptPlaceholder: 'Example: a coffee advertisement\n\nTip: Simple description is enough, AI will automatically optimize it into professional storyboard\nSupports Chinese or English input, the system will automatically translate and optimize it into professional storyboard description',
tip1: '💡 AI will automatically generate professional storyboards based on your description',
tip2: '🎬 Supports various scene compositions and camera types',
tipWarning: '⚠️ Do not upload images of real people or anime IP',
tip1: '🎬 Supports camera movements, character actions, scene transitions, etc.',
imageCost: 'Storyboard generation costs 30 points',
videoCost: 'Video generation costs 30 points',
videoPromptLabel: 'Video Description',
videoPromptPlaceholder: 'Describe actions and camera movements in the video, e.g.: camera slowly zooms in, person turns around and smiles',
videoTip1: '💡 Describe actions and camera movements for more vivid video generation',
@@ -480,14 +484,14 @@ export default {
free: 'Free',
standard: 'Standard',
professional: 'Professional',
perMonth: '/month',
perMonth: '/year',
subscribe: 'Subscribe Now',
renew: 'Renew',
upgrade: 'Upgrade',
features: 'Features',
unlimited: 'Unlimited',
limited: 'Limited',
pointsPerMonth: 'Points/Month',
pointsPerMonth: 'Points/Year',
videoQuality: 'Video Quality',
support: 'Support',
priorityQueue: 'Priority Queue',
@@ -506,8 +510,8 @@ export default {
currentPackage: 'Current Plan',
firstPurchaseDiscount: 'First Purchase Discount up to 15% off',
bestValue: 'Best Value',
standardPoints: '200 points per month',
premiumPoints: '1000 points per month',
standardPoints: '6000 points/year',
premiumPoints: '12000 points/year',
freeNewUserBonus: 'New users get 50 points free on first login',
fastGeneration: 'Fast Generation',
superFastGeneration: 'Super Fast Generation',

View File

@@ -232,6 +232,8 @@ export default {
userAvatar: '用户头像',
firstFrame: '首帧',
promptPlaceholder: '结合图片,描述想要生成的内容',
tipWarning: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
tip1: '🎬 支持描述镜头推拉、人物动作、场景变化等',
optimizing: '优化中...',
optimizePrompt: '一键优化',
hdMode: '高清模式',
@@ -327,11 +329,13 @@ export default {
uploadSuccess: '成功上传 {count} 张图片',
imageRemoved: '已删除图片',
promptPlaceholder: '例如:一个咖啡的广告\n\n提示:简单描述即可AI会自动优化成专业的分镜图\n支持中文或英文输入系统会自动翻译并优化为专业的分镜图描述',
tip1: '💡 AI会根据您的描述自动生成专业分镜图',
tip2: '🎬 支持多种画面构图和镜头类型描述',
tipWarning: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
tip1: '🎬 支持描述镜头推拉、人物动作、场景变化等',
imageCost: '生成分镜图消耗30积分',
videoCost: '生成视频消耗30积分',
videoPromptLabel: '视频描述',
videoPromptPlaceholder: '描述视频中的动作、镜头运动等,例如:镜头缓慢推进,人物转身微笑',
videoTip1: '💡 描述画面中的动作和镜头运动AI将生成更生动的视频',
videoTip1: '⚠️ 图片不能上传真人、涉及动漫IP等类型的图片',
videoTip2: '🎬 支持描述镜头推拉、人物动作、场景变化等',
storyboardReadyHint: '分镜图已准备就绪,输入视频描述后点击生成视频',
optimizing: '优化中...',
@@ -494,14 +498,14 @@ export default {
free: '免费版',
standard: '标准版',
professional: '专业版',
perMonth: '/',
perMonth: '/',
subscribe: '立即订阅',
renew: '续费',
upgrade: '升级',
features: '功能特性',
unlimited: '无限',
limited: '有限',
pointsPerMonth: '积分/',
pointsPerMonth: '积分/',
videoQuality: '视频质量',
support: '客服支持',
priorityQueue: '优先队列',
@@ -520,8 +524,8 @@ export default {
currentPackage: '当前套餐',
firstPurchaseDiscount: '首购低至8.5折',
bestValue: '超值之选',
standardPoints: '每月200积分',
premiumPoints: '每月1000积分',
standardPoints: '6000积分/年',
premiumPoints: '12000积分/年',
freeNewUserBonus: '新用户首次登陆免费获得50积分',
fastGeneration: '快速通道生成',
superFastGeneration: '极速通道生成',
@@ -657,6 +661,7 @@ export default {
cancelled: '已取消',
refunded: '已退款',
unpaid: '未支付',
allPaymentMethods: '全部支付方式',
alipay: '支付宝',
wechat: '微信支付',
paypal: 'PayPal',
@@ -780,6 +785,12 @@ export default {
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
storyboardSystemPrompt: '分镜图系统引导词',
storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格',
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...'
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...',
membershipUpdateSuccess: '会员等级配置更新成功',
membershipUpdateFailed: '会员等级配置更新失败',
loadMembershipFailed: '加载会员配置失败',
usingDefaultConfig: '使用默认配置',
enterValidNumber: '请输入有效的数字',
unknown: '未知错误'
}
}

View File

@@ -190,6 +190,12 @@ const routes = [
component: () => import('@/views/ApiManagement.vue'),
meta: { title: 'API管理', requiresAuth: true, requiresAdmin: true }
},
{
path: '/admin/error-statistics',
name: 'ErrorStatistics',
component: () => import('@/views/ErrorStatistics.vue'),
meta: { title: '错误统计', requiresAuth: true, requiresAdmin: true }
},
{
path: '/hello',
name: 'HelloWorld',
@@ -246,6 +252,13 @@ router.beforeEach(async (to, from, next) => {
try {
const userStore = useUserStore()
// 检查localStorage中的token是否被清除例如JWT过期后被request.js清除
// 如果token被清除但store中仍有用户信息则同步清除store
const storedToken = localStorage.getItem('token')
if (!storedToken && userStore.isAuthenticated) {
userStore.clearUserData()
}
// 优化:只在首次访问时初始化用户状态
if (!userStore.initialized) {
await userStore.init()

View File

@@ -128,19 +128,18 @@ export const useUserStore = defineStore('user', () => {
return
}
// 从localStorage恢复用户状态
// 从 localStorage 恢复用户状态
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
console.log('Store init - savedToken:', savedToken ? savedToken.substring(0, 30) + '...' : 'null')
if (savedToken && savedUser) {
try {
token.value = savedToken
user.value = JSON.parse(savedUser)
// 只在开发环境输出详细日志
if (process.env.NODE_ENV === 'development') {
console.log('恢复用户状态:', user.value?.username, '角色:', user.value?.role)
}
console.log('恢复用户状态:', user.value?.username)
// 刷新用户信息(确保角色等信息是最新的)
await fetchCurrentUser()
@@ -153,6 +152,11 @@ export const useUserStore = defineStore('user', () => {
initialized.value = true
}
// 重置初始化状态(登录成功后调用)
const resetInitialized = () => {
initialized.value = false
}
return {
// 状态
@@ -172,6 +176,7 @@ export const useUserStore = defineStore('user', () => {
fetchCurrentUser,
clearUserData,
init,
initialized
initialized,
resetInitialized
}
})

View File

@@ -27,6 +27,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -162,7 +166,8 @@ import {
User as Search,
User as Avatar,
ArrowDown,
Money
Money,
Warning
} from '@element-plus/icons-vue'
import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend, getSystemStatus } from '@/api/dashboard'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
@@ -229,6 +234,10 @@ const goToTasks = () => {
router.push('/generate-task-record')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}

View File

@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div v-if="isAdminMode" class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div v-if="isAdminMode" class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -218,7 +222,20 @@
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.status') }}:</span>
<span class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
<el-select
v-if="isAdminMode"
v-model="editingStatus"
size="small"
style="width: 120px;"
@change="handleStatusChange"
>
<el-option label="待支付" value="PENDING" />
<el-option label="已支付" value="PAID" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已取消" value="CANCELLED" />
<el-option label="已退款" value="REFUNDED" />
</el-select>
<span v-else class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
{{ getStatusText(currentOrderDetail.status) }}
</span>
</div>
@@ -300,9 +317,10 @@ import {
Delete,
CreditCard,
Wallet,
Loading
Loading,
Warning
} from '@element-plus/icons-vue'
import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders } from '@/api/orders'
import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders, updateOrderStatus } from '@/api/orders'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -348,6 +366,10 @@ const goToTasks = () => {
router.push('/generate-task-record')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}
@@ -502,12 +524,40 @@ const goToPage = (page) => {
// 订单详情弹窗相关
const orderDetailVisible = ref(false)
const currentOrderDetail = ref(null)
const editingStatus = ref('')
const viewOrder = async (order) => {
currentOrderDetail.value = order
editingStatus.value = order.status || 'PENDING'
orderDetailVisible.value = true
}
// 修改订单状态
const handleStatusChange = async (newStatus) => {
if (!currentOrderDetail.value) return
try {
const response = await updateOrderStatus(currentOrderDetail.value.id, newStatus)
if (response.data?.success) {
// 更新当前详情
currentOrderDetail.value.status = newStatus
// 更新列表中的订单
const orderIndex = orders.value.findIndex(o => o.id === currentOrderDetail.value.id)
if (orderIndex > -1) {
orders.value[orderIndex].status = newStatus
}
ElMessage.success('订单状态更新成功')
} else {
ElMessage.error(response.data?.message || '更新失败')
editingStatus.value = currentOrderDetail.value.status // 恢复原状态
}
} catch (error) {
console.error('更新订单状态失败:', error)
ElMessage.error('更新订单状态失败')
editingStatus.value = currentOrderDetail.value.status // 恢复原状态
}
}
const deleteOrder = async (order) => {
try {
await ElMessageBox.confirm(
@@ -1010,6 +1060,10 @@ const fetchSystemStats = async () => {
background: #6366f1;
}
.status-tag.refunded {
background: #f97316;
}
.action-link {
margin-right: 12px;
font-size: 14px;

View File

@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -126,7 +130,8 @@ import {
Document,
Setting,
Search,
ArrowDown
ArrowDown,
Warning
} from '@element-plus/icons-vue'
import api from '@/api/request'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
@@ -162,6 +167,10 @@ const goToTasks = () => {
router.push('/generate-task-record')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}

View File

@@ -0,0 +1,754 @@
<template>
<div class="error-statistics-page">
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<img src="/images/backgrounds/logo-admin.svg" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToDashboard">
<el-icon><Grid /></el-icon>
<span>{{ $t('nav.dashboard') }}</span>
</div>
<div class="nav-item" @click="goToMembers">
<el-icon><User /></el-icon>
<span>{{ $t('nav.members') }}</span>
</div>
<div class="nav-item" @click="goToOrders">
<el-icon><ShoppingCart /></el-icon>
<span>{{ $t('nav.orders') }}</span>
</div>
<div class="nav-item" @click="goToAPI">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.apiManagement') }}</span>
</div>
<div class="nav-item" @click="goToTasks">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item active">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索栏 -->
<header class="top-header">
<div class="page-title">
<h2>错误类型统计</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容包装器 -->
<div class="content-wrapper">
<!-- 统计卡片 -->
<div class="stats-cards" v-loading="loading">
<div class="stat-card">
<div class="stat-icon error">
<el-icon><Warning /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">总错误数</div>
<div class="stat-number">{{ statistics.totalErrors || 0 }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon today">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">今日错误</div>
<div class="stat-number">{{ statistics.todayErrors || 0 }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon week">
<el-icon><Calendar /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">本周错误</div>
<div class="stat-number">{{ statistics.weekErrors || 0 }}</div>
</div>
</div>
</div>
<!-- 错误类型分布 -->
<div class="charts-section">
<div class="chart-card full-width">
<div class="chart-header">
<h3>错误类型分布</h3>
<el-select v-model="selectedDays" @change="loadStatistics" class="days-select">
<el-option label="最近7天" :value="7"></el-option>
<el-option label="最近30天" :value="30"></el-option>
<el-option label="最近90天" :value="90"></el-option>
</el-select>
</div>
<div class="error-type-list">
<div
v-for="(item, index) in errorTypeStats"
:key="item.type"
class="error-type-item"
>
<div class="type-info">
<span class="type-name">{{ item.description || item.type }}</span>
<span class="type-count">{{ item.count }} </span>
</div>
<div class="type-bar">
<div
class="type-bar-fill"
:style="{ width: getBarWidth(item.count) + '%', backgroundColor: getBarColor(index) }"
></div>
</div>
<div class="type-percentage">{{ getPercentage(item.count) }}%</div>
</div>
<el-empty v-if="errorTypeStats.length === 0" description="暂无错误数据" />
</div>
</div>
</div>
<!-- 最近错误列表 -->
<div class="recent-errors-section">
<div class="section-header">
<h3>最近错误</h3>
<el-button type="primary" size="small" @click="loadRecentErrors">刷新</el-button>
</div>
<el-table :data="recentErrors" v-loading="tableLoading" stripe>
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="errorType" label="错误类型" width="150">
<template #default="{ row }">
<el-tag :type="getTagType(row.errorType)">{{ row.errorType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="username" label="用户" width="120" />
<el-table-column prop="taskId" label="任务ID" width="200" />
<el-table-column prop="errorMessage" label="错误信息" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="totalErrors"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="loadErrorLogs"
@current-change="loadErrorLogs"
/>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Grid,
User,
ShoppingCart,
Document,
Setting,
ArrowDown,
Warning,
Clock,
Calendar
} from '@element-plus/icons-vue'
import request from '@/api/request'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
// 状态
const loading = ref(false)
const tableLoading = ref(false)
const selectedDays = ref(7)
const currentPage = ref(1)
const pageSize = ref(20)
const totalErrors = ref(0)
const onlineUsers = ref('0')
const systemUptime = ref('--')
// 数据
const statistics = ref({})
const errorTypeStats = ref([])
const recentErrors = ref([])
const errorTypes = ref({})
// 计算总数
const totalCount = computed(() => {
return errorTypeStats.value.reduce((sum, item) => sum + item.count, 0)
})
// 加载统计数据
const loadStatistics = async () => {
loading.value = true
try {
const res = await request.get('/admin/error-logs/statistics', {
params: { days: selectedDays.value }
})
if (res.data.success) {
const data = res.data.data || {}
statistics.value = {
totalErrors: data.totalErrors || 0,
todayErrors: data.todayErrors || 0,
weekErrors: data.weekErrors || 0
}
// 处理错误类型统计 - 后端返回的是 errorsByType
if (data.errorsByType) {
errorTypeStats.value = Object.entries(data.errorsByType).map(([type, count]) => ({
type,
description: errorTypes.value[type] || type,
count
})).sort((a, b) => b.count - a.count)
}
}
} catch (error) {
console.error('加载统计失败:', error)
ElMessage.error('加载统计数据失败')
} finally {
loading.value = false
}
}
// 加载错误类型定义
const loadErrorTypes = async () => {
try {
const res = await request.get('/admin/error-logs/types')
if (res.data.success) {
errorTypes.value = res.data.data || {}
}
} catch (error) {
console.error('加载错误类型失败:', error)
}
}
// 加载错误日志列表
const loadErrorLogs = async () => {
tableLoading.value = true
try {
const res = await request.get('/admin/error-logs', {
params: {
page: currentPage.value - 1,
size: pageSize.value
}
})
if (res.data.success) {
recentErrors.value = res.data.data || []
totalErrors.value = res.data.totalElements || 0
}
} catch (error) {
console.error('加载错误日志失败:', error)
ElMessage.error('加载错误日志失败')
} finally {
tableLoading.value = false
}
}
// 加载最近错误
const loadRecentErrors = async () => {
tableLoading.value = true
try {
const res = await request.get('/admin/error-logs/recent', {
params: { limit: 20 }
})
if (res.data.success) {
recentErrors.value = res.data.data || []
}
} catch (error) {
console.error('加载最近错误失败:', error)
} finally {
tableLoading.value = false
}
}
// 获取进度条宽度
const getBarWidth = (count) => {
if (totalCount.value === 0) return 0
return Math.min((count / totalCount.value) * 100, 100)
}
// 获取百分比
const getPercentage = (count) => {
if (totalCount.value === 0) return 0
return ((count / totalCount.value) * 100).toFixed(1)
}
// 获取进度条颜色
const getBarColor = (index) => {
const colors = ['#f56c6c', '#e6a23c', '#409eff', '#67c23a', '#909399', '#b88230', '#8e44ad', '#16a085']
return colors[index % colors.length]
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
// 获取标签类型
const getTagType = (errorType) => {
const typeMap = {
'API_ERROR': 'danger',
'TASK_FAILED': 'warning',
'PAYMENT_ERROR': 'danger',
'AUTH_ERROR': 'info',
'SYSTEM_ERROR': 'danger'
}
return typeMap[errorType] || 'info'
}
// 导航函数
const goToDashboard = () => router.push('/admin/dashboard')
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 handleUserCommand = (command) => {
if (command === 'exitAdmin') {
router.push('/profile')
}
}
// 获取系统统计数据
const fetchSystemStats = async () => {
try {
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || '--'
}
} catch (error) {
console.error('获取系统统计失败:', error)
}
}
onMounted(async () => {
fetchSystemStats()
await loadErrorTypes()
await loadStatistics()
await loadErrorLogs()
})
</script>
<style scoped>
.error-statistics-page {
display: flex;
height: 100vh;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 左侧导航栏 */
.sidebar {
width: 240px;
background: #ffffff;
border-right: 1px solid #e5e7eb;
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;
justify-content: center;
padding: 0 24px;
margin-bottom: 32px;
}
.logo img {
width: 100%;
height: auto;
max-width: 180px;
object-fit: contain;
}
.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: #4b5563;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: #f3f4f6;
color: #1f2937;
}
.nav-item.active {
background: rgba(64, 158, 255, 0.15);
color: #409EFF;
}
.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 #e5e7eb;
background: #f9fafb;
margin-top: auto;
color: #4b5563;
}
.sidebar-footer .highlight {
color: #409EFF;
font-weight: 600;
}
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 5px;
line-height: 1.5;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #f8f9fa;
overflow-y: auto;
padding: 0;
}
/* 顶部搜索栏 */
.top-header {
background: #ffffff;
padding: 16px 24px;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.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;
}
.arrow-down {
color: #6b7280;
font-size: 12px;
}
/* 内容包装器 */
.content-wrapper {
padding: 24px;
flex: 1;
overflow-y: auto;
}
/* 统计卡片 */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-icon.error {
background: rgba(245, 108, 108, 0.2);
color: #f56c6c;
}
.stat-icon.today {
background: rgba(230, 162, 60, 0.2);
color: #e6a23c;
}
.stat-icon.week {
background: rgba(64, 158, 255, 0.2);
color: #409eff;
}
.stat-icon.users {
background: rgba(103, 194, 58, 0.2);
color: #67c23a;
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
color: #9ca3af;
margin-bottom: 4px;
}
.stat-number {
font-size: 28px;
font-weight: 600;
}
/* 图表区域 */
.charts-section {
margin-bottom: 30px;
}
.chart-card {
background: #fff;
border-radius: 12px;
padding: 20px;
}
.chart-card.full-width {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.chart-header h3 {
margin: 0;
font-size: 18px;
}
.days-select {
width: 120px;
}
/* 错误类型列表 */
.error-type-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.error-type-item {
display: flex;
align-items: center;
gap: 16px;
}
.type-info {
width: 200px;
display: flex;
justify-content: space-between;
}
.type-name {
color: #333;
font-size: 14px;
}
.type-count {
color: #9ca3af;
font-size: 13px;
}
.type-bar {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.type-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
.type-percentage {
width: 60px;
text-align: right;
color: #9ca3af;
font-size: 13px;
}
/* 最近错误区域 */
.recent-errors-section {
background: #fff;
border-radius: 12px;
padding: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h3 {
margin: 0;
font-size: 18px;
}
.pagination-wrapper {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
/* Element Plus 样式覆盖 */
:deep(.el-table) {
background: transparent;
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: #f5f5f5;
--el-table-row-hover-bg-color: #f5f5f5;
--el-table-border-color: #e5e7eb;
color: #333;
}
:deep(.el-table th) {
background: #f5f5f5 !important;
color: #666;
}
:deep(.el-select) {
--el-select-input-focus-border-color: #3b82f6;
}
:deep(.el-pagination) {
--el-pagination-bg-color: transparent;
--el-pagination-text-color: #9ca3af;
--el-pagination-button-disabled-bg-color: transparent;
}
@media (max-width: 1200px) {
.stats-cards {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.stats-cards {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
}
</style>

View File

@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -270,7 +274,8 @@ import {
ArrowDown,
ArrowLeft,
ArrowRight,
Delete
Delete,
Warning
} from '@element-plus/icons-vue'
import { taskStatusApi } from '@/api/taskStatus'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
@@ -314,6 +319,10 @@ const goToAPI = () => {
router.push('/api-management')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}

View File

@@ -58,6 +58,10 @@
class="text-input"
rows="6"
></textarea>
<div class="input-tips">
<div class="tip-item warning">{{ t('video.imageToVideo.tipWarning') }}</div>
<div class="tip-item">{{ t('video.imageToVideo.tip1') }}</div>
</div>
<div class="optimize-btn">
<button class="optimize-button" @click="optimizePromptHandler" :disabled="!inputText.trim() || optimizingPrompt">
{{ optimizingPrompt ? t('video.imageToVideo.optimizing') : t('video.imageToVideo.optimizePrompt') }}
@@ -83,6 +87,7 @@
</select>
</div>
<!-- 高清模式暂时屏蔽
<div class="setting-item">
<label>{{ t('video.imageToVideo.hdMode') }} (1080P)</label>
<div class="hd-setting">
@@ -90,6 +95,7 @@
<span class="cost-text">{{ t('video.imageToVideo.hdModeCost') }}</span>
</div>
</div>
-->
</div>
<!-- 生成按钮 -->
@@ -100,6 +106,7 @@
:disabled="!isAuthenticated || isCreatingTask"
>
{{ !isAuthenticated ? t('video.imageToVideo.pleaseLogin') : isCreatingTask ? t('video.imageToVideo.creatingTask') : t('video.imageToVideo.startGenerate') }}
<span v-if="isAuthenticated && !isCreatingTask" class="btn-points">30 <el-icon><Star /></el-icon></span>
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>{{ t('video.imageToVideo.loginRequired') }}</p>
@@ -237,7 +244,7 @@
<div class="history-prompt">{{ task.prompt || t('video.imageToVideo.noDescription') }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div class="history-preview" :class="{ 'vertical': task.aspectRatio === '9:16' }">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.imageToVideo.queuing') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
@@ -315,6 +322,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click.stop="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -337,7 +348,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
@@ -354,7 +365,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
// 表单数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const duration = ref('10')
const duration = ref('15')
const hdMode = ref(false)
const inProgress = ref(false)
@@ -465,6 +476,11 @@ const goToSystemSettings = () => {
router.push('/system-settings')
}
const goToErrorStats = () => {
showUserMenu.value = false
router.push('/admin/error-statistics')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -498,9 +514,11 @@ const uploadFirstFrame = () => {
}
firstFrameFile.value = file
console.log('[Upload] 文件已设置:', file.name, file.size, 'bytes')
const reader = new FileReader()
reader.onload = (e) => {
firstFrameImage.value = e.target.result
console.log('[Upload] 图片预览已设置base64长度:', e.target.result.length)
}
reader.readAsDataURL(file)
}
@@ -551,6 +569,9 @@ const removeLastFrame = () => {
// 开始生成视频
const startGenerate = async () => {
console.log('[StartGenerate] 开始firstFrameFile:', firstFrameFile.value ? `File(${firstFrameFile.value.name})` : 'null',
'firstFrameImage:', firstFrameImage.value ? firstFrameImage.value.substring(0, 30) + '...' : 'null')
// 检查登录状态
if (!userStore.isAuthenticated) {
ElMessage.warning(t('video.imageToVideo.pleaseLoginFirst'))
@@ -560,6 +581,36 @@ const startGenerate = async () => {
// 注允许多任务并发后端会检查最大任务数限制最多3个
// 如果 firstFrameFile 为空但 firstFrameImage 有值(比如从失败任务恢复),尝试从图片重新加载
if (!firstFrameFile.value && firstFrameImage.value) {
console.log('[StartGenerate] firstFrameFile 为空firstFrameImage:', firstFrameImage.value.substring(0, 50))
const imageUrl = firstFrameImage.value
// 检查是否是 base64 数据 URL用户手动上传的图片
if (imageUrl.startsWith('data:')) {
try {
// base64 数据 URL可以直接 fetch
const response = await fetch(imageUrl)
const blob = await response.blob()
const fileName = `first_frame_${Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('[StartGenerate] Base64图片已成功转换为文件对象:', fileName)
} catch (error) {
console.error('[StartGenerate] 无法从base64加载图片:', error)
// 清空图片显示,让用户重新上传
firstFrameImage.value = ''
ElMessage.error('图片加载失败,请重新选择图片文件')
return
}
} else {
// 外部 URL如 COS清空图片显示让用户重新上传
console.warn('[StartGenerate] 图片是外部URL清空显示让用户重新上传')
firstFrameImage.value = ''
ElMessage.error('请重新选择图片文件')
return
}
}
// 验证表单
if (!firstFrameFile.value) {
ElMessage.error(t('video.imageToVideo.uploadFirstFrameRequired'))
@@ -932,16 +983,49 @@ const downloadHistoryVideo = async (task) => {
}
}
// 重新生成
const retryTask = () => {
// 重置状态
currentTask.value = null
inProgress.value = false
taskProgress.value = 0
taskStatus.value = ''
// 重新生成复用原task_id和已上传的图片
const retryTask = async () => {
// 检查是否有失败的任务可以重试
if (!currentTask.value || !currentTask.value.taskId) {
ElMessage.error('没有可重试的任务')
return
}
// 重新开始生成
startGenerate()
const taskId = currentTask.value.taskId
console.log('[Retry] 开始重试任务:', taskId)
// 显示加载状态
const loading = ElLoading.service({
lock: true,
text: '正在重新提交任务...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 调用后端重试API复用原task_id和已上传的图片
const response = await imageToVideoApi.retryTask(taskId)
if (response.data && response.data.success) {
// 更新任务状态
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
ElMessage.success('重试任务已提交')
// 开始轮询任务状态
startPollingTask()
} else {
const errorMsg = response.data?.message || '重试失败'
ElMessage.error(errorMsg)
}
} catch (error) {
console.error('[Retry] 重试任务失败:', error)
ElMessage.error(error.response?.data?.message || error.message || '重试失败')
} finally {
loading.close()
}
}
// 投稿功能(已移除按钮,保留函数已删除)
@@ -1208,8 +1292,19 @@ const restoreProcessingTask = async () => {
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
if (work.thumbnailUrl) {
firstFrameImage.value = processHistoryUrl(work.thumbnailUrl)
const imageUrl = processHistoryUrl(work.thumbnailUrl)
firstFrameImage.value = imageUrl
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
// 尝试从URL加载图片并转换为File对象以便重新生成时可以提交
try {
const response = await fetch(imageUrl)
const blob = await response.blob()
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
} catch (error) {
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
}
}
// 只有真正在进行中的任务才恢复和显示消息
@@ -1263,7 +1358,18 @@ const checkLastTaskStatus = async () => {
}
// 恢复首帧图片(参考图)
if (lastTask.firstFrameUrl) {
firstFrameImage.value = processHistoryUrl(lastTask.firstFrameUrl)
const imageUrl = processHistoryUrl(lastTask.firstFrameUrl)
firstFrameImage.value = imageUrl
// 尝试从URL加载图片并转换为File对象以便重试时可以提交
try {
const response = await fetch(imageUrl)
const blob = await response.blob()
const fileName = `first_frame_${lastTask.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('[Last Task] 首帧图片已转换为文件对象:', fileName)
} catch (error) {
console.warn('[Last Task] 无法从URL加载首帧图片:', error)
}
}
// 恢复其他参数
if (lastTask.aspectRatio) {
@@ -1282,14 +1388,26 @@ const checkLastTaskStatus = async () => {
onMounted(async () => {
// 处理"做同款"传递的路由参数
if (route.query.prompt) {
inputText.value = route.query.prompt
if (route.query.prompt || route.query.referenceImage) {
console.log('[做同款] 接收参数:', route.query)
if (route.query.prompt) {
inputText.value = route.query.prompt
}
if (route.query.aspectRatio) {
aspectRatio.value = route.query.aspectRatio
}
if (route.query.duration) {
duration.value = route.query.duration
}
// 处理参考图
if (route.query.referenceImage) {
firstFrameImage.value = route.query.referenceImage
console.log('[做同款] 设置参考图:', route.query.referenceImage)
// 注意firstFrameFile 为 null用户需要重新上传或点击生成时会提示
}
ElMessage.success(t('video.imageToVideo.historyParamsFilled') || '已填充历史参数')
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
@@ -1679,6 +1797,27 @@ onUnmounted(() => {
color: #6b7280;
}
.input-tips {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: rgba(59, 130, 246, 0.05);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.tip-item {
font-size: 13px;
color: #9ca3af;
line-height: 1.5;
}
.tip-item:first-child {
color: #60a5fa;
font-weight: 500;
}
.optimize-btn {
display: flex;
justify-content: flex-end;
@@ -1789,6 +1928,21 @@ onUnmounted(() => {
background: #6b7280;
cursor: not-allowed;
transform: none;
}
.btn-points {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
font-size: 14px;
}
.btn-points .el-icon {
color: #60a5fa;
box-shadow: none;
}
@@ -1883,7 +2037,6 @@ onUnmounted(() => {
}
.preview-content {
flex: 1;
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 12px;
@@ -1891,11 +2044,11 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 24px;
margin-bottom: 20px;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
min-height: 300px;
}
.preview-content:hover {
@@ -1904,13 +2057,10 @@ onUnmounted(() => {
.preview-placeholder {
width: 100%;
min-height: 200px;
background: #1a1a1a;
border-radius: 8px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.placeholder-text {
@@ -1928,6 +2078,10 @@ onUnmounted(() => {
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
width: 80%;
max-width: 1000px;
min-height: 300px;
box-sizing: border-box;
}
.status-header {
@@ -2043,18 +2197,14 @@ onUnmounted(() => {
}
/* 任务描述样式 */
/* 任务描述样式 - 和历史记录提示词一致 */
.task-description {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
margin: 15px 0;
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
max-height: 120px;
overflow-y: auto;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 视频预览容器 */
@@ -2378,10 +2528,8 @@ onUnmounted(() => {
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
width: 100%;
max-width: none;
}
.history-status-checkbox {
@@ -2430,10 +2578,13 @@ onUnmounted(() => {
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
width: 80%;
max-width: 1000px;
}
.history-preview {
width: 100%;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
@@ -2442,6 +2593,22 @@ onUnmounted(() => {
position: relative;
}
.history-preview.vertical {
aspect-ratio: auto;
width: 80%;
max-width: 1000px;
display: flex;
justify-content: center;
}
.history-preview.vertical .history-video-thumbnail,
.history-preview.vertical .history-placeholder {
aspect-ratio: 9/16;
width: auto;
height: 100%;
max-height: 600px;
}
.history-placeholder {
width: 100%;
height: 100%;

View File

@@ -267,6 +267,15 @@ const handleLogin = async () => {
return
}
// 登录前清除旧的token避免过期token影响新的登录请求
const oldToken = localStorage.getItem('token')
console.log('登录前旧token:', oldToken ? oldToken.substring(0, 50) + '...' : 'null')
localStorage.removeItem('token')
localStorage.removeItem('user')
userStore.token = null
userStore.user = null
try {
console.log('开始登录... 登录方式:', loginType)
@@ -308,10 +317,21 @@ const handleLogin = async () => {
const loginToken = response.data.data.token
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
console.log('登录成功新token:', loginToken ? loginToken.substring(0, 50) + '...' : 'null')
console.log('新旧token是否相同:', oldToken === loginToken)
localStorage.setItem('token', loginToken)
localStorage.setItem('user', JSON.stringify(loginUser))
userStore.user = loginUser
userStore.token = loginToken
// 重置初始化状态,确保路由守卫使用新 token
userStore.resetInitialized()
// 验证保存是否成功
const savedToken = localStorage.getItem('token')
console.log('验证localStorage中的token:', savedToken ? savedToken.substring(0, 50) + '...' : 'null')
console.log('token保存成功:', savedToken === loginToken)
// 根据后端返回的标记设置是否需要修改密码
if (needsPasswordChange) {
@@ -332,8 +352,9 @@ const handleLogin = async () => {
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
// 使用replace而不是push避免浏览器历史记录问题
await router.replace(redirectPath)
// 强制刷新页面确保所有组件使用新token
window.location.href = redirectPath
return
console.log('路由跳转完成')
} else {

View File

@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -216,9 +220,12 @@
</el-form-item>
<el-form-item label="会员等级" prop="level">
<el-select v-model="editForm.level" placeholder="请选择会员等级">
<el-option label="免费会员" value="免费会员" />
<el-option label="标准会员" value="标准会员" />
<el-option label="专业会员" value="专业会员" />
<el-option
v-for="level in membershipLevels"
:key="level.id"
:label="level.displayName"
:value="level.displayName"
/>
</el-select>
</el-form-item>
<el-form-item label="剩余资源点" prop="points">
@@ -261,7 +268,8 @@ import {
ArrowDown,
ArrowLeft,
ArrowRight,
Delete
Delete,
Warning
} from '@element-plus/icons-vue'
import * as memberAPI from '@/api/members'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
@@ -318,6 +326,9 @@ const editRules = {
// 会员数据
const memberList = ref([])
// 会员等级列表从API动态获取
const membershipLevels = ref([])
// 导航功能
const goToDashboard = () => {
router.push('/admin/dashboard')
@@ -335,6 +346,10 @@ const goToTasks = () => {
router.push('/generate-task-record')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}
@@ -714,9 +729,23 @@ const fetchCurrentUserRole = () => {
}
}
// 加载会员等级列表
const loadMembershipLevels = async () => {
try {
const response = await memberAPI.getMembershipLevels()
if (response.data && response.data.success) {
membershipLevels.value = response.data.data || []
}
} catch (error) {
console.error('加载会员等级失败:', error)
}
}
onMounted(() => {
// 获取当前用户角色
fetchCurrentUserRole()
// 加载会员等级列表
loadMembershipLevels()
// 初始化数据
loadMembers()
fetchSystemStats()

View File

@@ -33,14 +33,17 @@
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span>{{ t('works.textToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>{{ t('works.imageToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ t('works.storyboardVideo') }}</span>
<span class="badge-max">Max</span>
</div>
</nav>
</aside>
@@ -122,10 +125,10 @@
size="small"
clearable
style="width: 220px"
@keyup.enter.native="reload"
@keyup.enter.native="doSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
<el-icon class="search-icon" @click="doSearch"><Search /></el-icon>
</template>
</el-input>
</div>
@@ -405,6 +408,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -424,7 +431,7 @@
import { ref, onMounted, onActivated, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete, CopyDocument, Download, Close, Setting, Lock } from '@element-plus/icons-vue'
import { Star, User, Compass, Document, VideoPlay, Picture, Film, Search, MoreFilled, Loading, ArrowUp, VideoCamera, Refresh, Delete, CopyDocument, Download, Close, Setting, Lock, Warning } from '@element-plus/icons-vue'
import { getMyWorks, getWorkDetail, deleteWork, recordDownload, getWorkFileUrl } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
@@ -471,6 +478,7 @@ const resolution = ref(null)
const sortBy = ref(null)
const order = ref('desc')
const keyword = ref('')
const searchKeyword = ref('')
const multiSelect = ref(false)
const selectedIds = ref(new Set())
@@ -584,6 +592,13 @@ const goToMembers = () => {
}
}
const goToErrorStats = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/error-statistics')
}
}
const goToSettings = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
@@ -763,9 +778,9 @@ const filteredItems = computed(() => {
})
}
// 按关键词筛选
if (keyword.value) {
const keywordLower = keyword.value.toLowerCase()
// 按关键词筛选(只有点击搜索或回车后才生效)
if (searchKeyword.value) {
const keywordLower = searchKeyword.value.toLowerCase()
filtered = filtered.filter(item =>
item.title.toLowerCase().includes(keywordLower) ||
item.id.includes(keywordLower)
@@ -781,6 +796,11 @@ const reload = () => {
loadList()
}
// 执行搜索
const doSearch = () => {
searchKeyword.value = keyword.value
}
// 筛选变化时的处理
const onFilterChange = () => {
// 筛选是响应式的,不需要额外处理
@@ -985,11 +1005,19 @@ const createSimilar = (item) => {
duration: item.duration || ''
}
// 添加参考图(图生视频需要,分镜图的 cover 是生成结果不传递)
// 分镜图的 cover 是生成的分镜图,不是用户上传的原始参考图
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
if (item.category === '文生视频') {
router.push({ path: '/text-to-video/create', query })
} else if (item.category === '图生视频') {
router.push({ path: '/image-to-video/create', query })
} else if (item.category === '分镜视频') {
} else if (item.category === '分镜视频' || item.category === '分镜图') {
router.push({ path: '/storyboard-video/create', query })
} else {
// 默认跳转到文生视频
@@ -1274,6 +1302,7 @@ const resetFilters = () => {
resolution.value = null
sortBy.value = null
keyword.value = ''
searchKeyword.value = ''
ElMessage.success(t('works.filtersReset'))
}
@@ -1486,6 +1515,10 @@ const loadUserInfo = async () => {
ElMessage.error(t('profile.loadUserInfoFailed'))
}
} catch (error) {
// 401错误由axios拦截器处理不重复提示
if (error.response?.status === 401) {
return
}
console.error('加载用户信息失败:', error)
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
}
@@ -1846,6 +1879,15 @@ onActivated(() => {
align-items: center;
}
.search-icon {
cursor: pointer;
transition: color 0.2s;
}
.search-icon:hover {
color: #409eff;
}
/* 筛选行:下拉框与重置按钮统一样式 - 匹配设计稿 */
.filters-bar :deep(.el-select__wrapper),
.filters-bar :deep(.el-button) {
@@ -2512,6 +2554,24 @@ onActivated(() => {
background-color: #1a1a1a !important;
border-color: #333 !important;
}
/* Sora2.0 SVG 风格标签 */
.badge-pro, .badge-max {
font-size: 9px;
padding: 0 3px;
border-radius: 2px;
font-weight: 500;
margin-left: 6px;
background: rgba(62, 163, 255, 0.2);
color: #5AE0FF;
flex: 0 0 auto !important;
width: auto !important;
}
.badge-max {
background: rgba(255, 100, 150, 0.2);
color: #FF7EB3;
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->

View File

@@ -30,17 +30,20 @@
<!-- 工具菜单 -->
<nav class="tools-menu">
<div class="nav-item">
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span @click="goToTextToVideo">{{ t('home.textToVideo') }}</span>
<span>{{ t('home.textToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span @click="goToImageToVideo">{{ t('home.imageToVideo') }}</span>
<span>{{ t('home.imageToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
<span>{{ t('home.storyboardVideo') }}</span>
<span class="badge-max">Max</span>
</div>
</nav>
</aside>
@@ -251,6 +254,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click.stop="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer; pointer-events: auto;">
@@ -281,7 +288,8 @@ import {
Compass,
VideoPlay,
Picture,
Film
Film,
Warning
} from '@element-plus/icons-vue'
import { getMyWorks } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
@@ -393,6 +401,14 @@ const goToMembers = () => {
}
}
// 跳转到错误统计
const goToErrorStats = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/error-statistics')
}
}
// 跳转到系统设置
const goToSettings = () => {
showUserMenu.value = false
@@ -438,12 +454,12 @@ const openDetail = async (item) => {
const work = response.data.data
selectedItem.value = transformWorkData(work)
} else {
console.error('获取作品详情失败:', response?.data?.message || '未知错误')
ElMessage.error(t('profile.loadDetailFailed'))
// 如果API返回失败但item已有数据只记录日志不显示错误
console.warn('获取作品详情API返回异常使用列表数据:', response?.data?.message || '未知错误')
}
} catch (error) {
console.error('加载作品详情失败:', error)
ElMessage.error(t('profile.loadDetailFailed') + ': ' + (error.message || '未知错误'))
// 如果API调用失败但item已有数据只记录日志不显示错误
console.warn('加载作品详情API失败使用列表数据:', error)
}
}
@@ -481,10 +497,35 @@ const formatDuration = (dur) => {
// 做同款
const createSimilar = (item) => {
if (!item) return
if (item.type === 'video') {
router.push('/text-to-video/create')
// 构建查询参数
const query = {
prompt: item.prompt || '',
aspectRatio: item.aspectRatio || '',
duration: item.duration || ''
}
// 添加参考图(分镜图的 cover 是生成结果,不是原始参考图,不传递)
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
// 根据作品类型跳转
if (item.category === '文生视频') {
router.push({ path: '/text-to-video/create', query })
} else if (item.category === '图生视频') {
router.push({ path: '/image-to-video/create', query })
} else if (item.category === '分镜视频' || item.category === '分镜图') {
router.push({ path: '/storyboard-video/create', query })
} else {
router.push('/image-to-video/create')
// 默认根据类型跳转
if (item.type === 'video') {
router.push({ path: '/text-to-video/create', query })
} else {
router.push({ path: '/image-to-video/create', query })
}
}
}
@@ -513,7 +554,7 @@ const transformWorkData = (work) => {
const thumbnailUrl = processUrl(work.thumbnailUrl)
return {
id: work.id?.toString() || work.taskId || '',
id: work.taskId || work.id?.toString() || '',
title: work.title || work.prompt || '未命名作品',
cover: thumbnailUrl || resultUrl || '/images/backgrounds/welcome.jpg',
resultUrl: resultUrl || '',
@@ -580,6 +621,10 @@ const loadUserInfo = async () => {
ElMessage.error(t('profile.loadUserInfoFailed'))
}
} catch (error) {
// 401错误由axios拦截器处理不重复提示
if (error.response?.status === 401) {
return
}
console.error('加载用户信息失败:', error)
ElMessage.error(t('profile.loadUserInfoFailed') + ': ' + (error.message || '未知错误'))
} finally {
@@ -1602,6 +1647,24 @@ onUnmounted(() => {
border-radius: 2px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* Sora2.0 SVG 风格标签 */
.badge-pro, .badge-max {
font-size: 9px;
padding: 0 3px;
border-radius: 2px;
font-weight: 500;
margin-left: 6px;
background: rgba(62, 163, 255, 0.2);
color: #5AE0FF;
flex: 0 0 auto !important;
width: auto !important;
}
.badge-max {
background: rgba(255, 100, 150, 0.2);
color: #FF7EB3;
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->

View File

@@ -35,6 +35,7 @@
/>
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
</div>
<!-- 确定按钮 -->
<el-button
@@ -128,7 +129,8 @@ const handleSubmit = async () => {
method: 'post',
data: {
oldPassword: null,
newPassword: form.newPassword
newPassword: form.newPassword,
isFirstTimeSetup: true
}
})
@@ -286,6 +288,13 @@ onMounted(() => {
text-align: left;
}
.input-hint {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
margin-top: 6px;
text-align: left;
}
/* 确定按钮 */
.submit-button {
width: 100%;

View File

@@ -22,14 +22,17 @@
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span>文生视频</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>图生视频</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
<el-icon><Film /></el-icon>
<span>分镜视频</span>
<span class="badge-max">Max</span>
</div>
</nav>
</aside>
@@ -791,6 +794,24 @@ onMounted(() => {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
/* Sora2.0 SVG 风格标签 */
.badge-pro, .badge-max {
font-size: 9px;
padding: 0 3px;
border-radius: 2px;
font-weight: 500;
margin-left: 6px;
background: rgba(62, 163, 255, 0.2);
color: #5AE0FF;
flex: 0 0 auto !important;
width: auto !important;
}
.badge-max {
background: rgba(255, 100, 150, 0.2);
color: #FF7EB3;
}
</style>

View File

@@ -42,6 +42,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click.stop="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -166,8 +170,9 @@
rows="6"
></textarea>
<div class="input-tips">
<div class="tip-item warning">{{ t('video.storyboard.tipWarning') }}</div>
<div class="tip-item">{{ t('video.storyboard.tip1') }}</div>
<div class="tip-item">{{ t('video.storyboard.tip2') }}</div>
<div class="tip-item points">💎 {{ t('video.storyboard.imageCost') }}</div>
</div>
<div class="optimize-btn">
<button type="button" class="optimize-button" @click="handleStep1ButtonClick" :disabled="!inputText.trim() || inProgress">
@@ -249,6 +254,7 @@
<div class="input-tips">
<div class="tip-item">{{ t('video.storyboard.videoTip1') }}</div>
<div class="tip-item">{{ t('video.storyboard.videoTip2') }}</div>
<div class="tip-item points">💎 {{ t('video.storyboard.videoCost') }}</div>
</div>
<div class="optimize-btn">
<button type="button" class="optimize-button" @click="handleStep2ButtonClick" :disabled="!mainReferenceImage || inProgress">
@@ -276,6 +282,7 @@
</select>
</div>
<!-- 高清模式暂时屏蔽
<div class="setting-item">
<label>{{ t('video.storyboard.hdMode') }}</label>
<div class="hd-setting">
@@ -283,6 +290,7 @@
<span class="cost-text">{{ t('video.storyboard.hdCost') }}</span>
</div>
</div>
-->
</div>
</div>
@@ -297,6 +305,7 @@
:disabled="isGenerateButtonDisabled"
>
{{ isAuthenticated ? getButtonText() : t('video.storyboard.pleaseLogin') }}
<span v-if="isAuthenticated" class="btn-points">30 <el-icon><Star /></el-icon></span>
</button>
<div v-if="!isAuthenticated" class="login-tip-floating">
<p>{{ t('video.storyboard.loginRequired') }}</p>
@@ -315,9 +324,12 @@
</div>
<!-- 任务描述 -->
<div class="task-description" v-if="inputText">
<div class="task-description" v-if="currentStep === 'generate' && inputText">
{{ inputText }}
</div>
<div class="task-description" v-else-if="currentStep === 'video' && videoPrompt">
{{ videoPrompt }}
</div>
<!-- 视频预览区域 -->
<div class="video-preview-container">
@@ -534,7 +546,7 @@
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks, retryStoryboardTask } from '@/api/storyboardVideo'
import { imageToVideoApi } from '@/api/imageToVideo'
@@ -556,9 +568,9 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
const inputText = ref('')
const videoPrompt = ref('') // 视频生成提示词
const aspectRatio = ref('16:9')
const duration = ref('10')
const duration = ref('15')
const hdMode = ref(false)
const imageModel = ref('nano-banana')
const imageModel = ref('nano-banana2')
const inProgress = ref(false)
const currentStep = ref('generate') // 'generate' 或 'video'
@@ -687,6 +699,11 @@ const goToSystemSettings = () => {
router.push('/system-settings')
}
const goToErrorStats = () => {
showUserMenu.value = false
router.push('/admin/error-statistics')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -801,18 +818,6 @@ const getButtonText = () => {
return t('video.generate')
}
// 第一步(生成分镜图):根据是否有参考图和提示词显示不同文本
if (hasUploadedImages.value && inputText.value.trim()) {
// 有参考图和提示词:显示"生成分镜图"
return t('video.storyboard.generateStoryboard')
}
if (hasUploadedImages.value) {
// 只有参考图没有提示词:提示输入描述
return t('video.storyboard.generateStoryboard')
}
if (inputText.value.trim()) {
// 只有提示词没有参考图
return t('video.storyboard.generateStoryboard')
}
return t('video.storyboard.generateStoryboard')
}
@@ -1422,6 +1427,7 @@ const pollTaskStatus = async (taskId) => {
const maxAttempts = 90 // 最大尝试次数(足够覆盖整个流程)
let attempts = 0
let currentInterval = 20000 // 初始间隔20秒分镜图生成阶段
let promptWaitAttempts = 0
const poll = async () => {
// 先检查是否超过最大尝试次数
@@ -1575,22 +1581,31 @@ const pollTaskStatus = async (taskId) => {
// 如果进度 < 100说明只是分镜图完成视频还没生成
// 注意如果正在生成视频inProgress=true不要停止轮询
if (taskProgress < 100 && !inProgress.value) {
// 检查提示词是否已经获取到(后端异步优化可能稍晚完成)
const hasPrompts = task.imagePrompt || task.videoPrompt
if (!hasPrompts) {
// 提示词还没就绪,继续轮询(缩短间隔)
console.log('[轮询] 分镜图已生成但提示词未就绪,继续轮询')
currentStep.value = 'video'
pollIntervalId.value = setTimeout(poll, 5000) // 5秒后再试
const imagePromptStr = task.imagePrompt ? String(task.imagePrompt).trim() : ''
const videoPromptStr = task.videoPrompt ? String(task.videoPrompt).trim() : ''
const hasImagePrompt = !!imagePromptStr && imagePromptStr !== 'null'
const hasVideoPrompt = !!videoPromptStr && videoPromptStr !== 'null'
currentStep.value = 'video'
// videoPrompt 还没就绪时:先用 imagePrompt 临时填充,继续短间隔轮询等待真正 videoPrompt
if (!hasVideoPrompt) {
if (!videoPrompt.value.trim() && hasImagePrompt) {
videoPrompt.value = imagePromptStr
}
promptWaitAttempts++
console.log('[轮询] 分镜图已生成但 videoPrompt 未就绪,继续轮询', { promptWaitAttempts })
pollIntervalId.value = setTimeout(poll, 5000)
return
}
// videoPrompt 已就绪,可以停止轮询并提示
if (pollIntervalId.value) {
clearTimeout(pollIntervalId.value)
pollIntervalId.value = null
}
currentStep.value = 'video'
promptWaitAttempts = 0
setTimeout(() => {
ElMessage.success(t('video.storyboard.storyboardCompleted'))
}, 100)
@@ -2719,14 +2734,29 @@ const checkLastTaskStatus = async () => {
onMounted(async () => {
// 处理"做同款"传递的路由参数
if (route.query.prompt) {
inputText.value = route.query.prompt
if (route.query.prompt || route.query.referenceImage) {
console.log('[做同款] 接收参数:', route.query)
if (route.query.prompt) {
inputText.value = route.query.prompt
}
if (route.query.aspectRatio) {
aspectRatio.value = route.query.aspectRatio
}
if (route.query.duration) {
duration.value = route.query.duration
}
// 处理参考图
if (route.query.referenceImage) {
uploadedImages.value = [{
url: route.query.referenceImage,
file: null,
name: '参考图片'
}]
console.log('[做同款] 设置参考图:', route.query.referenceImage)
}
ElMessage.success(t('video.storyboardVideo.historyParamsFilled') || '已填充历史参数')
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
@@ -3744,6 +3774,11 @@ onBeforeUnmount(() => {
font-weight: 500;
}
.tip-item.points {
color: #fbbf24;
font-weight: 500;
}
.optimize-btn {
display: flex;
justify-content: flex-end;
@@ -3883,7 +3918,6 @@ onBeforeUnmount(() => {
background: #6b7280;
cursor: not-allowed;
transform: none;
box-shadow: none;
opacity: 0.6;
}
@@ -3892,6 +3926,21 @@ onBeforeUnmount(() => {
box-shadow: none;
}
.btn-points {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
font-size: 14px;
}
.btn-points .el-icon {
color: #60a5fa;
}
.login-tip-floating {
margin-top: 12px;
text-align: center;
@@ -3963,7 +4012,6 @@ onBeforeUnmount(() => {
.preview-content {
flex: 1;
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 12px;
@@ -3971,14 +4019,14 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
overflow: hidden; /* 防止内容溢出 */
min-height: 0; /* 允许flex子项缩小 */
position: relative; /* 为绝对定位的子元素提供定位参考 */
overflow: hidden;
position: relative;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
min-height: 300px;
padding: 24px;
margin-bottom: 20px;
box-sizing: border-box;
}
.preview-content:hover {
@@ -3987,13 +4035,10 @@ onBeforeUnmount(() => {
.preview-placeholder {
width: 100%;
min-height: 200px;
background: #1a1a1a;
border-radius: 8px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.placeholder-text {
@@ -4212,15 +4257,15 @@ onBeforeUnmount(() => {
/* 任务状态样式 */
.task-status {
background: rgba(255, 255, 255, 0.05);
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 12px;
padding: 20px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
min-height: 300px;
box-sizing: border-box;
}
.status-header {
@@ -4243,18 +4288,14 @@ onBeforeUnmount(() => {
font-family: monospace;
}
/* 任务描述样式 */
/* 任务描述样式 - 和历史记录提示词一致 */
.task-description {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
margin: 15px 0;
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
max-height: 120px;
overflow-y: auto;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 视频预览容器 */
@@ -4636,10 +4677,8 @@ onBeforeUnmount(() => {
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
width: 100%;
max-width: none;
}
.history-status-checkbox {
@@ -4688,10 +4727,13 @@ onBeforeUnmount(() => {
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
width: 80%;
max-width: 1000px;
}
.history-preview {
width: 100%;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;

View File

@@ -33,14 +33,17 @@
<div class="nav-item" @click="goToTextToVideo">
<el-icon><VideoPlay /></el-icon>
<span>{{ $t('home.textToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item" @click="goToImageToVideo">
<el-icon><Picture /></el-icon>
<span>{{ $t('home.imageToVideo') }}</span>
<span class="badge-pro">Pro</span>
</div>
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
<el-icon><Film /></el-icon>
<span>{{ $t('home.storyboardVideo') }}</span>
<span class="badge-max">Max</span>
</div>
</nav>
</aside>
@@ -124,7 +127,7 @@
<div class="package-header">
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
</div>
<div class="package-price">${{ membershipPrices.free }}{{ $t('subscription.perMonth') }}</div>
<div class="package-price">¥{{ membershipPrices.free }}/</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
<div class="package-features">
@@ -132,6 +135,22 @@
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.freeNewUserBonus') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>文生视频30积分/</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>图生视频30积分/</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>分镜图生成30积分/</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>分镜视频生成30积分/</span>
</div>
</div>
</div>
@@ -141,7 +160,7 @@
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
</div>
<div class="package-price">${{ membershipPrices.standard }}{{ $t('subscription.perMonth') }}</div>
<div class="package-price">¥{{ membershipPrices.standard }}/</div>
<div class="points-box">{{ $t('subscription.standardPoints') }}</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
@@ -153,10 +172,6 @@
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.commercialUse') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.noWatermark') }}</span>
</div>
</div>
</div>
@@ -166,7 +181,7 @@
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
</div>
<div class="package-price">${{ membershipPrices.premium }}{{ $t('subscription.perMonth') }}</div>
<div class="package-price">¥{{ membershipPrices.premium }}/</div>
<div class="points-box">{{ $t('subscription.premiumPoints') }}</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
@@ -178,10 +193,6 @@
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.commercialUse') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.noWatermark') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.earlyAccess') }}</span>
@@ -213,6 +224,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -252,7 +267,7 @@
</div>
<div class="stat-item">
<span class="stat-label">{{ $t('subscription.currentPoints') }}</span>
<span class="stat-value current">{{ (userInfo.points || 0) - (userInfo.frozenPoints || 0) }}</span>
<span class="stat-value current">{{ userStore.availablePoints }}</span>
</div>
</div>
</div>
@@ -297,11 +312,12 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios'
import MyWorks from '@/views/MyWorks.vue'
import PaymentModal from '@/components/PaymentModal.vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { User, Document, VideoPlay, Picture, Film, Compass, Star, Check, Plus, Setting, Lock } from '@element-plus/icons-vue'
import { User, Document, VideoPlay, Picture, Film, Compass, Star, Check, Plus, Setting, Lock, Warning } from '@element-plus/icons-vue'
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
import { getPointsHistory } from '@/api/points'
import { getMembershipLevels } from '@/api/members'
@@ -384,11 +400,18 @@ const isRechargeType = (type) => {
return type === '充值' || type.toLowerCase() === 'recharge'
}
// 会员等级价格配置
// 会员等级价格配置从API动态加载禁止硬编码
const membershipPrices = ref({
free: 0,
standard: 59,
premium: 259
standard: null,
premium: null
})
// 会员等级积分配置从API动态加载禁止硬编码
const membershipPoints = ref({
free: 0,
standard: null,
premium: null
})
// 加载用户订阅信息
@@ -507,15 +530,57 @@ const loadUserSubscriptionInfo = async () => {
}
}
// 加载会员等级价格配置(使用固定价格
// 加载会员等级价格配置(从公开接口获取,无需登录
const loadMembershipPrices = async () => {
// 使用固定价格配置
membershipPrices.value = {
free: 0,
standard: 59,
premium: 259
try {
// 添加时间戳防止缓存
const response = await axios.get('/api/public/config', {
params: { _t: Date.now() },
headers: { 'Cache-Control': 'no-cache' }
})
if (response.data) {
// 从membershipLevels数组读取价格必须从数据库获取禁止硬编码
if (response.data.membershipLevels && response.data.membershipLevels.length > 0) {
const levels = response.data.membershipLevels
const freeLevel = levels.find(l => l.name === 'free')
const standardLevel = levels.find(l => l.name === 'standard')
const proLevel = levels.find(l => l.name === 'professional')
if (!standardLevel || !proLevel) {
throw new Error('数据库中缺少standard或professional会员等级配置')
}
membershipPrices.value = {
free: freeLevel?.price || 0,
standard: standardLevel.price,
premium: proLevel.price
}
// 同时加载积分配置
membershipPoints.value = {
free: freeLevel?.pointsBonus || 0,
standard: standardLevel.pointsBonus,
premium: proLevel.pointsBonus
}
} else {
throw new Error('API返回的会员等级数据为空')
}
console.log('从后端加载会员等级配置:', membershipPrices.value, membershipPoints.value)
}
} catch (error) {
console.error('加载价格配置失败:', error.message)
// 禁止使用硬编码默认值,提示用户检查数据库配置
membershipPrices.value = {
free: 0,
standard: null,
premium: null
}
membershipPoints.value = {
free: 0,
standard: null,
premium: null
}
ElMessage.error('无法加载会员等级价格配置请检查数据库中membership_levels表是否有数据')
}
console.log('会员等级价格配置:', membershipPrices.value)
}
// 组件挂载时加载数据
@@ -685,6 +750,13 @@ const goToMembers = () => {
}
}
const goToErrorStats = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/admin/error-statistics')
}
}
const goToSettings = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
@@ -739,13 +811,9 @@ const handleSubscribe = async (planType) => {
console.log('支付数据设置完成:', currentPaymentData.value)
// 显示支付模态框
// 显示支付模态框PaymentModal组件会自动处理支付流程
paymentModalVisible.value = true
console.log('支付模态框应该已显示')
// 直接生成二维码
ElMessage.info(t('subscription.generatingQRCode'))
await generateQRCode(planType, planInfo)
console.log('支付模态框已显示PaymentModal将自动处理支付')
} catch (error) {
console.error('订阅处理失败:', error)
@@ -818,33 +886,36 @@ const generateQRCode = async (planType, planInfo) => {
}
} else {
console.error('支付宝响应失败:', alipayResponse)
ElMessage.error(alipayResponse.data?.message || t('subscription.qrCodeGenerationFailed'))
// 不显示后端返回的详细错误消息,只显示通用提示
ElMessage.error(t('subscription.qrCodeGenerationFailed'))
}
} else {
console.error('创建支付订单失败:', createResponse)
ElMessage.error(createResponse.data?.message || t('subscription.createPaymentFailed'))
// 不显示后端返回的详细错误消息,只显示通用提示
ElMessage.error(t('subscription.createPaymentFailed'))
}
} catch (error) {
console.error('=== 二维码生成出错 ===')
console.error('错误详情:', error)
ElMessage.error(t('subscription.qrCodeGenerationError', { message: error.message || t('subscription.pleaseTryAgain') }))
// 不显示详细错误消息,只显示通用提示
ElMessage.error(t('subscription.qrCodeGenerationFailed'))
}
}
// 获取套餐信息
// 获取套餐信息(使用动态价格和积分,禁止硬编码)
const getPlanInfo = (planType) => {
const plans = {
standard: {
name: t('subscription.standard'),
price: 59,
points: 200,
price: membershipPrices.value.standard,
points: membershipPoints.value.standard,
description: t('subscription.standardDescription')
},
premium: {
name: t('subscription.professional'),
price: 259,
points: 1000,
price: membershipPrices.value.premium,
points: membershipPoints.value.premium,
description: t('subscription.premiumDescription')
}
}
@@ -1557,5 +1628,23 @@ const createSubscriptionOrder = async (planType, planInfo) => {
.history-info strong {
color: white;
}
/* Sora2.0 SVG 风格标签 */
.badge-pro, .badge-max {
font-size: 9px;
padding: 0 3px;
border-radius: 2px;
font-weight: 500;
margin-left: 6px;
background: rgba(62, 163, 255, 0.2);
color: #5AE0FF;
flex: 0 0 auto !important;
width: auto !important;
}
.badge-max {
background: rgba(255, 100, 150, 0.2);
color: #FF7EB3;
}
</style>

View File

@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="nav-item active">
<el-icon><Setting /></el-icon>
<span>{{ $t('nav.systemSettings') }}</span>
@@ -107,8 +111,8 @@
<h3>{{ level.name }}</h3>
</div>
<div class="card-body">
<p class="price">${{ level.price || 0 }}{{ $t('systemSettings.perMonth') }}</p>
<p class="description">{{ $t('systemSettings.includesPointsPerMonth', { points: level.resourcePoints || level.pointsBonus || 0 }) }}</p>
<p class="price">¥{{ level.price || 0 }}/</p>
<p class="description">包含{{ level.resourcePoints || level.pointsBonus || 0 }}积分/</p>
</div>
<div class="card-footer">
<el-button type="primary" @click="editLevel(level)">{{ $t('common.edit') }}</el-button>
@@ -305,7 +309,7 @@
<el-form :model="editForm" :rules="editRules" ref="editFormRef">
<div class="form-group">
<label class="form-label">{{ $t('systemSettings.membershipLevel') }}</label>
<el-select v-model="editForm.level" :placeholder="$t('systemSettings.selectLevelPlaceholder')" style="width: 100%;">
<el-select v-model="editForm.level" :placeholder="$t('systemSettings.selectLevelPlaceholder')" style="width: 100%;" disabled>
<el-option :label="$t('systemSettings.freeMembership')" value="free"></el-option>
<el-option :label="$t('systemSettings.standardMembership')" value="standard"></el-option>
<el-option :label="$t('systemSettings.professionalMembership')" value="professional"></el-option>
@@ -315,7 +319,7 @@
<div class="form-group">
<label class="form-label">{{ $t('systemSettings.membershipPrice') }}</label>
<div class="price-input">
<span class="price-prefix">$</span>
<span class="price-prefix">¥</span>
<input
type="text"
v-model="editForm.price"
@@ -343,12 +347,12 @@
<div class="radio-option">
<input
type="radio"
id="monthly"
id="yearly"
v-model="editForm.validityPeriod"
value="monthly"
value="yearly"
class="radio-input"
>
<label for="monthly" class="radio-label">{{ $t('systemSettings.monthly') }}</label>
<label for="yearly" class="radio-label">{{ $t('systemSettings.yearly') }}</label>
</div>
</div>
</div>
@@ -431,10 +435,11 @@ import {
User as ArrowDown,
Delete,
Refresh,
Check
Check,
Warning
} from '@element-plus/icons-vue'
import api from '@/api/request'
import cleanupApi from '@/api/cleanup'
import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
@@ -458,7 +463,7 @@ const editForm = reactive({
level: '',
price: '',
resourcePoints: 0,
validityPeriod: 'monthly'
validityPeriod: 'yearly'
})
const editRules = computed(() => ({
@@ -522,6 +527,10 @@ const goToTasks = () => {
router.push('/generate-task-record')
}
const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToSettings = () => {
router.push('/system-settings')
}
@@ -537,10 +546,10 @@ const handleUserCommand = (command) => {
const editLevel = (level) => {
// 映射后端数据到前端表单
editForm.id = level.id
editForm.level = level.name || level.displayName
editForm.level = level.key
editForm.price = level.price ? String(level.price) : '0'
editForm.resourcePoints = level.pointsBonus || level.resourcePoints || 0
editForm.validityPeriod = 'monthly' // 默认
editForm.validityPeriod = 'yearly' // 默认
editDialogVisible.value = true
}
@@ -561,76 +570,54 @@ const saveEdit = async () => {
if (!valid) return
try {
// 调用后端API更新会员等级配置
const updateData = {
price: parseFloat(editForm.price),
resourcePoints: parseInt(editForm.resourcePoints),
pointsBonus: parseInt(editForm.resourcePoints),
description: t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
const priceInt = parseInt(editForm.price)
if (Number.isNaN(priceInt) || priceInt < 0) {
ElMessage.error(t('systemSettings.enterValidNumber'))
return
}
await updateMembershipLevel(editForm.id, updateData)
// 更新本地数据
const index = membershipLevels.value.findIndex(level => level.id === editForm.id)
if (index !== -1) {
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 = t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
// 直接更新membership_levels表
const updateData = { price: priceInt }
console.log('准备更新会员等级:', editForm.id, updateData)
const response = await api.put(`/members/levels/${editForm.id}`, updateData)
console.log('会员等级更新响应:', response.data)
if (response.data?.success) {
ElMessage.success(t('systemSettings.membershipUpdateSuccess'))
editDialogVisible.value = false
await loadMembershipLevels()
} else {
ElMessage.error(t('systemSettings.membershipUpdateFailed') + ': ' + (response.data?.message || '未知错误'))
}
ElMessage.success(t('systemSettings.membershipUpdateSuccess'))
editDialogVisible.value = false
// 重新加载会员等级配置
await loadMembershipLevels()
} catch (error) {
console.error('Update membership level failed:', error)
ElMessage.error(t('systemSettings.membershipUpdateFailed') + ': ' + (error.response?.data?.message || error.message))
}
}
// 加载会员等级配置
// 加载会员等级配置从membership_levels表读取
const loadMembershipLevels = async () => {
loadingLevels.value = true
try {
const response = await getMembershipLevels()
console.log('会员等级配置响应:', response)
// 从membership_levels表读取数据
const levelsResp = await api.get('/members/levels', {
params: { _t: Date.now() },
headers: { 'Cache-Control': 'no-cache' }
})
// 检查响应结构
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) {
if (levelsResp.data?.success && levelsResp.data?.data) {
const levels = levelsResp.data.data
membershipLevels.value = levels.map(level => ({
id: level.id,
key: level.name,
name: level.displayName || level.name,
price: level.price || 0,
resourcePoints: level.pointsBonus || 0,
pointsBonus: level.pointsBonus || 0,
description: level.description || t('systemSettings.includesPointsPerMonth', { points: level.pointsBonus || 0 })
description: t('systemSettings.includesPointsPerMonth', { points: level.pointsBonus || 0 })
}))
console.log('Membership levels loaded:', membershipLevels.value)
} else {
// 如果没有数据,使用默认值
console.warn('No membership data in database, using defaults')
membershipLevels.value = [
{ id: 1, name: t('systemSettings.freeMembership'), price: 0, resourcePoints: 200, description: t('systemSettings.includesPointsPerMonth', { points: 200 }) },
{ id: 2, name: t('systemSettings.standardMembership'), price: 59, resourcePoints: 500, description: t('systemSettings.includesPointsPerMonth', { points: 500 }) },
{ id: 3, name: t('systemSettings.professionalMembership'), price: 250, resourcePoints: 2000, description: t('systemSettings.includesPointsPerMonth', { points: 2000 }) }
]
throw new Error('获取会员等级数据失败')
}
} catch (error) {
console.error('Load membership config failed:', error)
@@ -640,12 +627,9 @@ const loadMembershipLevels = async () => {
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('systemSettings.unknown')
ElMessage.warning(`${t('systemSettings.loadMembershipFailed')}: ${errorMessage}, ${t('systemSettings.usingDefaultConfig')}`)
// 使用默认值,确保页面可以正常显示
membershipLevels.value = [
{ id: 1, name: t('systemSettings.freeMembership'), price: 0, resourcePoints: 200, description: t('systemSettings.includesPointsPerMonth', { points: 200 }) },
{ id: 2, name: t('systemSettings.standardMembership'), price: 59, resourcePoints: 500, description: t('systemSettings.includesPointsPerMonth', { points: 500 }) },
{ id: 3, name: t('systemSettings.professionalMembership'), price: 250, resourcePoints: 2000, description: t('systemSettings.includesPointsPerMonth', { points: 2000 }) }
]
// API调用失败清空数据并提示用户检查数据库配置
membershipLevels.value = []
ElMessage.error('无法加载会员等级配置请检查数据库中membership_levels表是否有数据')
} finally {
loadingLevels.value = false
}

View File

@@ -385,10 +385,10 @@ onMounted(() => {
/* 主内容区域 */
.main-content {
flex: 1;
padding: 24px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 20px;
}
/* 用户信息卡片 */
@@ -450,7 +450,7 @@ onMounted(() => {
.published-works {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
}
.works-tabs {
@@ -481,14 +481,14 @@ onMounted(() => {
.works-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.work-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
@@ -570,25 +570,31 @@ onMounted(() => {
/* .work-overlay 和 .overlay-text 已移除:不再使用 */
.work-info {
padding: 16px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.work-title {
font-size: 16px;
font-size: 14px;
font-weight: 600;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-meta {
font-size: 12px;
font-size: 11px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.work-actions {
padding: 0 16px 16px;
padding: 0 12px 12px;
opacity: 0;
transition: opacity 0.2s ease;
}
@@ -602,7 +608,7 @@ onMounted(() => {
}
.work-director {
padding: 0 16px 16px;
padding: 0 12px 12px;
text-align: center;
}
@@ -619,7 +625,7 @@ onMounted(() => {
}
.works-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}

View File

@@ -65,6 +65,7 @@
</select>
</div>
<!-- 高清模式暂时屏蔽
<div class="setting-item">
<label>{{ t('video.textToVideo.hdMode') }}</label>
<div class="hd-setting">
@@ -72,6 +73,7 @@
<span class="cost-text">{{ t('video.textToVideo.hdModeCost') }}</span>
</div>
</div>
-->
</div>
<!-- 生成按钮 -->
@@ -82,6 +84,7 @@
:disabled="!isAuthenticated || isCreatingTask"
>
{{ !isAuthenticated ? t('video.textToVideo.pleaseLogin') : isCreatingTask ? t('video.textToVideo.creatingTask') : t('video.textToVideo.startGenerate') }}
<span v-if="isAuthenticated && !isCreatingTask" class="btn-points">30 <el-icon><Star /></el-icon></span>
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>{{ t('video.textToVideo.loginRequired') }}</p>
@@ -218,7 +221,7 @@
<div class="history-prompt">{{ task.prompt || t('video.textToVideo.noDescription') }}</div>
<!-- 预览区域 -->
<div class="history-preview">
<div class="history-preview" :class="{ 'vertical': task.aspectRatio === '9:16' }">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.textToVideo.queuing') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
@@ -293,6 +296,10 @@
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
<div class="menu-item" @click.stop="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -315,7 +322,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { textToVideoApi } from '@/api/textToVideo'
import { useUserStore } from '@/stores/user'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
@@ -332,7 +339,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
// 响应式数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const duration = ref(10)
const duration = ref(15)
const hdMode = ref(false)
const inProgress = ref(false)
const currentTask = ref(null)
@@ -435,6 +442,11 @@ const goToSystemSettings = () => {
router.push('/system-settings')
}
const goToErrorStats = () => {
showUserMenu.value = false
router.push('/admin/error-statistics')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -801,16 +813,49 @@ const downloadHistoryVideo = async (task) => {
}
}
// 重新生成
const retryTask = () => {
// 重置状态
currentTask.value = null
inProgress.value = false
taskProgress.value = 0
taskStatus.value = ''
// 重新生成复用原task_id
const retryTask = async () => {
// 检查是否有失败的任务可以重试
if (!currentTask.value || !currentTask.value.taskId) {
ElMessage.error('没有可重试的任务')
return
}
// 重新开始生成
startGenerate()
const taskId = currentTask.value.taskId
console.log('[Retry] 开始重试任务:', taskId)
// 显示加载状态
const loading = ElLoading.service({
lock: true,
text: '正在重新提交任务...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 调用后端重试API复用原task_id
const response = await textToVideoApi.retryTask(taskId)
if (response.data && response.data.success) {
// 更新任务状态
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
ElMessage.success('重试任务已提交')
// 开始轮询任务状态
startPollingTask()
} else {
const errorMsg = response.data?.message || '重试失败'
ElMessage.error(errorMsg)
}
} catch (error) {
console.error('[Retry] 重试任务失败:', error)
ElMessage.error(error.response?.data?.message || error.message || '重试失败')
} finally {
loading.close()
}
}
// 投稿功能(已移除按钮,保留函数已删除)
@@ -1478,6 +1523,21 @@ onUnmounted(() => {
background: #6b7280;
cursor: not-allowed;
transform: none;
}
.btn-points {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 8px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
font-size: 14px;
}
.btn-points .el-icon {
color: #60a5fa;
box-shadow: none;
}
@@ -1572,7 +1632,6 @@ onUnmounted(() => {
}
.preview-content {
flex: 1;
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 12px;
@@ -1580,11 +1639,11 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 24px;
margin-bottom: 20px;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
align-self: flex-start;
min-height: 300px;
}
.preview-content:hover {
@@ -1593,13 +1652,10 @@ onUnmounted(() => {
.preview-placeholder {
width: 100%;
min-height: 200px;
background: #1a1a1a;
border-radius: 8px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.placeholder-text {
@@ -1706,11 +1762,15 @@ onUnmounted(() => {
/* 任务状态样式 */
.task-status {
background: rgba(255, 255, 255, 0.05);
background: #1a1a1a;
border: 2px solid #2a2a2a;
border-radius: 12px;
padding: 20px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
width: 80%;
max-width: 1000px;
min-height: 300px;
box-sizing: border-box;
}
.status-header {
@@ -1796,18 +1856,14 @@ onUnmounted(() => {
}
/* 任务描述样式 */
/* 任务描述样式 - 和历史记录提示词一致 */
.task-description {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 16px;
margin: 15px 0;
color: #e5e7eb;
font-size: 14px;
line-height: 1.6;
color: #e5e7eb;
border: 1px solid rgba(255, 255, 255, 0.1);
max-height: 120px;
overflow-y: auto;
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 视频预览容器 */
@@ -2147,10 +2203,8 @@ onUnmounted(() => {
border: none;
padding: 0;
transition: all 0.2s ease;
width: 80%;
max-width: 1000px;
margin-left: 0;
margin-right: auto;
width: 100%;
max-width: none;
}
.history-status-checkbox {
@@ -2199,10 +2253,13 @@ onUnmounted(() => {
margin-bottom: 16px;
white-space: pre-wrap;
word-wrap: break-word;
width: 80%;
max-width: 1000px;
}
.history-preview {
width: 100%;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
@@ -2211,6 +2268,22 @@ onUnmounted(() => {
position: relative;
}
.history-preview.vertical {
aspect-ratio: auto;
width: 80%;
max-width: 1000px;
display: flex;
justify-content: center;
}
.history-preview.vertical .history-video-thumbnail,
.history-preview.vertical .history-placeholder {
aspect-ratio: 9/16;
width: auto;
height: 100%;
max-height: 600px;
}
.history-placeholder {
width: 100%;
height: 100%;

View File

@@ -249,7 +249,7 @@ const loadVideoData = async () => {
const thumbnailUrl = processUrl(work.thumbnailUrl)
videoData.value = {
id: work.id?.toString() || '',
id: work.taskId || work.id?.toString() || '',
username: work.username || work.user?.username || '未知用户',
title: work.title || work.prompt || '未命名作品',
description: work.prompt || work.description || '暂无提示词',