feat: 添加任务状态级联触发器,优化支付和做同款功能

主要更新:
- 添加 MySQL 触发器实现 task_status 表到其他表的状态级联
- 移除控制器中的多表状态检查代码
- 完善做同款功能,支持参数传递
- 支付宝 USD 转 CNY 汇率转换
- 修复状态枚举映射问题

注意: 触发器仅在 task_status 更新时触发,部分代码仍直接更新业务表
This commit is contained in:
AIGC Developer
2025-12-08 13:54:02 +08:00
parent 624d560fb4
commit 3c37006ebd
84 changed files with 5325 additions and 1668 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -607,6 +607,21 @@ main.with-navbar {
font-family: inherit;
}
/* 修复 el-select 选中值不显示的问题 */
.el-select .el-select__wrapper .el-select__selected-item {
color: inherit !important;
display: flex !important;
}
.el-select .el-select__wrapper .el-select__selection {
display: flex !important;
flex-wrap: nowrap !important;
}
.el-select .el-select__wrapper .el-select__input-wrapper {
display: flex !important;
}
/* 移除 el-dialog 的所有可能的白色边框 */
.payment-modal-dialog,
.payment-modal-dialog.el-dialog,

View File

@@ -34,3 +34,13 @@ export const getMembershipLevels = () => {
export const updateMembershipLevel = (id, data) => {
return api.put(`/members/levels/${id}`, data)
}
// 封禁/解封会员
export const toggleBanMember = (id, isActive) => {
return api.put(`/members/${id}/ban`, { isActive })
}
// 设置用户角色(仅超级管理员可用)
export const setUserRole = (id, role) => {
return api.put(`/members/${id}/role`, { role })
}

View File

@@ -46,11 +46,18 @@
<!-- 支付宝二维码区域 -->
<div v-if="selectedMethod === 'alipay'" class="qr-section">
<div class="qr-code">
<img id="qr-code-img" style="display: none; width: 200px; height: 200px; margin: 0; padding: 0; border: none; object-fit: contain; background: #1a1a1a;" alt="支付二维码" />
<div ref="qrPlaceholder" class="qr-placeholder">
<div class="qr-grid">
<div class="qr-dot" v-for="i in 64" :key="i"></div>
</div>
<img v-if="showQrCode && qrCodeUrl" :src="qrCodeUrl" style="width: 200px; height: 200px; margin: 0; padding: 0; border: none; object-fit: contain; background: #1a1a1a;" alt="支付二维码" />
<div v-if="!showQrCode" class="qr-placeholder">
<svg width="200" height="200" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg">
<foreignObject x="-5.8" y="-5.8" width="371.6" height="371.6"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.9px);clip-path:url(#bgblur_0_605_316_clip_path);height:100%;width:100%"></div></foreignObject>
<rect data-figma-bg-blur-radius="5.8" width="360" height="360" rx="10" fill="#0F0F12" fill-opacity="0.9"/>
<defs>
<clipPath id="bgblur_0_605_316_clip_path" transform="translate(5.8 5.8)"><rect width="360" height="360" rx="10"/></clipPath>
</defs>
<!-- 加载动画 -->
<text x="180" y="165" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-size="28" font-family="Arial" font-weight="500">正在生成二维码</text>
<text x="180" y="210" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-size="22" font-family="Arial">请稍候...</text>
</svg>
</div>
</div>
<div class="qr-tip">支付前请阅读Vionow支付服务条款</div>
@@ -83,6 +90,9 @@
<p>请使用支付宝扫描上方二维码完成支付</p>
<p class="tip-small">支付完成后页面将自动更新</p>
</div>
<button class="check-payment-btn" @click="manualCheckPayment" :disabled="!currentPaymentId">
我已完成支付
</button>
</div>
<!-- 底部链接 -->
@@ -126,28 +136,43 @@ const visible = ref(false)
const selectedMethod = ref('alipay')
const loading = ref(false)
const currentPaymentId = ref(null)
const qrCodeUrl = ref('') // 二维码URL
const showQrCode = ref(false) // 是否显示二维码
let paymentPollingTimer = null
let isPaymentStarted = false // 防止重复调用
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
// 当模态框打开时,自动开始支付流程
if (newVal) {
// 当模态框打开时,自动开始支付流程(只调用一次)
if (newVal && !isPaymentStarted) {
isPaymentStarted = true
handlePay()
}
// 关闭时重置标志
if (!newVal) {
isPaymentStarted = false
}
})
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
// 如果模态框关闭,停止轮询
// 如果模态框关闭,停止轮询并重置状态
if (!newVal) {
stopPaymentPolling()
qrCodeUrl.value = ''
showQrCode.value = false
}
})
// 选择支付方式
const selectMethod = (method) => {
// 如果切换了支付方式,需要重置 paymentId因为支付方式不同需要创建新的支付记录
if (selectedMethod.value !== method) {
currentPaymentId.value = null
console.log('切换支付方式,重置 paymentId')
}
selectedMethod.value = method
}
@@ -156,6 +181,31 @@ const handlePay = async () => {
try {
loading.value = true
// 如果还没有创建支付记录,先创建
if (!currentPaymentId.value) {
// 生成唯一的订单ID加上时间戳避免重复
const uniqueOrderId = `${props.orderId}_${Date.now()}`
const paymentData = {
orderId: uniqueOrderId,
amount: props.amount.toString(),
method: selectedMethod.value === 'paypal' ? 'PAYPAL' : 'ALIPAY',
description: `${props.title} - ${selectedMethod.value === 'paypal' ? 'PayPal' : '支付宝'}支付`
}
console.log('=== 创建支付记录 ===')
console.log('支付数据:', paymentData)
const createResponse = await createPayment(paymentData)
console.log('创建支付订单响应:', createResponse)
if (createResponse.data && createResponse.data.success) {
currentPaymentId.value = createResponse.data.data.id
console.log('支付记录创建成功ID', currentPaymentId.value)
} else {
throw new Error(createResponse.data?.message || '创建支付记录失败')
}
}
// 根据选择的支付方式处理
if (selectedMethod.value === 'paypal') {
await handlePayPalPayment()
@@ -179,125 +229,57 @@ const handlePay = async () => {
// 处理支付宝支付
const handleAlipayPayment = async () => {
ElMessage.info('正在创建支付订单...')
// 重置二维码显示状态
showQrCode.value = false
qrCodeUrl.value = ''
// 创建支付订单数据
const paymentData = {
orderId: props.orderId,
amount: props.amount.toString(),
method: 'ALIPAY',
description: `${props.title} - 支付宝支付`
}
ElMessage.info('正在生成支付二维码...')
const paymentId = currentPaymentId.value
console.log('=== 开始支付宝支付流程 ===')
console.log('支付数据', paymentData)
console.log('使用已创建的支付ID', paymentId)
// 创建支付宝支付
const alipayResponse = await createAlipayPayment({ paymentId })
console.log('支付宝支付响应:', alipayResponse)
if (alipayResponse.data && alipayResponse.data.success) {
// 显示二维码
const qrCode = alipayResponse.data.data.qrCode
console.log('支付宝二维码:', qrCode)
// 先创建支付记录
console.log('1. 创建支付订单...')
const createResponse = await createPayment(paymentData)
console.log('创建支付订单响应:', createResponse)
// 使用QuickChart API生成二维码
qrCodeUrl.value = `https://quickchart.io/qr?text=${encodeURIComponent(qrCode)}&size=200&margin=0&dark=ffffff&light=1a1a1a`
if (createResponse.data && createResponse.data.success) {
const paymentId = createResponse.data.data.id
currentPaymentId.value = paymentId
console.log('2. 支付订单创建成功ID', paymentId)
ElMessage.info('正在生成支付宝二维码...')
console.log('3. 创建支付宝支付...')
// 创建支付宝支付
const alipayResponse = await createAlipayPayment({ paymentId })
console.log('支付宝支付响应:', alipayResponse)
console.log('支付宝支付响应数据:', alipayResponse.data)
console.log('支付宝支付响应数据详情:', JSON.stringify(alipayResponse.data, null, 2))
if (alipayResponse.data && alipayResponse.data.success) {
// 显示二维码
const qrCode = alipayResponse.data.data.qrCode
console.log('4. 支付宝二维码:', qrCode)
// 使用在线API生成二维码图片直接使用支付宝返回的URL生成二维码
try {
console.log('开始生成二维码,内容:', qrCode)
// 使用QuickChart API生成二维码完全去除白边
// 直接使用支付宝返回的URL作为二维码内容
const qrCodeUrl = `https://quickchart.io/qr?text=${encodeURIComponent(qrCode)}&size=200&margin=0&dark=ffffff&light=1a1a1a`
console.log('5. 二维码图片URL已生成')
// 更新二维码显示
const qrCodeElement = document.querySelector('#qr-code-img')
if (qrCodeElement) {
qrCodeElement.src = qrCodeUrl
qrCodeElement.style.display = 'block'
console.log('6. 二维码图片已设置')
}
// 隐藏模拟二维码
const qrPlaceholder = document.querySelector('.qr-placeholder')
if (qrPlaceholder) {
qrPlaceholder.style.display = 'none'
console.log('7. 模拟二维码已隐藏')
}
ElMessage.success('二维码已生成,请使用支付宝扫码支付')
console.log('=== 支付流程完成,开始轮询支付状态 ===')
// 开始轮询支付状态
startPaymentPolling(paymentId)
} catch (error) {
console.error('生成二维码失败:', error)
ElMessage.error('生成二维码失败,请重试')
}
} else {
console.error('支付宝响应失败:', alipayResponse)
ElMessage.error(alipayResponse.data?.message || '生成二维码失败')
emit('pay-error', new Error(alipayResponse.data?.message || '生成二维码失败'))
}
} else {
console.error('创建支付订单失败:', createResponse)
ElMessage.error(createResponse.data?.message || '创建支付订单失败')
emit('pay-error', new Error(createResponse.data?.message || '创建支付订单失败'))
}
console.log('二维码图片URL已生成:', qrCodeUrl.value)
// 显示二维码
showQrCode.value = true
console.log('二维码已显示')
// 开始轮询支付状态
console.log('=== 开始轮询支付状态 ===')
startPaymentPolling(paymentId)
} else {
console.error('支付宝响应失败', alipayResponse)
ElMessage.error(alipayResponse.data?.message || '生成二维码失败')
emit('pay-error', new Error(alipayResponse.data?.message || '生成二维码失败'))
}
}
// 处理PayPal支付
const handlePayPalPayment = async () => {
ElMessage.info('正在创建PayPal支付...')
// 从sessionStorage获取用户信息
const userStr = sessionStorage.getItem('user')
let username = 'guest'
if (userStr) {
try {
const user = JSON.parse(userStr)
username = user.username || user.name || 'guest'
} catch (e) {
console.error('解析用户信息失败:', e)
}
}
const paymentData = {
username: username,
orderId: props.orderId,
amount: props.amount.toString(),
method: 'PAYPAL'
}
const paymentId = currentPaymentId.value
console.log('=== 开始PayPal支付流程 ===')
console.log('支付数据', paymentData)
console.log('使用已创建的支付ID', paymentId)
const response = await createPayPalPayment(paymentData)
const response = await createPayPalPayment({ paymentId })
console.log('PayPal支付响应', response)
if (response.data && response.data.success) {
const paymentUrl = response.data.paymentUrl
const paymentId = response.data.paymentId
currentPaymentId.value = paymentId
console.log('PayPal支付URL:', paymentUrl)
ElMessage.success('正在跳转到PayPal支付页面...')
@@ -317,7 +299,8 @@ const startPaymentPolling = (paymentId) => {
stopPaymentPolling()
let pollCount = 0
const maxPolls = 60 // 最多轮询60次10分钟10秒一次)
const maxPolls = 200 // 最多轮询200次10分钟3秒一次)
const pollInterval = 3000 // 3秒轮询一次更快响应支付成功
const poll = async () => {
if (pollCount >= maxPolls) {
@@ -329,13 +312,22 @@ const startPaymentPolling = (paymentId) => {
try {
console.log(`轮询支付状态 (${pollCount + 1}/${maxPolls})支付ID:`, paymentId)
const response = await getPaymentById(paymentId)
console.log('轮询响应:', response.data)
if (response.data && response.data.success) {
const payment = response.data.data
const status = payment.status
console.log('支付状态:', status, '状态说明:', getStatusDescription(status))
// 兼容枚举可能是字符串或对象的情况
const rawStatus = payment.status
// 更全面地解析状态:可能是字符串、对象、或者对象的属性
let status = rawStatus
if (typeof rawStatus === 'object' && rawStatus !== null) {
status = rawStatus.name || rawStatus.value || rawStatus.toString()
}
// 转为大写以便比较
const statusUpper = String(status).toUpperCase()
console.log('支付状态原始值:', rawStatus, '类型:', typeof rawStatus, '解析后:', status, '大写:', statusUpper)
if (status === 'SUCCESS' || status === 'COMPLETED') {
if (statusUpper === 'SUCCESS' || statusUpper === 'COMPLETED') {
console.log('✅ 支付成功!支付数据:', payment)
stopPaymentPolling()
ElMessage.success('支付成功!')
@@ -345,44 +337,42 @@ const startPaymentPolling = (paymentId) => {
visible.value = false
}, 2000)
return
} else if (status === 'FAILED' || status === 'CANCELLED') {
} else if (statusUpper === 'FAILED' || statusUpper === 'CANCELLED') {
console.log('支付失败或取消')
stopPaymentPolling()
ElMessage.warning('支付已取消或失败')
emit('pay-error', new Error('支付已取消或失败'))
return
} else if (status === 'PROCESSING') {
} else if (statusUpper === 'PROCESSING') {
console.log('支付处理中...')
// PROCESSING 状态继续轮询,但可以给用户提示
if (pollCount % 6 === 0) { // 每60秒提示一次
ElMessage.info('支付处理中,请稍候...')
}
} else if (status === 'PENDING') {
// PROCESSING 状态继续轮询
} else if (statusUpper === 'PENDING') {
console.log('支付待处理中(等待支付宝回调)...')
// PENDING 状态继续轮询
if (pollCount % 6 === 0) { // 每60秒提示一次
ElMessage.info('等待支付确认,请确保已完成支付...')
}
} else {
console.log('未知状态:', statusUpper, '继续轮询...')
}
} else {
console.warn('轮询响应失败:', response.data)
}
// 继续轮询
pollCount++
paymentPollingTimer = setTimeout(poll, 10000) // 每10秒轮询一次
paymentPollingTimer = setTimeout(poll, pollInterval)
} catch (error) {
console.error('轮询支付状态失败:', error)
// 错误时也继续轮询,直到达到最大次数
pollCount++
if (pollCount < maxPolls) {
paymentPollingTimer = setTimeout(poll, 10000)
paymentPollingTimer = setTimeout(poll, pollInterval)
}
}
}
// 开始轮询(等待5秒后开始第一次轮询)
// 开始轮询(等待2秒后开始第一次轮询,更快响应
setTimeout(() => {
poll()
}, 5000)
}, 2000)
}
// 停止轮询支付状态
@@ -394,6 +384,49 @@ const stopPaymentPolling = () => {
}
}
// 手动检查支付状态
const manualCheckPayment = async () => {
if (!currentPaymentId.value) {
ElMessage.warning('请先生成支付二维码')
return
}
ElMessage.info('正在查询支付状态...')
try {
const response = await getPaymentById(currentPaymentId.value)
if (response.data && response.data.success) {
const payment = response.data.data
// 兼容枚举可能是字符串或对象的情况
const rawStatus = payment.status
// 更全面地解析状态
let status = rawStatus
if (typeof rawStatus === 'object' && rawStatus !== null) {
status = rawStatus.name || rawStatus.value || rawStatus.toString()
}
const statusUpper = String(status).toUpperCase()
console.log('手动查询支付状态原始值:', rawStatus, '类型:', typeof rawStatus, '解析后:', status, '大写:', statusUpper)
if (statusUpper === 'SUCCESS' || statusUpper === 'COMPLETED') {
stopPaymentPolling()
ElMessage.success('支付成功!')
emit('pay-success', payment)
setTimeout(() => {
visible.value = false
}, 1500)
} else if (statusUpper === 'FAILED' || statusUpper === 'CANCELLED') {
ElMessage.warning('支付已取消或失败')
} else {
ElMessage.info(`支付尚未完成(状态:${statusUpper}),请完成支付后再试`)
}
}
} catch (error) {
console.error('查询支付状态失败:', error)
ElMessage.error('查询失败,请稍后重试')
}
}
// 关闭模态框
const handleClose = () => {
stopPaymentPolling()
@@ -866,55 +899,21 @@ const showAgreement = () => {
}
.qr-placeholder {
width: 180px;
height: 180px;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
border-radius: 8px;
width: 200px;
height: 200px;
background: transparent;
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.qr-placeholder::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 60px;
height: 60px;
border: 3px solid #4a9eff;
border-radius: 8px;
transform: translate(-50%, -50%);
opacity: 0.6;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.8;
transform: translate(-50%, -50%) scale(1.05);
}
}
.qr-placeholder::after {
content: '扫码支付';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, calc(-50% + 40px));
color: white !important;
font-size: 12px !important;
font-weight: 500 !important;
opacity: 0.8 !important;
line-height: 1.5 !important;
letter-spacing: 0.3px !important;
.qr-placeholder svg {
width: 100%;
height: 100%;
border-radius: 10px;
}
.qr-tip {
@@ -1010,6 +1009,34 @@ const showAgreement = () => {
color: #3a8bdf !important;
}
/* 我已完成支付按钮 */
.check-payment-btn {
width: 100%;
padding: 12px 24px;
margin-top: 16px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.check-payment-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4);
}
.check-payment-btn:disabled {
background: #4a4a4a;
cursor: not-allowed;
opacity: 0.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.payment-modal :deep(.el-dialog) {

View File

@@ -8,13 +8,19 @@
</div>
<div class="progress-section" v-if="taskStatus">
<div class="progress-bar">
<!-- 排队中不确定进度条 -->
<div v-if="taskStatus.status === 'PENDING'" class="progress-bar indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="progress-bar">
<div
class="progress-fill"
class="progress-fill animated"
:style="{ width: taskStatus.progress + '%' }"
></div>
</div>
<div class="progress-text">{{ taskStatus.progress }}%</div>
<div class="progress-text" v-if="taskStatus.status !== 'PENDING'">{{ taskStatus.progress }}%</div>
<div class="progress-text" v-else>排队中...</div>
</div>
<div class="task-info">
@@ -283,6 +289,59 @@ onUnmounted(() => {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
transition: width 0.3s ease;
position: relative;
}
/* 动态进度条动画 */
.progress-fill.animated {
background: linear-gradient(90deg, #3b82f6, #60a5fa, #3b82f6);
background-size: 200% 100%;
animation: progress-gradient 2s ease infinite, progress-pulse 1.5s ease-in-out infinite;
}
.progress-fill.animated::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: progress-shine 1.5s ease-in-out infinite;
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
@keyframes progress-shine {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* 不确定进度条(排队中) */
.progress-bar.indeterminate {
overflow: hidden;
}
.progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #3b82f6, #60a5fa, #3b82f6, transparent);
border-radius: 4px;
animation: indeterminate-slide 1.5s ease-in-out infinite;
}
@keyframes indeterminate-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.progress-text {

View File

@@ -570,7 +570,9 @@ export default {
tasks: 'Task Records',
systemSettings: 'System Settings',
onlineUsers: 'Online Users',
systemUptime: 'System Uptime'
systemUptime: 'System Uptime',
todayVisitors: 'Today Visitors',
loading: 'Loading...'
},
admin: {
@@ -585,6 +587,7 @@ export default {
dailyActive: 'Daily Active User Trend',
conversionRate: 'User Conversion Rate',
comparedToLastMonth: 'vs last month',
comparedToYesterday: 'vs yesterday',
year2025: '2025',
year2024: '2024',
year2023: '2023',
@@ -602,7 +605,8 @@ export default {
month11: 'Nov',
month12: 'Dec',
pleaseLogin: 'Please login first',
loadDataFailed: 'Failed to load dashboard data'
loadDataFailed: 'Failed to load dashboard data',
unknownError: 'Unknown error'
},
orders: {
@@ -616,6 +620,7 @@ export default {
operation: 'Operation',
allStatus: 'All Status',
allTypes: 'All Types',
allPaymentMethods: 'All Payment Methods',
pending: 'Pending',
confirmed: 'Confirmed',
paid: 'Paid',
@@ -633,7 +638,18 @@ export default {
orderDetail: 'Order Detail',
basicInfo: 'Basic Info',
orderType: 'Order Type',
paymentInfo: 'Payment Info'
paymentInfo: 'Payment Info',
confirmDeleteOrder: 'Are you sure to delete order {orderNumber}?',
confirmDeleteTitle: 'Confirm Delete',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Delete failed',
pleaseSelectOrders: 'Please select orders to delete first',
confirmBatchDelete: 'Are you sure to delete the selected {count} orders?',
batchDeleteTitle: 'Batch Delete',
batchDeleteSuccess: 'Batch delete successful',
batchDeleteFailed: 'Batch delete failed',
loadOrdersFailed: 'Failed to load orders',
apiDataFormatError: 'API data format error'
},
tasks: {
@@ -740,6 +756,30 @@ export default {
originalTasksDeleted: 'Original task records will be deleted',
irreversibleWarning: 'This operation is irreversible, please proceed with caution!',
confirmCleanup: 'Confirm Cleanup',
selectLevelRequired: 'Please select membership level',
enterPriceRequired: 'Please enter price',
enterValidNumber: 'Please enter a valid number',
enterResourcePointsRequired: 'Please enter resource points',
selectValidityRequired: 'Please select validity period',
enterUsernameRequired: 'Please enter username',
usernameLengthLimit: 'Username must be 2-50 characters',
membershipUpdateSuccess: 'Membership level updated successfully',
membershipUpdateFailed: 'Failed to update membership level',
loadMembershipFailed: 'Failed to load membership configuration',
usingDefaultConfig: 'Using default configuration',
statsRefreshSuccess: 'Statistics refreshed successfully',
statsRefreshFailed: 'Failed to get statistics',
fullCleanupSuccess: 'Full cleanup executed successfully',
fullCleanupFailed: 'Failed to execute full cleanup',
userCleanupSuccess: 'User tasks cleaned up successfully',
userCleanupFailed: 'Failed to cleanup user tasks',
configSaveSuccess: 'Cleanup configuration saved successfully',
configSaveFailed: 'Failed to save cleanup configuration',
aiModelSaveSuccess: 'AI model settings saved successfully',
aiModelSaveFailed: 'Failed to save AI model settings',
aiModelLoadFailed: 'Failed to load AI model settings',
includesPointsPerMonth: 'Includes {points} points/month',
unknown: 'Unknown',
aiModel: 'AI Model Settings',
promptOptimization: 'Prompt Optimization Settings',
promptOptimizationApiUrl: 'API Endpoint',

View File

@@ -452,6 +452,7 @@ export default {
createSimilarInfo: '基于作品"{title}"创建同款',
goToCreate: '跳转到创作页面',
downloadStart: '开始下载:{title}',
downloadComplete: '下载完成',
shareComingSoon: '分享链接功能即将上线',
downloadWithWatermarkStart: '开始下载带水印版本',
downloadWithoutWatermarkStart: '开始下载不带水印版本(会员专享)',
@@ -463,6 +464,7 @@ export default {
bulkDeleteSuccess: '已删除选中项目',
filtersReset: '筛选器已重置',
processing: '生成中...',
queuing: '排队中...',
noPreview: '无预览',
videoLoadFailed: '视频加载失败',
videoFileNotExist: '视频文件可能不存在或已被删除',
@@ -583,7 +585,8 @@ export default {
tasks: '生成任务记录',
systemSettings: '系统设置',
onlineUsers: '当前在线用户',
systemUptime: '系统运行时间'
systemUptime: '系统运行时间',
todayVisitors: '今日访客'
},
admin: {
@@ -598,6 +601,7 @@ export default {
dailyActive: '日活用户趋势',
conversionRate: '用户转化率',
comparedToLastMonth: '较上月同期',
comparedToYesterday: '较昨日',
year2025: '2025年',
year2024: '2024年',
year2023: '2023年',
@@ -713,6 +717,7 @@ export default {
autoCleanup: '自动清理',
perMonth: '/月',
includesPoints: '包含{points}资源点/月',
includesPointsPerMonth: '包含{points}资源点/月',
cleanupStatsInfo: '清理统计信息',
refresh: '刷新',
currentTotalTasks: '当前任务总数',

View File

@@ -31,6 +31,8 @@ const TaskStatusPage = () => import('@/views/TaskStatusPage.vue')
const TermsOfService = () => import('@/views/TermsOfService.vue')
const UserAgreement = () => import('@/views/UserAgreement.vue')
const PrivacyPolicy = () => import('@/views/PrivacyPolicy.vue')
const ChangePassword = () => import('@/views/ChangePassword.vue')
const SetPassword = () => import('@/views/SetPassword.vue')
const routes = [
{
@@ -212,6 +214,18 @@ const routes = [
component: PrivacyPolicy,
meta: { title: '隐私政策' }
},
{
path: '/change-password',
name: 'ChangePassword',
component: ChangePassword,
meta: { title: '修改密码', requiresAuth: true }
},
{
path: '/set-password',
name: 'SetPassword',
component: SetPassword,
meta: { title: '设置密码', requiresAuth: true }
},
]
const router = createRouter({

View File

@@ -22,7 +22,8 @@ export const useUserStore = defineStore('user', () => {
// 计算属性
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'ROLE_ADMIN')
const isAdmin = computed(() => user.value?.role === 'ROLE_ADMIN' || user.value?.role === 'ROLE_SUPER_ADMIN')
const isSuperAdmin = computed(() => user.value?.role === 'ROLE_SUPER_ADMIN')
const username = computed(() => user.value?.username || '')
// 可用积分(总积分 - 冻结积分)
@@ -141,6 +142,9 @@ export const useUserStore = defineStore('user', () => {
console.log('恢复用户状态:', user.value?.username, '角色:', user.value?.role)
}
// 刷新用户信息(确保角色等信息是最新的)
await fetchCurrentUser()
} catch (error) {
console.error('Failed to restore user state:', error)
clearUserData()
@@ -158,6 +162,7 @@ export const useUserStore = defineStore('user', () => {
// 计算属性
isAuthenticated,
isAdmin,
isSuperAdmin,
username,
availablePoints,
// 方法

View File

@@ -35,7 +35,7 @@
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
@@ -105,7 +105,7 @@
<div class="stat-title">{{ $t('dashboard.todayRevenue') }}</div>
<div class="stat-number">{{ formatCurrency(stats.todayRevenue) }}</div>
<div class="stat-change" :class="stats.todayRevenueChange >= 0 ? 'positive' : 'negative'">
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% {{ $t('dashboard.comparedToLastMonth') }}
{{ stats.todayRevenueChange >= 0 ? '+' : '' }}{{ stats.todayRevenueChange }}% {{ $t('dashboard.comparedToYesterday') }}
</div>
</div>
</div>
@@ -151,6 +151,7 @@
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
Grid,
User,
@@ -167,6 +168,7 @@ import { getDashboardOverview, getConversionRate, getDailyActiveUsersTrend, getS
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const { t } = useI18n()
// 年份选择
const selectedYear = ref('2025')
@@ -186,7 +188,7 @@ const loading = ref(false)
// 系统状态数据
const onlineUsers = ref('0/500')
const systemUptime = ref('加载中...')
const systemUptime = ref(t('nav.loading'))
// 图表相关
const dailyActiveChart = ref(null)
@@ -272,21 +274,19 @@ const loadDashboardData = async () => {
totalUsers: data.totalUsers || 0,
paidUsers: data.paidUsers || 0,
todayRevenue: data.todayRevenue || 0,
// 暂时使用固定值后续可以从API获取同比数据
// TODO: 后端需要添加计算同比变化的逻辑
totalUsersChange: 0, // 暂时设为0等待后端实现
paidUsersChange: 0, // 暂时设为0等待后端实现
todayRevenueChange: 0 // 暂时设为0等待后端实现
totalUsersChange: data.totalUsersChange ?? 0,
paidUsersChange: data.paidUsersChange ?? 0,
todayRevenueChange: data.todayRevenueChange ?? 0
}
console.log('设置后的统计数据:', stats.value)
} else {
console.error('获取仪表盘数据失败:', data.error || data.message)
ElMessage.error('获取仪表盘数据失败: ' + (data.message || '未知错误'))
console.error('Get dashboard data failed:', data.error || data.message)
ElMessage.error(t('dashboard.loadDataFailed') + ': ' + (data.message || t('dashboard.unknownError')))
}
} catch (error) {
console.error('加载仪表盘数据失败:', error)
ElMessage.error('加载仪表盘数据失败: ' + (error.message || '未知错误'))
console.error('Load dashboard data failed:', error)
ElMessage.error(t('dashboard.loadDataFailed') + ': ' + (error.message || t('dashboard.unknownError')))
} finally {
loading.value = false
}
@@ -488,44 +488,26 @@ onMounted(async () => {
}, 30000)
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
const response = await getSystemStatus()
console.log('系统状态API返回:', response)
if (response && response.data && response.data.success) {
const data = response.data.data
console.log('系统状态数据:', data)
// 设置在线用户数
const currentOnline = data.onlineUsers || 0
const maxOnline = data.maxUsers || 500
onlineUsers.value = `${currentOnline}/${maxOnline}`
console.log(`在线用户数已更新: ${onlineUsers.value}`)
// 设置系统运行时间
if (data.uptime) {
// 假设后端返回的是秒数,转换为小时和分钟
const totalSeconds = data.uptime
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
systemUptime.value = `${hours}小时${minutes}`
} else if (data.uptimeFormatted) {
// 或者后端直接返回格式化的字符串
systemUptime.value = data.uptimeFormatted
} else {
systemUptime.value = '未知'
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
console.log(`系统运行时间已更新: ${systemUptime.value}`)
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || t('systemSettings.unknown')
} else {
console.warn('系统状态API返回格式不正确:', response)
throw new Error('获取系统状态失败')
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
}

View File

@@ -42,8 +42,8 @@
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
</div>
@@ -93,11 +93,7 @@
<el-select v-model="filters.status" :placeholder="$t('orders.allStatus')" size="small" @change="handleFilterChange">
<el-option :label="$t('orders.allStatus')" value="" />
<el-option :label="$t('orders.pending')" value="PENDING" />
<el-option :label="$t('orders.confirmed')" value="CONFIRMED" />
<el-option :label="$t('orders.paid')" value="PAID" />
<el-option :label="$t('orders.processing')" value="PROCESSING" />
<el-option :label="$t('orders.shipped')" value="SHIPPED" />
<el-option :label="$t('orders.delivered')" value="DELIVERED" />
<el-option :label="$t('orders.completed')" value="COMPLETED" />
<el-option :label="$t('orders.cancelled')" value="CANCELLED" />
<el-option :label="$t('orders.refunded')" value="REFUNDED" />
@@ -290,6 +286,7 @@
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
Grid,
User,
@@ -310,6 +307,7 @@ import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
// 判断是否为管理员模式(基于路由路径)
const isAdminMode = computed(() => route.path.includes('/admin/'))
@@ -324,7 +322,7 @@ const totalOrders = ref(0)
// 系统状态数据
const onlineUsers = ref('0/500')
const systemUptime = ref('加载中...')
const systemUptime = ref(t('nav.loading'))
// 筛选条件
const filters = reactive({
@@ -513,17 +511,17 @@ const viewOrder = async (order) => {
const deleteOrder = async (order) => {
try {
await ElMessageBox.confirm(
`确定要删除订单 ${order.orderNumber || order.id} 吗?`,
'确认删除',
t('orders.confirmDeleteOrder', { orderNumber: order.orderNumber || order.id }),
t('orders.confirmDeleteTitle'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
const response = await deleteOrderAPI(order.id)
console.log('删除订单响应:', response)
console.log('Delete order response:', response)
// 检查响应状态
if (response.data?.success) {
@@ -532,51 +530,51 @@ const deleteOrder = async (order) => {
orders.value.splice(index, 1)
totalOrders.value--
}
ElMessage.success('删除成功')
ElMessage.success(t('orders.deleteSuccess'))
} else {
ElMessage.error(response.data?.message || '删除失败')
ElMessage.error(response.data?.message || t('orders.deleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败: ' + (error.message || '未知错误'))
console.error('Delete failed:', error)
ElMessage.error(t('orders.deleteFailed') + ': ' + (error.message || t('dashboard.unknownError')))
}
}
}
const deleteSelected = async () => {
if (selectedOrders.value.length === 0) {
ElMessage.warning('请先选择要删除的订单')
ElMessage.warning(t('orders.pleaseSelectOrders'))
return
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedOrders.value.length} 个订单吗?`,
'批量删除',
t('orders.confirmBatchDelete', { count: selectedOrders.value.length }),
t('orders.batchDeleteTitle'),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
const ids = selectedOrders.value.map(o => o.id)
const response = await deleteOrders(ids)
console.log('批量删除订单响应:', response)
console.log('Batch delete orders response:', response)
if (response.data?.success) {
orders.value = orders.value.filter(o => !ids.includes(o.id))
totalOrders.value -= response.data?.deletedCount || ids.length
selectedOrders.value = []
ElMessage.success(response.data?.message || '批量删除成功')
ElMessage.success(response.data?.message || t('orders.batchDeleteSuccess'))
} else {
ElMessage.error(response.data?.message || '批量删除失败')
ElMessage.error(response.data?.message || t('orders.batchDeleteFailed'))
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败: ' + (error.message || '未知错误'))
console.error('Batch delete failed:', error)
ElMessage.error(t('orders.batchDeleteFailed') + ': ' + (error.message || t('dashboard.unknownError')))
}
}
}
@@ -615,8 +613,8 @@ const fetchOrders = async () => {
orders.value = pageData
totalOrders.value = pageData.length
} else {
console.error('API返回数据格式错误: data不是Page对象也不是数组', pageData)
ElMessage.error('API返回数据格式错误')
console.error('API data format error: data is not Page object or array', pageData)
ElMessage.error(t('orders.apiDataFormatError'))
}
} else if (responseData.content) {
// 直接返回Page对象没有success包装
@@ -627,13 +625,13 @@ const fetchOrders = async () => {
orders.value = responseData.list || []
totalOrders.value = responseData.total || 0
} else {
console.error('API返回数据格式错误:', responseData)
ElMessage.error('API返回数据格式错误')
console.error('API data format error:', responseData)
ElMessage.error(t('orders.apiDataFormatError'))
}
} catch (error) {
console.error('获取订单列表失败:', error)
ElMessage.error('获取订单列表失败: ' + (error.message || '未知错误'))
console.error('Get orders list failed:', error)
ElMessage.error(t('orders.loadOrdersFailed') + ': ' + (error.message || t('dashboard.unknownError')))
} finally {
loading.value = false
}
@@ -651,22 +649,26 @@ onMounted(() => {
fetchSystemStats()
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || t('systemSettings.unknown')
} else {
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
}
</script>
@@ -899,6 +901,39 @@ const fetchSystemStats = async () => {
gap: 16px;
}
/* 确保下拉框选中值可见 */
.toolbar-left :deep(.el-select) {
min-width: 120px;
}
.toolbar-left :deep(.el-select__wrapper) {
color: #374151 !important;
font-size: 14px;
min-height: 32px;
}
.toolbar-left :deep(.el-select__wrapper *) {
color: #374151 !important;
}
.toolbar-left :deep(.el-select__selection) {
display: flex !important;
align-items: center !important;
}
.toolbar-left :deep(.el-select__selected-item) {
color: #374151 !important;
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.toolbar-left :deep(.el-select__placeholder) {
color: #9ca3af !important;
opacity: 1 !important;
visibility: visible !important;
}
.table-container {
background: white;
border-radius: 8px;

View File

@@ -33,7 +33,7 @@
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
@@ -139,7 +139,7 @@ const loading = ref(false)
// 系统状态数据
const onlineUsers = ref('0/500')
const systemUptime = ref('加载中...')
const systemUptime = ref(t('common.loading'))
const apiForm = reactive({
apiKey: '',
jwtExpirationHours: 24 // 默认24小时
@@ -221,13 +221,13 @@ const saveApiKey = async () => {
// 验证输入:至少需要提供一个配置项
if (!hasApiKey && !hasJwtExpiration) {
ElMessage.warning('请至少输入API密钥或设置Token过期时间')
ElMessage.warning(t('apiManagement.atLeastOneRequired') || '请至少输入API密钥或设置Token过期时间')
return
}
// 验证JWT过期时间范围
if (hasJwtExpiration && (apiForm.jwtExpirationHours < 1 || apiForm.jwtExpirationHours > 720)) {
ElMessage.warning('Token过期时间必须在1-720小时之间1小时-30天')
ElMessage.warning(t('apiManagement.tokenRangeError') || 'Token过期时间必须在1-720小时之间')
return
}
@@ -275,22 +275,26 @@ onMounted(() => {
fetchSystemStats()
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || $t('systemSettings.unknown')
} else {
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
}
</script>

View File

@@ -0,0 +1,382 @@
<template>
<div class="login-page">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
</div>
<!-- 修改密码卡片 -->
<div class="login-card">
<!-- 标题 -->
<div class="page-title">修改密码</div>
<!-- 表单 -->
<div class="password-form">
<!-- 当前密码可选 -->
<div class="input-group">
<el-input
v-model="form.currentPassword"
placeholder="输入当前密码(可选)"
class="password-input"
show-password
@keyup.enter="handleSubmit"
/>
<div class="input-error" v-if="errors.currentPassword">{{ errors.currentPassword }}</div>
</div>
<!-- 新密码 -->
<div class="input-group">
<el-input
v-model="form.newPassword"
placeholder="输入新密码"
class="password-input"
show-password
@keyup.enter="handleSubmit"
/>
<div class="input-error" v-if="errors.newPassword">{{ errors.newPassword }}</div>
</div>
<!-- 确认新密码 -->
<div class="input-group">
<el-input
v-model="form.confirmPassword"
placeholder="确认新密码"
class="password-input"
show-password
@keyup.enter="handleSubmit"
/>
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
</div>
<!-- 确定修改按钮 -->
<el-button
type="primary"
class="submit-button"
:loading="loading"
@click="handleSubmit"
>
{{ loading ? '提交中...' : '确定修改' }}
</el-button>
<!-- 返回按钮 -->
<div class="back-button-wrapper">
<el-button
class="back-button"
@click="handleBack"
>
返回
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
// 判断是否首次设置密码
const isFirstTimeSetup = computed(() => {
return sessionStorage.getItem('needSetPassword') === '1'
})
const form = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const errors = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
// 验证表单
const validateForm = () => {
let valid = true
errors.currentPassword = ''
errors.newPassword = ''
errors.confirmPassword = ''
// 当前密码为可选,不强制必填
// 新密码必填且必须包含英文字母和数字不少于8位
if (!form.newPassword) {
errors.newPassword = '请输入新密码'
valid = false
} else if (form.newPassword.length < 8) {
errors.newPassword = '密码长度至少8位'
valid = false
} else if (!/[a-zA-Z]/.test(form.newPassword)) {
errors.newPassword = '密码必须包含英文字母'
valid = false
} else if (!/[0-9]/.test(form.newPassword)) {
errors.newPassword = '密码必须包含数字'
valid = false
}
// 确认密码必填且必须与新密码一致
if (!form.confirmPassword) {
errors.confirmPassword = '请确认新密码'
valid = false
} else if (form.newPassword !== form.confirmPassword) {
errors.confirmPassword = '两次输入的密码不一致'
valid = false
}
return valid
}
// 提交修改
const handleSubmit = async () => {
if (!validateForm()) return
loading.value = true
try {
const response = await request({
url: '/auth/change-password',
method: 'post',
data: {
oldPassword: form.currentPassword || null,
newPassword: form.newPassword
}
})
console.log('修改密码响应:', response)
// response.data 是后端返回的数据
const result = response.data
if (result && result.success) {
ElMessage.success('密码修改成功')
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/profile'
router.replace(redirect)
} else {
ElMessage.error(result?.message || '修改失败')
}
} catch (error) {
console.error('修改密码失败:', error)
const errorMsg = error.response?.data?.message || error.message || '修改失败,请重试'
ElMessage.error(errorMsg)
} finally {
loading.value = false
}
}
// 返回
const handleBack = () => {
if (isFirstTimeSetup.value) {
// 首次设置时返回到首页
router.replace('/')
} else {
// 非首次设置时返回上一页
router.back()
}
}
onMounted(() => {
// 检查用户是否已登录
if (!userStore.isAuthenticated) {
router.replace('/login')
}
})
</script>
<style scoped>
.login-page {
min-height: 100vh;
width: 100vw;
height: 100vh;
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
z-index: 1;
}
/* 左上角Logo */
.logo {
position: absolute;
top: 30px;
left: 30px;
z-index: 10;
}
.logo img {
height: 40px;
width: auto;
}
/* 卡片 */
.login-card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 550px;
max-width: 90vw;
background: rgba(121, 121, 121, 0.1);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 60px 80px;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* 页面标题 */
.page-title {
text-align: center;
font-size: 32px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 50px;
}
/* 表单 */
.password-form {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 输入组 */
.input-group {
margin-bottom: 5px;
}
.password-input {
width: 100%;
}
.password-input :deep(.el-input__wrapper) {
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
box-shadow: none;
height: 60px;
transition: all 0.3s ease;
}
.password-input :deep(.el-input__wrapper:hover) {
background: rgba(217, 217, 217, 0.25);
}
.password-input :deep(.el-input__wrapper.is-focus) {
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.password-input :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.9);
background: transparent;
font-size: 16px;
}
.password-input :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.5);
}
.input-error {
color: #ff7875;
font-size: 12px;
margin-top: 6px;
text-align: left;
}
/* 确定修改按钮 */
.submit-button {
width: 100%;
height: 60px;
background: #0DC0FF;
border: none;
border-radius: 10px;
color: white;
font-size: 18px;
font-weight: 500;
margin-top: 20px;
transition: all 0.3s ease;
}
.submit-button:hover {
background: #4DD4FF;
transform: translateY(-1px);
}
.submit-button:active {
transform: translateY(0);
}
/* 返回按钮 */
.back-button {
width: 100%;
height: 60px;
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
font-weight: 500;
transition: all 0.3s ease;
}
.back-button:hover {
background: rgba(217, 217, 217, 0.3);
}
.back-button-wrapper {
width: 100%;
}
.back-button-wrapper .back-button {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
width: 90%;
padding: 40px 30px;
}
.page-title {
font-size: 28px;
}
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.page-title {
font-size: 24px;
}
}
</style>

View File

@@ -33,7 +33,7 @@
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
@@ -754,22 +754,26 @@ onMounted(() => {
fetchSystemStats()
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || $t('systemSettings.unknown')
} else {
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
}
</script>
@@ -996,6 +1000,39 @@ const fetchSystemStats = async () => {
gap: 16px;
}
/* 确保下拉框选中值可见 */
.toolbar-left :deep(.el-select) {
min-width: 120px;
}
.toolbar-left :deep(.el-select__wrapper) {
color: #374151 !important;
font-size: 14px;
min-height: 32px;
}
.toolbar-left :deep(.el-select__wrapper *) {
color: #374151 !important;
}
.toolbar-left :deep(.el-select__selection) {
display: flex !important;
align-items: center !important;
}
.toolbar-left :deep(.el-select__selected-item) {
color: #374151 !important;
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.toolbar-left :deep(.el-select__placeholder) {
color: #9ca3af !important;
opacity: 1 !important;
visibility: visible !important;
}
.table-container {
background: white;
border-radius: 8px;

View File

@@ -71,8 +71,6 @@
<label>{{ t('video.aspectRatio') }}</label>
<select v-model="aspectRatio" class="setting-select">
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="3:4">3:4</option>
<option value="9:16">9:16</option>
</select>
</div>
@@ -99,9 +97,9 @@
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated || inProgress || isCreatingTask"
:disabled="!isAuthenticated || isCreatingTask"
>
{{ !isAuthenticated ? t('video.imageToVideo.pleaseLogin') : (inProgress || isCreatingTask) ? t('video.imageToVideo.taskInProgress') : t('video.imageToVideo.startGenerate') }}
{{ !isAuthenticated ? t('video.imageToVideo.pleaseLogin') : isCreatingTask ? t('video.imageToVideo.creatingTask') : t('video.imageToVideo.startGenerate') }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>{{ t('video.imageToVideo.loginRequired') }}</p>
@@ -131,10 +129,16 @@
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ t('video.generating') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: taskProgress + '%' }"></div>
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.imageToVideo.statusPending') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中确定进度条 -->
<div v-else class="progress-bar-large">
<div class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
</div>
</div>
@@ -235,7 +239,15 @@
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">{{ t('video.imageToVideo.queuing') }}</div>
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.imageToVideo.queuing') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中确定进度条 -->
<div v-else class="history-progress-bar">
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
</div>
<div class="queue-link">{{ t('video.imageToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('video.imageToVideo.cancel') }}</button>
</div>
@@ -261,9 +273,19 @@
</div>
</div>
<!-- 做同款按钮 -->
<!-- 操作按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('video.imageToVideo.createSimilar') }}</button>
<button
v-if="task.status === 'COMPLETED' && task.resultUrl"
class="download-btn"
@click="downloadHistoryVideo(task)"
:title="t('video.imageToVideo.downloadVideo')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
</div>
</div>
</div>
@@ -275,24 +297,32 @@
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<div class="menu-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>{{ t('common.userProfile') }}</span>
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click.stop="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click.stop="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToSystemSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<div class="menu-item" @click="goToMyWorks">
<el-icon><VideoCamera /></el-icon>
<span>{{ t('profile.myWorks') }}</span>
</div>
<div class="menu-item" @click="goToSubscription">
<el-icon><Star /></el-icon>
<span>{{ t('profile.subscription') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('common.settings') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item logout" @click="logout">
<!-- 退出登录 -->
<div class="menu-item" @click.stop="logout">
<el-icon><SwitchButton /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
@@ -303,17 +333,18 @@
<script setup>
import { ref, reactive, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
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 } from '@element-plus/icons-vue'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { imageToVideoApi } from '@/api/imageToVideo'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const { t } = useI18n()
@@ -414,6 +445,31 @@ const goToSettings = () => {
router.push('/settings')
}
const goToDashboard = () => {
showUserMenu.value = false
router.push('/admin/dashboard')
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
router.push('/member-management')
}
const goToSystemSettings = () => {
showUserMenu.value = false
router.push('/system-settings')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
const logout = () => {
showUserMenu.value = false
userStore.logout()
@@ -502,11 +558,7 @@ const startGenerate = async () => {
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning(t('video.imageToVideo.taskInProgress'))
return
}
// 允许多任务并发后端会检查最大任务数限制最多3个
// 验证表单
if (!firstFrameFile.value) {
@@ -572,7 +624,25 @@ const startGenerate = async () => {
} else {
// 任务创建失败,重置所有状态
ElMessage.error(response.data?.message || t('video.imageToVideo.createTaskFailed'))
const errorMsg = response.data?.message || t('video.imageToVideo.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
@@ -582,7 +652,25 @@ const startGenerate = async () => {
} catch (error) {
console.error('创建任务失败:', error)
ElMessage.error(t('video.imageToVideo.createTaskFailedRetry'))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.imageToVideo.createTaskFailedRetry'))
}
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
@@ -783,17 +871,64 @@ const createSimilar = () => {
}
// 下载视频
const downloadVideo = () => {
if (currentTask.value && currentTask.value.resultUrl) {
const downloadVideo = async () => {
if (!currentTask.value || !currentTask.value.resultUrl) {
ElMessage.error(t('video.imageToVideo.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(currentTask.value.resultUrl)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = currentTask.value.resultUrl
link.download = `video_${currentTask.value.taskId}.mp4`
link.href = url
link.download = `video_${currentTask.value.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.imageToVideo.startDownload'))
} else {
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(currentTask.value.resultUrl, '_blank')
}
}
// 下载历史记录中的视频
const downloadHistoryVideo = async (task) => {
if (!task || !task.resultUrl) {
ElMessage.error(t('video.imageToVideo.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
const videoUrl = processHistoryUrl(task.resultUrl)
const response = await fetch(videoUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.imageToVideo.startDownload'))
} catch (error) {
console.error('下载失败:', error)
window.open(processHistoryUrl(task.resultUrl), '_blank')
}
}
@@ -876,12 +1011,14 @@ const loadHistory = async () => {
}
try {
const response = await imageToVideoApi.getTasks(0, 5)
// 请求更多条数以确保能筛选出足够的任务
const response = await imageToVideoApi.getTasks(0, 50)
console.log('历史记录API响应:', response.data)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
// 只显示已完成的任务,不显示失败的任务
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 5)
).slice(0, 10)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
@@ -899,7 +1036,7 @@ const loadHistory = async () => {
}
// 从历史记录创建同款
const createSimilarFromHistory = (task) => {
const createSimilarFromHistory = async (task) => {
if (task.prompt) {
inputText.value = task.prompt
}
@@ -907,14 +1044,25 @@ const createSimilarFromHistory = (task) => {
aspectRatio.value = task.aspectRatio
}
if (task.duration) {
duration.value = task.duration
duration.value = String(task.duration)
}
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 如果有首帧图片URL尝试加载
// 如果有首帧图片URL加载并转换为文件对象
if (task.firstFrameUrl) {
firstFrameImage.value = task.firstFrameUrl
// 尝试从URL加载图片并转换为File对象
try {
const response = await fetch(task.firstFrameUrl)
const blob = await response.blob()
const fileName = `first_frame_${task.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
console.log('首帧图片已转换为文件对象:', fileName)
} catch (error) {
console.warn('无法从URL加载首帧图片:', error)
// 即使转换失败,也保留预览图片
}
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -1055,7 +1203,7 @@ const restoreProcessingTask = async () => {
aspectRatio.value = work.aspectRatio
}
if (work.duration) {
duration.value = work.duration || '10'
duration.value = String(work.duration)
}
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
@@ -1122,7 +1270,7 @@ const checkLastTaskStatus = async () => {
aspectRatio.value = lastTask.aspectRatio
}
if (lastTask.duration) {
duration.value = lastTask.duration
duration.value = String(lastTask.duration)
}
}
// 如果最近一条任务是成功的,不需要处理
@@ -1133,6 +1281,20 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 处理"做同款"传递的路由参数
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
}
ElMessage.success(t('video.imageToVideo.historyParamsFilled') || '已填充历史参数')
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
loadHistory()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(async () => {
@@ -1257,14 +1419,14 @@ onUnmounted(() => {
transform: scale(1.05);
}
/* 用户菜单样式 */
/* 用户菜单样式 - 深色主题 */
.user-menu-teleport {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 8px 0;
min-width: 200px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
z-index: 99999;
}
@@ -1273,36 +1435,24 @@ onUnmounted(() => {
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #333;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #f5f7fa;
color: #667eea;
}
.menu-item.logout {
color: #f56565;
}
.menu-item.logout:hover {
background: #fef2f2;
color: #e53e3e;
}
.menu-divider {
height: 1px;
background: #e2e8f0;
margin: 4px 0;
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 12px;
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
/* 主内容区域 */
.main-content {
flex: 1;
@@ -1954,6 +2104,64 @@ onUnmounted(() => {
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
/* 生成中进度条动态动画 - 使用蓝绿渐变风格 */
.progress-fill-large.animated {
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 不确定进度条(排队中) */
.progress-bar-large.indeterminate {
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 4px;
animation: progress-move 1.5s ease-in-out infinite;
}
/* 进度百分比文字 */
.progress-percentage {
font-size: 14px;
color: #60a5fa;
font-weight: 600;
margin-top: 12px;
text-align: center;
}
/* 历史记录进度条 */
.history-progress-bar {
width: 80%;
max-width: 200px;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
margin: 12px 0;
}
.history-progress-bar.indeterminate .progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 3px;
animation: progress-move 1.5s ease-in-out infinite;
}
.history-progress-bar .progress-fill-large {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
border-radius: 3px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 完成状态 */
@@ -2344,6 +2552,8 @@ onUnmounted(() => {
.history-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
margin-top: 0;
}
@@ -2364,6 +2574,27 @@ onUnmounted(() => {
border-color: #4a4a4a;
}
.download-btn {
width: 36px;
height: 36px;
background: #fff;
color: #333;
border: none;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.download-btn:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {
@@ -2434,12 +2665,16 @@ onUnmounted(() => {
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(233%); }
100% { transform: translateX(-100%); }
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
</style>

View File

@@ -83,7 +83,14 @@
<!-- 密码输入仅密码登录显示 -->
<div v-if="loginType === 'password'" class="password-input-group">
<el-input ref="passwordInput" v-model="loginForm.password" placeholder="请输入密码" show-password @keyup.enter="handleLogin" />
<el-input
ref="passwordInput"
v-model="loginForm.password"
placeholder="请输入密码"
class="password-input"
show-password
@keyup.enter="handleLogin"
/>
<div class="input-error" v-if="errors.password">{{ errors.password }}</div>
</div>
</div>
@@ -320,9 +327,9 @@ const handleLogin = async () => {
// 等待一下确保状态更新
await new Promise(resolve => setTimeout(resolve, 200))
// 如果需要设置密码,优先跳转到个人主页,由个人主页负责弹出修改密码弹窗
// 如果需要设置密码,跳转到设置密码页面
const needSetPassword = sessionStorage.getItem('needSetPassword') === '1'
const redirectPath = needSetPassword ? '/profile' : (route.query.redirect || '/profile')
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
// 使用replace而不是push避免浏览器历史记录问题
@@ -348,7 +355,7 @@ const handleLogin = async () => {
min-height: 100vh;
width: 100vw;
height: 100vh;
background: #0a0e1a url('/images/backgrounds/login-bg.svg') center/cover no-repeat;
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
position: fixed;
top: 0;
left: 0;
@@ -518,7 +525,7 @@ const handleLogin = async () => {
.email-input :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
font-size: 24px;
}
.email-input :deep(.el-input__inner::placeholder) {
@@ -552,7 +559,7 @@ const handleLogin = async () => {
.code-input :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
font-size: 24px;
}
.code-input :deep(.el-input__inner::placeholder) {
@@ -581,7 +588,7 @@ const handleLogin = async () => {
margin-top: 30px;
}
.password-input-group :deep(.el-input__wrapper) {
.password-input :deep(.el-input__wrapper) {
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
@@ -590,22 +597,22 @@ const handleLogin = async () => {
transition: all 0.3s ease;
}
.password-input-group :deep(.el-input__wrapper:hover) {
.password-input :deep(.el-input__wrapper:hover) {
background: rgba(217, 217, 217, 0.25);
}
.password-input-group :deep(.el-input__wrapper.is-focus) {
.password-input :deep(.el-input__wrapper.is-focus) {
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.password-input-group :deep(.el-input__inner) {
.password-input :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.5);
background: transparent;
font-size: 16px;
font-size: 24px;
}
.password-input-group :deep(.el-input__inner::placeholder) {
.password-input :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.5);
}
@@ -642,12 +649,12 @@ const handleLogin = async () => {
/* 协议文字 */
.agreement-text {
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
margin: 20px 0 0 0;
line-height: 30px;
width: 266px;
height: 30px;
width: auto;
height: auto;
margin-left: auto;
margin-right: auto;
}

View File

@@ -33,7 +33,7 @@
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
@@ -78,10 +78,16 @@
<div class="table-toolbar">
<div class="toolbar-left">
<el-select v-model="selectedLevel" :placeholder="$t('members.allLevels')" size="small" @change="handleLevelChange">
<el-option :label="$t('members.allLevels')" value="all" />
<el-option :label="$t('members.professional')" value="professional" />
<el-option :label="$t('members.standard')" value="standard" />
<el-select v-model="selectedLevel" placeholder="会员等级" size="small" @change="handleFilterChange">
<el-option label="全部等级" value="all" />
<el-option label="免费会员" value="free" />
<el-option label="标准会员" value="standard" />
<el-option label="专业会员" value="professional" />
</el-select>
<el-select v-model="selectedStatus" placeholder="用户状态" size="small" @change="handleFilterChange" style="margin-left: 10px;">
<el-option label="活跃用户" value="active" />
<el-option label="封禁用户" value="banned" />
<el-option label="全部用户" value="all" />
</el-select>
</div>
<div class="toolbar-right">
@@ -101,7 +107,9 @@
</th>
<th>{{ $t('members.userId') }}</th>
<th>{{ $t('members.username') }}</th>
<th>角色</th>
<th>{{ $t('members.level') }}</th>
<th>状态</th>
<th>{{ $t('members.points') }}</th>
<th>{{ $t('members.expiryDate') }}</th>
<th>{{ $t('members.operation') }}</th>
@@ -115,17 +123,40 @@
:checked="selectedMembers.some(m => m.id === member.id)"
@change="toggleMemberSelection(member)" />
</td>
<td>{{ member.id }}</td>
<td>{{ member.userId || member.id }}</td>
<td>{{ member.username }}</td>
<td>
<span class="role-tag" :class="getRoleClass(member.role)">
{{ getRoleLabel(member.role) }}
</span>
</td>
<td>
<span class="level-tag" :class="member.level === '专业会员' ? 'professional' : 'standard'">
{{ member.level }}
</span>
</td>
<td>
<span class="status-tag" :class="member.isActive ? 'active' : 'banned'">
{{ member.isActive ? '活跃' : '封禁' }}
</span>
</td>
<td>{{ member.points.toLocaleString() }}</td>
<td>{{ member.expiryDate }}</td>
<td>
<el-link type="primary" class="action-link" @click="editMember(member)">{{ $t('common.edit') }}</el-link>
<el-link
v-if="isSuperAdmin && member.role !== 'ROLE_SUPER_ADMIN'"
:type="member.role === 'ROLE_ADMIN' ? 'info' : 'primary'"
class="action-link"
@click="toggleRole(member)">
{{ member.role === 'ROLE_ADMIN' ? '取消管理员' : '设为管理员' }}
</el-link>
<el-link
:type="member.isActive ? 'warning' : 'success'"
class="action-link"
@click="toggleBan(member)">
{{ member.isActive ? '封禁' : '解封' }}
</el-link>
<el-link type="danger" class="action-link" @click="deleteMember(member)">{{ $t('common.delete') }}</el-link>
</td>
</tr>
@@ -177,10 +208,17 @@
<el-form-item label="用户名" prop="username">
<el-input v-model="editForm.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item v-if="isSuperAdmin && editForm.role !== 'ROLE_SUPER_ADMIN'" label="用户角色" prop="role">
<el-select v-model="editForm.role" placeholder="请选择用户角色">
<el-option label="普通用户" value="ROLE_USER" />
<el-option label="管理员" value="ROLE_ADMIN" />
</el-select>
</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 label="专业会员" value="专业会员" />
</el-select>
</el-form-item>
<el-form-item label="剩余资源点" prop="points">
@@ -233,10 +271,15 @@ const router = useRouter()
// 数据状态
const selectedMembers = ref([])
const selectedLevel = ref('all')
const selectedStatus = ref('active') // 默认显示活跃用户
const currentPage = ref(1)
const pageSize = ref(10)
const totalMembers = ref(50)
// 当前用户角色(从 sessionStorage 或 API 获取)
const currentUserRole = ref('')
const isSuperAdmin = computed(() => currentUserRole.value === 'ROLE_SUPER_ADMIN')
// 系统状态数据
const onlineUsers = ref('0/500')
const systemUptime = ref('加载中...')
@@ -248,6 +291,7 @@ const saveLoading = ref(false)
const editForm = ref({
id: '',
username: '',
role: '',
level: '',
points: 0,
expiryDate: ''
@@ -385,6 +429,7 @@ const editMember = (member) => {
editForm.value = {
id: member.id,
username: member.username,
role: member.role,
level: member.level,
points: member.points,
expiryDate: member.expiryDate
@@ -407,22 +452,29 @@ const saveEdit = async () => {
saveLoading.value = true
// 调用API更新会员信息
await memberAPI.updateMember(editForm.value.id, {
// 调用API更新会员信息(超级管理员可以修改角色)
const updateData = {
username: editForm.value.username,
level: editForm.value.level,
points: editForm.value.points,
expiryDate: editForm.value.expiryDate
})
// 更新本地数据
const index = memberList.value.findIndex(m => m.id === editForm.value.id)
if (index > -1) {
memberList.value[index] = { ...editForm.value }
}
ElMessage.success('会员信息更新成功')
editDialogVisible.value = false
// 只有超级管理员才能修改角色,且不能修改超级管理员的角色
if (isSuperAdmin.value && editForm.value.role && editForm.value.role !== 'ROLE_SUPER_ADMIN') {
updateData.role = editForm.value.role
}
const response = await memberAPI.updateMember(editForm.value.id, updateData)
if (response.data && response.data.success) {
ElMessage.success('会员信息更新成功')
editDialogVisible.value = false
// 重新加载列表以确保数据一致
await loadMembers()
} else {
ElMessage.error(response.data?.message || '更新失败')
}
} catch (error) {
console.error('保存失败:', error)
@@ -433,6 +485,7 @@ const saveEdit = async () => {
}
const deleteMember = async (member) => {
console.log('deleteMember 函数被调用, member:', member)
try {
await ElMessageBox.confirm(
`确定要删除用户 ${member.username} 吗?`,
@@ -445,20 +498,23 @@ const deleteMember = async (member) => {
)
// 调用API删除会员
await memberAPI.deleteMember(member.id)
console.log('准备发送DELETE请求, id:', member.id)
const response = await memberAPI.deleteMember(member.id)
console.log('DELETE请求响应:', response)
// 从本地列表中移除
const index = memberList.value.findIndex(m => m.id === member.id)
if (index > -1) {
memberList.value.splice(index, 1)
totalMembers.value--
if (response.data && response.data.success) {
ElMessage.success('删除成功')
// 重新加载列表
await loadMembers()
} else {
console.log('删除失败, response.data:', response.data)
ElMessage.error(response.data?.message || '删除失败')
}
ElMessage.success('删除成功')
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
const errorMsg = error.response?.data?.message || '删除失败'
ElMessage.error(errorMsg)
}
}
}
@@ -483,35 +539,70 @@ const deleteSelected = async () => {
const ids = selectedMembers.value.map(m => m.id)
// 调用API批量删除
await memberAPI.deleteMembers(ids)
const response = await memberAPI.deleteMembers(ids)
// 从本地列表中移除
memberList.value = memberList.value.filter(m => !ids.includes(m.id))
totalMembers.value -= ids.length
selectedMembers.value = []
ElMessage.success('批量删除成功')
if (response.data && response.data.success) {
ElMessage.success(response.data.message || '批量删除成功')
selectedMembers.value = []
// 重新加载列表
await loadMembers()
} else {
ElMessage.error(response.data?.message || '批量删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
const errorMsg = error.response?.data?.message || '批量删除失败'
ElMessage.error(errorMsg)
}
}
}
// 监听筛选条件变化
const handleLevelChange = () => {
const handleFilterChange = () => {
currentPage.value = 1
loadMembers()
}
// 封禁/解封会员
const toggleBan = async (member) => {
const action = member.isActive ? '封禁' : '解封'
try {
await ElMessageBox.confirm(
`确定要${action}用户 ${member.username} 吗?`,
`确认${action}`,
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await memberAPI.toggleBanMember(member.id, !member.isActive)
if (response.data && response.data.success) {
ElMessage.success(response.data.message || `${action}成功`)
await loadMembers()
} else {
ElMessage.error(response.data?.message || `${action}失败`)
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || `${action}失败`
ElMessage.error(errorMsg)
}
}
}
// 加载会员数据
const loadMembers = async () => {
try {
const response = await memberAPI.getMembers({
page: currentPage.value,
pageSize: pageSize.value,
level: selectedLevel.value === 'all' ? '' : selectedLevel.value
level: selectedLevel.value === 'all' ? '' : selectedLevel.value,
status: selectedStatus.value
})
console.log('获取会员列表响应:', response)
@@ -524,7 +615,9 @@ const loadMembers = async () => {
memberList.value = data.list.map(member => ({
id: member.id,
username: member.username,
role: member.role,
level: getMembershipLevel(member.membership),
isActive: member.isActive,
points: member.points || 0,
expiryDate: getMembershipExpiry(member.membership)
}))
@@ -552,28 +645,103 @@ const getMembershipExpiry = (membership) => {
return membership.end_date ? membership.end_date.split(' ')[0] : '2025-12-31'
}
// 辅助函数:获取角色显示名称
const getRoleLabel = (role) => {
const roleMap = {
'ROLE_SUPER_ADMIN': '超级管理员',
'ROLE_ADMIN': '管理员',
'ROLE_USER': '普通用户'
}
return roleMap[role] || '普通用户'
}
// 辅助函数:获取角色样式类
const getRoleClass = (role) => {
const classMap = {
'ROLE_SUPER_ADMIN': 'super-admin',
'ROLE_ADMIN': 'admin',
'ROLE_USER': 'user'
}
return classMap[role] || 'user'
}
// 设置/取消管理员
const toggleRole = async (member) => {
const newRole = member.role === 'ROLE_ADMIN' ? 'ROLE_USER' : 'ROLE_ADMIN'
const action = newRole === 'ROLE_ADMIN' ? '设为管理员' : '取消管理员权限'
try {
await ElMessageBox.confirm(
`确定要将用户 ${member.username} ${action}吗?`,
`确认${action}`,
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const response = await memberAPI.setUserRole(member.id, newRole)
if (response.data && response.data.success) {
ElMessage.success(response.data.message || `${action}成功`)
await loadMembers()
} else {
ElMessage.error(response.data?.message || `${action}失败`)
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || `${action}失败`
ElMessage.error(errorMsg)
}
}
}
// 获取当前用户角色
const fetchCurrentUserRole = () => {
try {
// 登录时保存的是 'user' 而不是 'userInfo'
const userStr = sessionStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
currentUserRole.value = user.role || 'ROLE_USER'
console.log('当前用户角色:', currentUserRole.value)
}
} catch (error) {
console.error('获取用户角色失败:', error)
currentUserRole.value = 'ROLE_USER'
}
}
onMounted(() => {
// 获取当前用户角色
fetchCurrentUserRole()
// 初始化数据
loadMembers()
fetchSystemStats()
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || $t('systemSettings.unknown')
} else {
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = $t('systemSettings.unknown')
}
}
</script>
@@ -799,6 +967,39 @@ const fetchSystemStats = async () => {
gap: 16px;
}
/* 确保下拉框选中值可见 */
.toolbar-left :deep(.el-select) {
min-width: 120px;
}
.toolbar-left :deep(.el-select__wrapper) {
color: #374151 !important;
font-size: 14px;
min-height: 32px;
}
.toolbar-left :deep(.el-select__wrapper *) {
color: #374151 !important;
}
.toolbar-left :deep(.el-select__selection) {
display: flex !important;
align-items: center !important;
}
.toolbar-left :deep(.el-select__selected-item) {
color: #374151 !important;
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
.toolbar-left :deep(.el-select__placeholder) {
color: #9ca3af !important;
opacity: 1 !important;
visibility: visible !important;
}
.table-container {
background: white;
border-radius: 8px;
@@ -865,6 +1066,51 @@ const fetchSystemStats = async () => {
color: white;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background: #10b981;
color: white;
}
.status-tag.banned {
background: #ef4444;
color: white;
}
.role-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.role-tag.super-admin {
background: #dc2626;
color: white;
}
.role-tag.admin {
background: #f59e0b;
color: white;
}
.role-tag.user {
background: #6b7280;
color: white;
}
.role-icon {
margin-right: 4px;
}
.action-link {
margin-right: 12px;
font-size: 14px;

View File

@@ -176,9 +176,19 @@
@error="onImageError"
/>
<!-- 否则使用默认占位符 -->
<div v-else class="work-placeholder">
<div v-else class="work-placeholder" :class="{ 'is-processing': item.status === 'PROCESSING' || item.status === 'PENDING' }">
<el-icon><VideoPlay /></el-icon>
<div class="placeholder-text">{{ item.status === 'PROCESSING' ? t('works.processing') : t('works.noPreview') }}</div>
<div class="placeholder-text">{{ item.status === 'PROCESSING' ? t('works.processing') : (item.status === 'PENDING' ? t('works.queuing') : t('works.noPreview')) }}</div>
</div>
<!-- 生成中/排队中状态的覆盖层始终显示 -->
<div v-if="item.status === 'PROCESSING' || item.status === 'PENDING'" class="processing-overlay">
<div class="processing-content">
<el-icon class="processing-icon"><VideoPlay /></el-icon>
<div class="processing-text">{{ item.status === 'PROCESSING' ? t('works.processing') : t('works.queuing') }}</div>
<div class="progress-bar-container">
<div class="progress-bar-animated"></div>
</div>
</div>
</div>
<div class="checker" v-if="multiSelect">
@@ -396,6 +406,11 @@
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<div class="menu-item" @click="logout">
<el-icon><User /></el-icon>
<span>{{ t('common.logout') }}</span>
@@ -409,7 +424,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 } 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 } from '@element-plus/icons-vue'
import { getMyWorks, getWorkDetail, deleteWork, recordDownload, getWorkFileUrl } from '@/api/userWorks'
import { getCurrentUser } from '@/api/auth'
import { useUserStore } from '@/stores/user'
@@ -578,6 +593,11 @@ const goToSettings = () => {
}
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
// 退出登录
const logout = async () => {
try {
@@ -618,7 +638,9 @@ const loadList = async () => {
})
// 转换数据格式
const transformedData = data.map(transformWorkData)
const transformedData = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
// 调试日志: 查看转换后的数据
console.log('转换后的作品数据:', transformedData)
@@ -924,18 +946,19 @@ const deleteFailedWork = async () => {
)
// 执行删除
console.log('删除作品:', selectedItem.value.id)
const response = await deleteWork(selectedItem.value.id)
const itemId = selectedItem.value.id // 先保存 id因为 handleClose 会将 selectedItem 设为 null
console.log('删除作品:', itemId)
const response = await deleteWork(itemId)
if (response.data.success) {
ElMessage.success(t('works.deleteSuccess'))
// 从列表中移除该作品(在关闭详情页之前)
items.value = items.value.filter(item => item.id !== itemId)
// 关闭详情页
handleClose()
// 从列表中移除该作品
items.value = items.value.filter(item => item.id !== selectedItem.value.id)
// 或者重新加载列表
// reload()
} else {
@@ -951,19 +974,31 @@ const deleteFailedWork = async () => {
// 创建同款
const createSimilar = (item) => {
if (item) {
ElMessage.info(t('works.createSimilarInfo', { title: item.title }))
// 根据作品类型跳转到相应的创建页面
if (item.type === 'video') {
router.push('/text-to-video/create')
} else if (item.type === 'image') {
router.push('/image-to-video/create')
} else {
router.push('/text-to-video/create')
}
} else {
if (!item) {
ElMessage.info(t('works.goToCreate'))
return
}
// 根据作品类别跳转到对应的创建页面,并携带参数
const query = {
taskId: item.taskId,
prompt: item.prompt || '',
aspectRatio: item.aspectRatio || '',
duration: item.duration || ''
}
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 === '分镜视频') {
router.push({ path: '/storyboard-video/create', query })
} else {
// 默认跳转到文生视频
router.push({ path: '/text-to-video/create', query })
}
ElMessage.success(t('works.createSimilarInfo', { title: item.title }))
}
const download = async (item) => {
@@ -974,7 +1009,7 @@ const download = async (item) => {
return
}
ElMessage.success(t('works.downloadStart', { title: item.title }))
ElMessage.info(t('works.downloadStart', { title: item.title }))
// 记录下载次数
try {
@@ -983,7 +1018,41 @@ const download = async (item) => {
console.warn('记录下载次数失败:', err)
}
// 构建下载URL使用代理下载模式download=true避免 CORS 问题
// 尝试直接从 resultUrl 下载(绕过后端代理)
const videoUrl = item.resultUrl
console.log('直接下载视频URL:', videoUrl)
try {
const response = await fetch(videoUrl)
if (!response.ok) {
throw new Error(`下载失败: ${response.status}`)
}
const blob = await response.blob()
console.log('文件大小:', blob.size, 'bytes')
if (blob.size === 0) {
throw new Error('文件内容为空')
}
const blobUrl = window.URL.createObjectURL(blob)
const filename = `${item.title || 'work'}_${Date.now()}${item.type === 'video' ? '.mp4' : '.png'}`
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000)
ElMessage.success(t('works.downloadComplete'))
return
} catch (directError) {
console.warn('直接下载失败,尝试后端代理:', directError)
}
// 备用方案:使用后端代理
const downloadUrl = getWorkFileUrl(item.id, true)
const token = sessionStorage.getItem('token')
@@ -1773,6 +1842,22 @@ onActivated(() => {
.filters-bar :deep(.el-select__placeholder) {
opacity: 1 !important;
}
/* 搜索框深色样式 */
.filters-bar :deep(.el-input__wrapper) {
background-color: rgba(255, 255, 255, 0.1) !important;
border: none !important;
box-shadow: none !important;
}
.filters-bar :deep(.el-input__inner) {
color: #ffffff !important;
}
.filters-bar :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.6) !important;
}
.select-row { padding: 4px 0 8px; }
.works-grid {
margin-top: 12px;
@@ -1820,6 +1905,84 @@ onActivated(() => {
font-size: 12px;
color: #666;
}
/* 动态进度条 */
.progress-bar-container {
width: 60%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar-animated {
width: 30%;
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
border-radius: 2px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
@keyframes progress-move {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(233%);
}
100% {
transform: translateX(-100%);
}
}
@keyframes progress-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
/* 生成中/排队中状态覆盖层 */
.processing-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
border-radius: 8px;
}
.processing-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
}
.processing-icon {
font-size: 32px;
color: #409eff;
}
.processing-text {
font-size: 14px;
color: #fff;
font-weight: 500;
}
.work-placeholder.is-processing {
background: rgba(0, 0, 0, 0.5);
}
.checker { position: absolute; left: 6px; top: 6px; }
.actions { position: absolute; right: 6px; top: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity .2s ease; }
.thumb:hover .actions { opacity: 1; }
@@ -2273,4 +2436,58 @@ onActivated(() => {
}
</style>
<!-- 全局下拉框深色样式弹出层传送到body需要非scoped样式 -->
<style>
.el-select-dropdown {
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
}
.el-select-dropdown__item {
color: #e5e7eb !important;
}
.el-select-dropdown__item:hover,
.el-select-dropdown__item.hover {
background-color: #2a2a2a !important;
}
.el-select-dropdown__item.is-selected {
color: #409eff !important;
background-color: rgba(64, 158, 255, 0.1) !important;
}
.el-popper.is-light {
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
}
.el-popper.is-light .el-popper__arrow::before {
background-color: #1a1a1a !important;
border-color: #333 !important;
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(233%);
}
100% {
transform: translateX(-100%);
}
}
@keyframes progress-gradient {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>

View File

@@ -71,9 +71,8 @@
<img src="/images/backgrounds/avatar-default.svg" :alt="t('dashboard.userAvatar')" class="avatar-image" />
</div>
<div class="user-details">
<h2 class="username">{{ userInfo.nickname || userInfo.username || t('profile.noUsername') }}</h2>
<h2 class="username">{{ userInfo.email || t('profile.noUsername') }}</h2>
<p class="profile-status" v-if="userInfo.bio">{{ userInfo.bio }}</p>
<p class="user-id">{{ t('profile.userId') }} {{ userInfo.id || t('common.loading') }}</p>
</div>
</div>
</section>
@@ -94,6 +93,10 @@
<div v-else-if="video.status === 'PROCESSING' || video.status === 'PENDING'" class="processing-overlay">
<div class="processing-icon"></div>
<div class="processing-text">{{ video.status === 'PENDING' ? '排队中' : '生成中' }}</div>
<!-- 动态进度条 -->
<div class="progress-bar-container">
<div class="progress-bar-animated"></div>
</div>
</div>
<!-- 如果是视频类型且有视频URL使用video元素显示首帧 -->
<video
@@ -227,74 +230,36 @@
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click="goToDashboard">
<div class="menu-item" @click.stop="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click="goToOrders">
<div class="menu-item" @click.stop="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click="goToMembers">
<div class="menu-item" @click.stop="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<div class="menu-item" @click.stop="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click="openChangePasswordDialog">
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer; pointer-events: auto;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<!-- 退出登录 -->
<div class="menu-item" @click="logout">
<div class="menu-item" @click.stop="logout">
<el-icon><User /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
<!-- 修改密码弹窗 -->
<el-dialog
v-model="changePasswordDialogVisible"
:title="t('profile.changePassword')"
width="420px"
>
<el-form :model="changePasswordForm" label-width="90px">
<el-form-item label="当前密码">
<el-input
v-model="changePasswordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" required>
<el-input
v-model="changePasswordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" required>
<el-input
v-model="changePasswordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="changePasswordLoading" @click="submitChangePassword">
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
@@ -331,6 +296,7 @@ const userStatusRef = ref(null)
const userInfo = ref({
username: '',
nickname: '',
email: '',
bio: '',
avatar: '',
id: '',
@@ -338,54 +304,16 @@ const userInfo = ref({
frozenPoints: 0
})
// 修改密码弹窗
const changePasswordDialogVisible = ref(false)
const changePasswordLoading = ref(false)
const changePasswordForm = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const openChangePasswordDialog = () => {
// 跳转到修改密码页面
const goToChangePassword = () => {
showUserMenu.value = false
changePasswordForm.value = {
oldPassword: '',
newPassword: '',
confirmPassword: ''
}
changePasswordDialogVisible.value = true
}
const submitChangePassword = async () => {
if (!changePasswordForm.value.newPassword) {
ElMessage.error('新密码不能为空')
return
}
if (changePasswordForm.value.newPassword.length < 6) {
ElMessage.error('新密码长度不能少于6位')
return
}
if (changePasswordForm.value.newPassword !== changePasswordForm.value.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
try {
changePasswordLoading.value = true
await changePassword({
oldPassword: changePasswordForm.value.oldPassword,
newPassword: changePasswordForm.value.newPassword
})
ElMessage.success('密码修改成功')
changePasswordDialogVisible.value = false
} catch (error) {
console.error('修改密码失败:', error)
const msg = error?.response?.data?.message || '修改密码失败'
ElMessage.error(msg)
} finally {
changePasswordLoading.value = false
}
console.log('准备跳转到 /change-password')
router.push('/change-password').then(() => {
console.log('跳转成功')
}).catch(err => {
console.error('跳转失败:', err)
alert('跳转失败: ' + err.message)
})
}
const userLoading = ref(false)
@@ -610,7 +538,8 @@ const loadUserInfo = async () => {
console.log('用户数据:', user)
userInfo.value = {
username: user.username || '',
nickname: user.nickname || user.username || '',
nickname: user.nickname || '',
email: user.email || '',
bio: user.bio || '',
avatar: user.avatar || '',
id: user.id ? String(user.id) : '',
@@ -659,15 +588,17 @@ const loadVideos = async () => {
try {
const response = await getMyWorks({
page: 0,
size: 6 // 只加载前6个作品
size: 10 // 只加载最近10个作品
})
console.log('获取作品列表响应:', response)
if (response && response.data && response.data.success) {
const data = response.data.data || []
console.log('作品数据:', data)
// 转换数据格式
videos.value = data.map(transformWorkData)
// 转换数据格式,并过滤掉失败和删除的作品
videos.value = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
console.log('转换后的作品列表:', videos.value)
} else {
console.error('获取作品列表失败:', response?.data?.message || '未知错误')
@@ -689,7 +620,9 @@ const editProfile = () => {
// 点击外部关闭菜单
const handleClickOutside = (event) => {
const userStatus = event.target.closest('.user-status')
if (!userStatus) {
const userMenu = event.target.closest('.user-menu-teleport')
// 点击头像或菜单本身不关闭
if (!userStatus && !userMenu) {
showUserMenu.value = false
}
}
@@ -927,8 +860,8 @@ onUnmounted(() => {
overflow: hidden;
}
/* Teleport菜单样式 */
.user-menu-teleport {
/* Teleport菜单样式 - 使用 :global() 因为 Teleport 渲染到 body */
:global(.user-menu-teleport) {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
@@ -937,7 +870,7 @@ onUnmounted(() => {
overflow: hidden;
}
.menu-item {
:global(.user-menu-teleport .menu-item) {
display: flex;
align-items: center;
padding: 12px 16px;
@@ -947,16 +880,16 @@ onUnmounted(() => {
font-size: 14px;
}
.menu-item:hover {
:global(.user-menu-teleport .menu-item:hover) {
background: #2a2a2a;
}
.menu-item .el-icon {
:global(.user-menu-teleport .menu-item .el-icon) {
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
:global(.user-menu-teleport .menu-item:not(:last-child)) {
border-bottom: 1px solid #333;
}
@@ -1041,12 +974,6 @@ onUnmounted(() => {
.profile-status {
font-size: 14px;
color: #999;
margin: 0 0 4px 0;
}
.user-id {
font-size: 12px;
color: #666;
margin: 0;
}
@@ -1091,8 +1018,8 @@ onUnmounted(() => {
.video-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.video-item {
@@ -1113,7 +1040,7 @@ onUnmounted(() => {
}
.thumbnail-image {
height: 200px;
aspect-ratio: 1 / 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
position: relative;
display: flex;
@@ -1269,7 +1196,13 @@ onUnmounted(() => {
/* 响应式设计 */
@media (max-width: 1200px) {
.video-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 900px) {
.video-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@@ -1552,6 +1485,49 @@ onUnmounted(() => {
background-color: rgba(0, 0, 0, 0.8) !important;
}
/* 处理中状态覆盖层 */
.processing-overlay,
.failed-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
z-index: 10;
border-radius: 8px;
}
.processing-icon,
.failed-icon {
font-size: 32px;
margin-bottom: 8px;
}
.processing-text,
.failed-text {
font-size: 14px;
color: #fff;
font-weight: 500;
}
.status-text {
font-size: 12px;
color: #999;
}
.status-text.processing {
color: #409eff;
}
.status-text.failed {
color: #f56c6c;
}
/* 不全局影响其它对话框,仅本弹窗 */
/* 仅作用于本弹窗的遮罩类,避免全局影响 */
@@ -1580,4 +1556,37 @@ onUnmounted(() => {
.el-dialog.detail-dialog .el-dialog__body {
background: #0a0a0a !important;
}
/* 动态进度条 */
.progress-bar-container {
width: 60%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 12px;
}
.progress-bar-animated {
width: 30%;
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
border-radius: 2px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(233%); }
100% { transform: translateX(-100%); }
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
</style>

View File

@@ -0,0 +1,358 @@
<template>
<div class="login-page">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
</div>
<!-- 设置密码卡片 -->
<div class="login-card">
<!-- 标题 -->
<div class="page-title">设置密码</div>
<!-- 表单 -->
<div class="password-form">
<!-- 新密码 -->
<div class="input-group">
<el-input
v-model="form.newPassword"
placeholder="输入密码至少8位包含字母和数字"
class="password-input"
show-password
@keyup.enter="handleSubmit"
/>
<div class="input-error" v-if="errors.newPassword">{{ errors.newPassword }}</div>
</div>
<!-- 确认密码 -->
<div class="input-group">
<el-input
v-model="form.confirmPassword"
placeholder="确认密码"
class="password-input"
show-password
@keyup.enter="handleSubmit"
/>
<div class="input-error" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div>
</div>
<!-- 确定按钮 -->
<el-button
type="primary"
class="submit-button"
:loading="loading"
@click="handleSubmit"
>
{{ loading ? '提交中...' : '确定' }}
</el-button>
<!-- 跳过按钮 -->
<div class="skip-button-wrapper">
<el-button
class="skip-button"
@click="handleSkip"
>
跳过稍后设置
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const form = reactive({
newPassword: '',
confirmPassword: ''
})
const errors = reactive({
newPassword: '',
confirmPassword: ''
})
// 验证表单
const validateForm = () => {
let valid = true
errors.newPassword = ''
errors.confirmPassword = ''
// 密码必填且必须包含英文字母和数字不少于8位
if (!form.newPassword) {
errors.newPassword = '请输入密码'
valid = false
} else if (form.newPassword.length < 8) {
errors.newPassword = '密码长度至少8位'
valid = false
} else if (!/[a-zA-Z]/.test(form.newPassword)) {
errors.newPassword = '密码必须包含英文字母'
valid = false
} else if (!/[0-9]/.test(form.newPassword)) {
errors.newPassword = '密码必须包含数字'
valid = false
}
// 确认密码必填且必须与密码一致
if (!form.confirmPassword) {
errors.confirmPassword = '请确认密码'
valid = false
} else if (form.newPassword !== form.confirmPassword) {
errors.confirmPassword = '两次输入的密码不一致'
valid = false
}
return valid
}
// 提交设置
const handleSubmit = async () => {
if (!validateForm()) return
loading.value = true
try {
const response = await request({
url: '/auth/change-password',
method: 'post',
data: {
oldPassword: null,
newPassword: form.newPassword
}
})
console.log('设置密码响应:', response)
const result = response.data
if (result && result.success) {
ElMessage.success('密码设置成功')
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
// 跳转到首页或之前的页面
const redirect = route.query.redirect || '/'
router.replace(redirect)
} else {
ElMessage.error(result?.message || '设置失败')
}
} catch (error) {
console.error('设置密码失败:', error)
const errorMsg = error.response?.data?.message || error.message || '设置失败,请重试'
ElMessage.error(errorMsg)
} finally {
loading.value = false
}
}
// 跳过
const handleSkip = () => {
// 清除首次设置标记
sessionStorage.removeItem('needSetPassword')
// 跳转到首页
const redirect = route.query.redirect || '/'
router.replace(redirect)
}
onMounted(() => {
// 检查用户是否已登录
if (!userStore.isAuthenticated) {
router.replace('/login')
}
})
</script>
<style scoped>
.login-page {
min-height: 100vh;
width: 100vw;
height: 100vh;
background: #0a0e1a url('/images/backgrounds/login_bg.png') center/cover no-repeat;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 0;
z-index: 1;
}
/* 左上角Logo */
.logo {
position: absolute;
top: 30px;
left: 30px;
z-index: 10;
}
.logo img {
height: 40px;
width: auto;
}
/* 卡片 */
.login-card {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 550px;
max-width: 90vw;
background: rgba(121, 121, 121, 0.1);
backdrop-filter: blur(50px);
-webkit-backdrop-filter: blur(50px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 60px 80px;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
/* 页面标题 */
.page-title {
text-align: center;
font-size: 32px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 50px;
}
/* 表单 */
.password-form {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 输入组 */
.input-group {
margin-bottom: 5px;
}
.password-input {
width: 100%;
}
.password-input :deep(.el-input__wrapper) {
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
box-shadow: none;
height: 60px;
transition: all 0.3s ease;
}
.password-input :deep(.el-input__wrapper:hover) {
background: rgba(217, 217, 217, 0.25);
}
.password-input :deep(.el-input__wrapper.is-focus) {
background: rgba(217, 217, 217, 0.3);
box-shadow: none;
}
.password-input :deep(.el-input__inner) {
color: rgba(255, 255, 255, 0.9);
background: transparent;
font-size: 16px;
}
.password-input :deep(.el-input__inner::placeholder) {
color: rgba(255, 255, 255, 0.5);
}
.input-error {
color: #ff7875;
font-size: 12px;
margin-top: 6px;
text-align: left;
}
/* 确定按钮 */
.submit-button {
width: 100%;
height: 60px;
background: #0DC0FF;
border: none;
border-radius: 10px;
color: white;
font-size: 18px;
font-weight: 500;
margin-top: 20px;
transition: all 0.3s ease;
}
.submit-button:hover {
background: #4DD4FF;
transform: translateY(-1px);
}
.submit-button:active {
transform: translateY(0);
}
/* 跳过按钮 */
.skip-button {
width: 100%;
height: 60px;
background: rgba(217, 217, 217, 0.2);
border: none;
border-radius: 10px;
color: rgba(255, 255, 255, 0.9);
font-size: 18px;
font-weight: 500;
transition: all 0.3s ease;
}
.skip-button:hover {
background: rgba(217, 217, 217, 0.3);
}
.skip-button-wrapper {
width: 100%;
}
.skip-button-wrapper .skip-button {
width: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
width: 90%;
padding: 40px 30px;
}
.page-title {
font-size: 28px;
}
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.page-title {
font-size: 24px;
}
}
</style>

View File

@@ -15,12 +15,47 @@
<span class="points-number">{{ userStore.availablePoints }}</span>
</div>
<LanguageSwitcher />
<div class="user-avatar">
<div class="user-avatar" @click="toggleUserMenu" ref="userAvatarRef">
<img src="/images/backgrounds/avatar-default.svg" :alt="t('video.storyboard.userAvatar')" />
</div>
</div>
</header>
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click.stop="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click.stop="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToSystemSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<!-- 退出登录 -->
<div class="menu-item" @click.stop="logout">
<el-icon><SwitchButton /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
</div>
</Teleport>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 左侧设置面板 -->
@@ -175,7 +210,7 @@
<button
class="generate-btn"
@click="handleGenerateClick"
:disabled="!isAuthenticated || inProgress || isCreatingTask || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
:disabled="!isAuthenticated || isCreatingTask || (currentStep === 'generate' && !hasUploadedImages && !inputText.trim()) || (currentStep === 'video' && !generatedImageUrl && !hasUploadedImages)"
>
{{ isAuthenticated ? getButtonText() : t('video.storyboard.pleaseLogin') }}
</button>
@@ -205,10 +240,16 @@
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ currentStep === 'generate' ? t('video.storyboard.generatingStoryboardText') : t('video.storyboard.generatingVideoText') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: (currentStep === 'video' ? videoProgress : 50) + '%' }"></div>
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : (currentStep === 'generate' ? t('video.storyboard.generatingStoryboardText') : t('video.storyboard.generatingVideoText')) }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="progress-bar-large">
<div class="progress-fill-large animated" :style="{ width: (currentStep === 'video' ? videoProgress : 50) + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ currentStep === 'video' ? videoProgress : 50 }}%</div>
</div>
</div>
@@ -374,9 +415,19 @@
</div>
</div>
<!-- 做同款按钮 -->
<!-- 操作按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('profile.createSimilar') }}</button>
<button
v-if="task.status === 'COMPLETED' && task.videoResultUrl"
class="download-btn"
@click="downloadHistoryVideo(task)"
:title="t('video.storyboard.downloadVideo')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
</div>
</div>
</div>
@@ -389,8 +440,9 @@
<script setup>
import { ref, computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
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 { useUserStore } from '@/stores/user'
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks } from '@/api/storyboardVideo'
import { imageToVideoApi } from '@/api/imageToVideo'
@@ -402,6 +454,7 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// 计算是否已登录
@@ -426,6 +479,23 @@ const pollIntervalId = ref(null) // 保存轮询定时器ID
const optimizingPrompt = ref(false) // 优化提示词状态
const optimizingVideoPrompt = ref(false) // 优化视频提示词状态
// 用户菜单相关
const showUserMenu = ref(false)
const userAvatarRef = ref(null)
// 计算菜单位置
const menuStyle = computed(() => {
if (!userAvatarRef.value || !showUserMenu.value) return {}
const rect = userAvatarRef.value.getBoundingClientRect()
return {
position: 'fixed',
top: `${rect.bottom + 8}px`,
right: `${window.innerWidth - rect.right}px`,
zIndex: 99999
}
})
// 为了兼容性,保留 uploadedImage 作为计算属性(返回第一张图片)
const uploadedImage = computed(() => {
if (uploadedImages.value.length > 0 && uploadedImages.value[0]?.url) {
@@ -472,6 +542,67 @@ const goToImageToVideo = () => {
router.push('/image-to-video/create')
}
// 用户菜单相关方法
const toggleUserMenu = () => {
// 未登录时跳转到登录页面
if (!isAuthenticated.value) {
router.push('/login')
return
}
showUserMenu.value = !showUserMenu.value
}
const goToProfile = () => {
showUserMenu.value = false
router.push('/profile')
}
const goToMyWorks = () => {
showUserMenu.value = false
router.push('/works')
}
const goToSubscription = () => {
showUserMenu.value = false
router.push('/subscription')
}
const goToSettings = () => {
showUserMenu.value = false
router.push('/settings')
}
const goToDashboard = () => {
showUserMenu.value = false
router.push('/admin/dashboard')
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
router.push('/member-management')
}
const goToSystemSettings = () => {
showUserMenu.value = false
router.push('/system-settings')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
const logout = () => {
showUserMenu.value = false
userStore.logout()
router.push('/login')
}
// 切换到生成分镜图步骤
const switchToGenerateStep = () => {
currentStep.value = 'generate'
@@ -851,7 +982,25 @@ const startGenerate = async () => {
} else {
// 任务创建失败,重置所有状态
console.error('创建任务失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.createTaskFailed'))
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
@@ -865,7 +1014,25 @@ const startGenerate = async () => {
response: error.response,
stack: error.stack
})
ElMessage.error(t('video.storyboard.generateFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.generateFailed') + ': ' + errorMsg)
}
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
@@ -1137,12 +1304,48 @@ const startVideoGenerate = async () => {
pollTaskStatus(taskId.value)
} else {
console.error('启动视频生成失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.videoStartFailed'))
const errorMsg = response.data?.message || t('video.storyboard.videoStartFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
}
} catch (error) {
console.error('启动视频生成失败,完整错误:', error)
ElMessage.error(t('video.storyboard.videoStartFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.videoStartFailed') + ': ' + errorMsg)
}
inProgress.value = false
}
return
@@ -1253,7 +1456,25 @@ const startVideoGenerate = async () => {
pollTaskStatus(newTaskId)
} else {
console.error('创建任务失败,响应:', response.data)
ElMessage.error(response.data?.message || t('video.storyboard.createTaskFailed'))
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
}
} catch (error) {
@@ -1263,7 +1484,25 @@ const startVideoGenerate = async () => {
response: error.response,
stack: error.stack
})
ElMessage.error(t('video.storyboard.generateVideoFailed') + ': ' + (error.response?.data?.message || error.message))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.storyboard.generateVideoFailed') + ': ' + errorMsg)
}
inProgress.value = false
}
}
@@ -1379,14 +1618,15 @@ const loadHistory = async () => {
}
try {
const response = await getUserStoryboardTasks(0, 10)
// 请求更多条数以确保能筛选出足够的 COMPLETED 任务
const response = await getUserStoryboardTasks(0, 50)
console.log('分镜视频历史记录API响应:', response)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 10)
).slice(0, 5)
console.log('获取到的任务列表:', tasks)
@@ -1395,7 +1635,10 @@ const loadHistory = async () => {
...task,
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:image')
? processHistoryUrl(task.resultUrl)
: task.resultUrl
: task.resultUrl,
imageUrl: task.imageUrl && !task.imageUrl.startsWith('data:image')
? processHistoryUrl(task.imageUrl)
: task.imageUrl
}))
console.log('历史记录加载成功:', historyTasks.value.length, '条')
@@ -1421,20 +1664,25 @@ const createSimilarFromHistory = (task) => {
if (task.hdMode !== undefined) {
hdMode.value = task.hdMode
}
// 如果有分镜图URL加载图片支持所有URL类型
if (task.resultUrl) {
generatedImageUrl.value = task.resultUrl
// 优先使用用户上传的参考图片imageUrl如果没有则使用生成的分镜图resultUrl
const imageToUse = task.imageUrl || task.resultUrl
if (imageToUse) {
generatedImageUrl.value = imageToUse
uploadedImages.value = [{
url: task.resultUrl,
url: imageToUse,
file: null,
name: '分镜图'
name: task.imageUrl ? '参考图片' : '分镜图'
}]
// 设置 taskId以便可以直接调用生成视频API
if (task.taskId) {
// 如果是已完成的分镜图任务且有resultUrl设置taskId以便直接生成视频
if (task.resultUrl && task.taskId) {
taskId.value = task.taskId
// 切换到视频步骤
currentStep.value = 'video'
} else {
// 只有参考图,停留在生成步骤
currentStep.value = 'generate'
}
// 切换到视频步骤
currentStep.value = 'video'
}
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -1537,17 +1785,63 @@ const getStatusText = (status) => {
}
// 下载视频
const downloadVideo = () => {
if (videoResultUrl.value) {
const downloadVideo = async () => {
if (!videoResultUrl.value) {
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(videoResultUrl.value)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = videoResultUrl.value
link.download = `storyboard_video_${taskId.value}.mp4`
link.href = url
link.download = `storyboard_video_${taskId.value || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.storyboard.downloadStarted'))
} else {
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(videoResultUrl.value, '_blank')
}
}
// 下载历史记录中的视频
const downloadHistoryVideo = async (task) => {
if (!task || !task.videoResultUrl) {
ElMessage.error(t('video.storyboard.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
const response = await fetch(task.videoResultUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `storyboard_video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.storyboard.downloadStarted'))
} catch (error) {
console.error('下载失败:', error)
window.open(task.videoResultUrl, '_blank')
}
}
@@ -1848,6 +2142,20 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 处理"做同款"传递的路由参数
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
}
ElMessage.success(t('video.storyboardVideo.historyParamsFilled') || '已填充历史参数')
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
loadHistory()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(async () => {
@@ -1886,6 +2194,40 @@ onBeforeUnmount(() => {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 用户菜单样式 - 深色主题 */
.user-menu-teleport {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
z-index: 99999;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
/* 顶部导航栏 */
.top-header {
display: flex;
@@ -3105,6 +3447,37 @@ onBeforeUnmount(() => {
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
/* 生成中进度条动态动画 - 使用蓝绿渐变风格 */
.progress-fill-large.animated {
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 不确定进度条(排队中) */
.progress-bar-large.indeterminate {
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 4px;
animation: progress-move 1.5s ease-in-out infinite;
}
/* 进度百分比文字 */
.progress-percentage {
font-size: 14px;
color: #60a5fa;
font-weight: 600;
margin-top: 12px;
text-align: center;
}
/* 完成状态 */
@@ -3485,6 +3858,8 @@ onBeforeUnmount(() => {
.history-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
margin-top: 0;
}
@@ -3504,6 +3879,41 @@ onBeforeUnmount(() => {
background: #3a3a3a;
border-color: #4a4a4a;
}
.download-btn {
width: 36px;
height: 36px;
background: #fff;
color: #333;
border: none;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.download-btn:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(233%); }
100% { transform: translateX(-100%); }
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
</style>
@@ -3514,5 +3924,3 @@ onBeforeUnmount(() => {

View File

@@ -77,8 +77,7 @@
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('subscription.userAvatar')" class="avatar-image" />
</div>
<div class="user-meta">
<div class="username">{{ userInfo.username || t('subscription.loading') }}</div>
<div class="user-id">{{ $t('profile.userId') }} {{ userInfo.userId || '...' }}</div>
<div class="username">{{ userInfo.email || t('subscription.loading') }}</div>
</div>
</div>
<div class="user-right">
@@ -89,7 +88,7 @@
<span>{{ userStore.availablePoints }}</span>
</div>
<button class="mini-btn" @click="goToOrderDetails">{{ $t('subscription.pointsDetails') }}</button>
<button class="mini-btn" @click="goToWorks">{{ $t('subscription.myOrders') }}</button>
<button class="mini-btn" @click="goToWorks">{{ $t('profile.myWorks') }}</button>
</div>
</div>
<!-- 下层三项总结 -->
@@ -216,6 +215,11 @@
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<div class="menu-item" @click="logout">
<el-icon><User /></el-icon>
<span>{{ t('common.logout') }}</span>
@@ -298,7 +302,7 @@ 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 } from '@element-plus/icons-vue'
import { User, Document, VideoPlay, Picture, Film, Compass, Star, Check, Plus, Setting, Lock } from '@element-plus/icons-vue'
import { createPayment, createAlipayPayment, getUserSubscriptionInfo } from '@/api/payments'
import { getPointsHistory } from '@/api/points'
import { getMembershipLevels } from '@/api/members'
@@ -504,34 +508,15 @@ 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
}
// 使用固定价格配置
membershipPrices.value = {
free: 0,
standard: 59,
premium: 259
}
console.log('会员等级价格配置:', membershipPrices.value)
}
// 组件挂载时加载数据
@@ -710,6 +695,11 @@ const goToSettings = () => {
}
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
// 退出登录
const logout = async () => {
try {

View File

@@ -33,7 +33,7 @@
</nav>
<div class="sidebar-footer">
<div class="online-users">
{{ $t('nav.onlineUsers') }}: <span class="highlight">{{ onlineUsers }}</span>
{{ $t('nav.todayVisitors') }}: <span class="highlight">{{ onlineUsers }}</span>
</div>
<div class="system-uptime">
{{ $t('nav.systemUptime') }}: <span class="highlight">{{ systemUptime }}</span>
@@ -108,7 +108,7 @@
</div>
<div class="card-body">
<p class="price">${{ level.price || 0 }}{{ $t('systemSettings.perMonth') }}</p>
<p class="description">{{ level.description || $t('systemSettings.includesPoints', { points: level.resourcePoints || 0 }) }}</p>
<p class="description">{{ $t('systemSettings.includesPointsPerMonth', { points: level.resourcePoints || level.pointsBonus || 0 }) }}</p>
</div>
<div class="card-footer">
<el-button type="primary" @click="editLevel(level)">{{ $t('common.edit') }}</el-button>
@@ -360,26 +360,6 @@
>
<label for="monthly" class="radio-label">{{ $t('systemSettings.monthly') }}</label>
</div>
<div class="radio-option">
<input
type="radio"
id="quarterly"
v-model="editForm.validityPeriod"
value="quarterly"
class="radio-input"
>
<label for="quarterly" class="radio-label">{{ $t('systemSettings.quarterly') }}</label>
</div>
<div class="radio-option">
<input
type="radio"
id="yearly"
v-model="editForm.validityPeriod"
value="yearly"
class="radio-input"
>
<label for="yearly" class="radio-label">{{ $t('systemSettings.yearly') }}</label>
</div>
</div>
</div>
</el-form>
@@ -447,9 +427,10 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
Grid,
User,
@@ -467,13 +448,14 @@ import { getMembershipLevels, updateMembershipLevel } from '@/api/members'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const { t } = useI18n()
// 选项卡状态
const activeTab = ref('membership')
// 系统状态数据
const onlineUsers = ref('0/500')
const systemUptime = ref('加载中...')
const onlineUsers = ref('0')
const systemUptime = ref(t('nav.loading'))
// 会员收费标准相关
const membershipLevels = ref([])
@@ -486,18 +468,18 @@ const editForm = reactive({
level: '',
price: '',
resourcePoints: 0,
validityPeriod: 'quarterly'
validityPeriod: 'monthly'
})
const editRules = reactive({
level: [{ required: true, message: '请选择会员等级', trigger: 'change' }],
const editRules = computed(() => ({
level: [{ required: true, message: t('systemSettings.selectLevelRequired'), trigger: 'change' }],
price: [
{ required: true, message: '请输入价格', trigger: 'blur' },
{ pattern: /^\d+(\.\d+)?$/, message: '请输入有效的数字', trigger: 'blur' }
{ required: true, message: t('systemSettings.enterPriceRequired'), trigger: 'blur' },
{ pattern: /^\d+(\.\d+)?$/, message: t('systemSettings.enterValidNumber'), trigger: 'blur' }
],
resourcePoints: [{ required: true, message: '请输入资源点数量', trigger: 'blur' }],
validityPeriod: [{ required: true, message: '请选择有效期', trigger: 'change' }]
})
resourcePoints: [{ required: true, message: t('systemSettings.enterResourcePointsRequired'), trigger: 'blur' }],
validityPeriod: [{ required: true, message: t('systemSettings.selectValidityRequired'), trigger: 'change' }]
}))
// 任务清理相关
const cleanupStats = ref(null)
@@ -512,12 +494,12 @@ const userCleanupForm = reactive({
username: ''
})
const userCleanupRules = reactive({
const userCleanupRules = computed(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度在2到50个字符', trigger: 'blur' }
{ required: true, message: t('systemSettings.enterUsernameRequired'), trigger: 'blur' },
{ min: 2, max: 50, message: t('systemSettings.usernameLengthLimit'), trigger: 'blur' }
]
})
}))
const cleanupConfig = reactive({
retentionDays: 30,
@@ -595,7 +577,7 @@ const saveEdit = async () => {
price: parseFloat(editForm.price),
resourcePoints: parseInt(editForm.resourcePoints),
pointsBonus: parseInt(editForm.resourcePoints),
description: `包含${editForm.resourcePoints}资源点/月`
description: t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
}
await updateMembershipLevel(editForm.id, updateData)
@@ -606,17 +588,17 @@ const saveEdit = async () => {
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}资源点/月`
membershipLevels.value[index].description = t('systemSettings.includesPointsPerMonth', { points: editForm.resourcePoints })
}
ElMessage.success('会员等级更新成功')
ElMessage.success(t('systemSettings.membershipUpdateSuccess'))
editDialogVisible.value = false
// 重新加载会员等级配置
await loadMembershipLevels()
} catch (error) {
console.error('更新会员等级失败:', error)
ElMessage.error('更新会员等级失败: ' + (error.response?.data?.message || error.message))
console.error('Update membership level failed:', error)
ElMessage.error(t('systemSettings.membershipUpdateFailed') + ': ' + (error.response?.data?.message || error.message))
}
}
@@ -649,31 +631,31 @@ const loadMembershipLevels = async () => {
price: level.price || 0,
resourcePoints: level.pointsBonus || 0,
pointsBonus: level.pointsBonus || 0,
description: level.description || `包含${level.pointsBonus || 0}资源点/月`
description: level.description || t('systemSettings.includesPointsPerMonth', { points: level.pointsBonus || 0 })
}))
console.log('会员等级配置加载成功:', membershipLevels.value)
console.log('Membership levels loaded:', membershipLevels.value)
} else {
// 如果没有数据,使用默认值
console.warn('数据库中没有会员等级数据,使用默认值')
console.warn('No membership data in database, using defaults')
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资源点/月' }
{ 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 }) }
]
}
} catch (error) {
console.error('加载会员等级配置失败:', error)
console.error('错误详情:', error.response?.data || error.message)
console.error('Load membership config failed:', error)
console.error('Error details:', error.response?.data || error.message)
// 显示更详细的错误信息
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || '未知错误'
ElMessage.warning(`加载会员等级配置失败: ${errorMessage},使用默认配置`)
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: '免费版会员', price: 0, resourcePoints: 200, description: '包含200资源点/月' },
{ id: 2, name: '标准版会员', price: 59, resourcePoints: 500, description: '包含500资源点/月' },
{ id: 3, name: '专业版会员', price: 250, resourcePoints: 2000, description: '包含2000资源点/月' }
{ 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 }) }
]
} finally {
loadingLevels.value = false
@@ -691,10 +673,10 @@ const refreshStats = async () => {
try {
const response = await cleanupApi.getCleanupStats()
cleanupStats.value = response.data
ElMessage.success('统计信息刷新成功')
ElMessage.success(t('systemSettings.statsRefreshSuccess'))
} catch (error) {
console.error('获取统计信息失败:', error)
ElMessage.error('获取统计信息失败')
console.error('Get statistics failed:', error)
ElMessage.error(t('systemSettings.statsRefreshFailed'))
} finally {
loadingStats.value = false
}
@@ -705,13 +687,13 @@ const performFullCleanup = async () => {
loadingCleanup.value = true
try {
const response = await cleanupApi.performFullCleanup()
ElMessage.success('完整清理执行成功')
console.log('清理结果:', response.data)
ElMessage.success(t('systemSettings.fullCleanupSuccess'))
console.log('Cleanup result:', response.data)
// 刷新统计信息
await refreshStats()
} catch (error) {
console.error('执行完整清理失败:', error)
ElMessage.error('执行完整清理失败')
console.error('Execute full cleanup failed:', error)
ElMessage.error(t('systemSettings.fullCleanupFailed'))
} finally {
loadingCleanup.value = false
}
@@ -731,15 +713,15 @@ const performUserCleanup = async () => {
loadingUserCleanup.value = true
try {
const response = await cleanupApi.cleanupUserTasks(userCleanupForm.username)
ElMessage.success('用户任务清理成功')
console.log('清理结果:', response.data)
ElMessage.success(t('systemSettings.userCleanupSuccess'))
console.log('Cleanup result:', response.data)
// 刷新统计信息
await refreshStats()
// 关闭对话框
handleCloseUserCleanupDialog()
} catch (error) {
console.error('清理用户任务失败:', error)
ElMessage.error('清理用户任务失败')
console.error('Cleanup user tasks failed:', error)
ElMessage.error(t('systemSettings.userCleanupFailed'))
} finally {
loadingUserCleanup.value = false
}
@@ -761,17 +743,17 @@ const performUserCleanup_old = async () => {
if (response.ok) {
const result = await response.json()
ElMessage.success(`用户 ${userCleanupForm.username} 的任务清理完成`)
console.log('用户清理结果:', result)
ElMessage.success(t('systemSettings.userCleanupSuccess'))
console.log('User cleanup result:', result)
// 关闭对话框并刷新统计信息
handleCloseUserCleanupDialog()
await refreshStats()
} else {
ElMessage.error('清理用户任务失败')
ElMessage.error(t('systemSettings.userCleanupFailed'))
}
} catch (error) {
console.error('清理用户任务失败:', error)
ElMessage.error('清理用户任务失败')
console.error('Cleanup user tasks failed:', error)
ElMessage.error(t('systemSettings.userCleanupFailed'))
} finally {
loadingUserCleanup.value = false
}
@@ -783,10 +765,10 @@ const saveCleanupConfig = async () => {
// 这里可以添加保存配置的API调用
// 目前只是模拟保存
await new Promise(resolve => setTimeout(resolve, 1000))
ElMessage.success('清理配置保存成功')
ElMessage.success(t('systemSettings.configSaveSuccess'))
} catch (error) {
console.error('保存清理配置失败:', error)
ElMessage.error('保存清理配置失败')
console.error('Save cleanup config failed:', error)
ElMessage.error(t('systemSettings.configSaveFailed'))
} finally {
loadingConfig.value = false
}
@@ -795,7 +777,11 @@ const saveCleanupConfig = async () => {
// 加载AI模型设置
const loadAiModelSettings = async () => {
try {
const response = await fetch('/api/admin/settings')
const response = await fetch('/api/admin/settings', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
if (response.ok) {
const data = await response.json()
if (data.promptOptimizationModel) {
@@ -812,7 +798,7 @@ const loadAiModelSettings = async () => {
}
}
} catch (error) {
console.error('加载AI模型设置失败:', error)
console.error('Load AI model settings failed:', error)
}
}
@@ -823,7 +809,8 @@ const saveAiModelSettings = async () => {
const response = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
},
body: JSON.stringify({
promptOptimizationModel: promptOptimizationModel.value,
@@ -833,13 +820,13 @@ const saveAiModelSettings = async () => {
})
})
if (response.ok) {
ElMessage.success('AI模型设置保存成功')
ElMessage.success(t('systemSettings.aiModelSaveSuccess'))
} else {
throw new Error('保存失败')
throw new Error('Save failed')
}
} catch (error) {
console.error('保存AI模型设置失败:', error)
ElMessage.error('保存AI模型设置失败')
console.error('Save AI model settings failed:', error)
ElMessage.error(t('systemSettings.aiModelSaveFailed'))
} finally {
savingAiModel.value = false
}
@@ -853,22 +840,26 @@ onMounted(() => {
loadAiModelSettings()
})
// 获取系统统计数据
// 获取系统统计数据(当天访问人数和系统运行时间)
const fetchSystemStats = async () => {
try {
// 临时使用计算值后续可以从API获取
// 计算在线用户数(这里简化处理)
const randomOnline = Math.floor(Math.random() * 50) + 10
onlineUsers.value = `${randomOnline}/500`
// 计算系统运行时间(基于当前时间简单模拟)
const hours = new Date().getHours()
const minutes = new Date().getMinutes()
systemUptime.value = `${hours}小时${minutes}`
const response = await fetch('/api/admin/online-stats', {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
})
const data = await response.json()
if (data.success) {
onlineUsers.value = data.todayVisitors || 0
systemUptime.value = data.uptime || t('systemSettings.unknown')
} else {
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
} catch (error) {
console.error('获取系统统计失败:', error)
onlineUsers.value = '0/500'
systemUptime.value = '未知'
console.error('Get online stats failed:', error)
onlineUsers.value = '0'
systemUptime.value = t('systemSettings.unknown')
}
}
</script>
@@ -954,15 +945,15 @@ const fetchSystemStats = async () => {
.online-users,
.system-uptime {
font-size: 14px;
color: #64748b;
margin-bottom: 5px;
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
line-height: 1.5;
}
.highlight {
color: #333;
font-weight: bold;
color: #3b82f6;
font-weight: 600;
}
.main-content {

View File

@@ -79,9 +79,9 @@
<button
class="generate-btn"
@click="startGenerate"
:disabled="!isAuthenticated || inProgress || isCreatingTask"
:disabled="!isAuthenticated || isCreatingTask"
>
{{ !isAuthenticated ? t('video.textToVideo.pleaseLogin') : (inProgress || isCreatingTask) ? t('video.textToVideo.taskInProgress') : t('video.textToVideo.startGenerate') }}
{{ !isAuthenticated ? t('video.textToVideo.pleaseLogin') : isCreatingTask ? t('video.textToVideo.creatingTask') : t('video.textToVideo.startGenerate') }}
</button>
<div v-if="!isAuthenticated" class="login-tip">
<p>{{ t('video.textToVideo.loginRequired') }}</p>
@@ -110,10 +110,16 @@
<!-- 生成中的状态 -->
<div v-if="inProgress" class="generating-container">
<div class="generating-placeholder">
<div class="generating-text">{{ t('video.generating') }}</div>
<div class="progress-bar-large">
<div class="progress-fill-large" :style="{ width: taskProgress + '%' }"></div>
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.textToVideo.queuing') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="progress-bar-large">
<div class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
</div>
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
</div>
</div>
@@ -214,7 +220,15 @@
<!-- 预览区域 -->
<div class="history-preview">
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
<div class="queue-text">{{ t('video.textToVideo.queuing') }}</div>
<div class="queue-text">{{ task.status === 'PENDING' ? t('video.textToVideo.queuing') : t('video.generating') }}</div>
<!-- 排队中不确定进度条 -->
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
<div class="progress-fill-indeterminate"></div>
</div>
<!-- 生成中动态进度条 -->
<div v-else class="history-progress-bar">
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
</div>
<div class="queue-link">{{ t('video.textToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
</div>
@@ -237,9 +251,19 @@
</div>
</div>
<!-- 做同款按钮 -->
<!-- 操作按钮 -->
<div class="history-actions">
<button class="similar-btn" @click="createSimilarFromHistory(task)">{{ t('video.textToVideo.createSimilar') }}</button>
<button
v-if="task.status === 'COMPLETED' && task.resultUrl"
class="download-btn"
@click="downloadHistoryVideo(task)"
:title="t('video.textToVideo.downloadVideo')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
</button>
</div>
</div>
</div>
@@ -251,24 +275,32 @@
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
<div class="menu-item" @click="goToProfile">
<el-icon><User /></el-icon>
<span>{{ t('common.userProfile') }}</span>
<!-- 管理员功能 -->
<template v-if="userStore.isAdmin">
<div class="menu-item" @click.stop="goToDashboard">
<el-icon><User /></el-icon>
<span>{{ t('profile.dashboard') }}</span>
</div>
<div class="menu-item" @click.stop="goToOrders">
<el-icon><Document /></el-icon>
<span>{{ t('profile.orderManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToMembers">
<el-icon><User /></el-icon>
<span>{{ t('profile.memberManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToSystemSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('profile.systemSettings') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
<el-icon><Lock /></el-icon>
<span>{{ t('profile.changePassword') }}</span>
</div>
<div class="menu-item" @click="goToMyWorks">
<el-icon><VideoCamera /></el-icon>
<span>{{ t('profile.myWorks') }}</span>
</div>
<div class="menu-item" @click="goToSubscription">
<el-icon><Star /></el-icon>
<span>{{ t('profile.subscription') }}</span>
</div>
<div class="menu-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
<span>{{ t('common.settings') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item logout" @click="logout">
<!-- 退出登录 -->
<div class="menu-item" @click.stop="logout">
<el-icon><SwitchButton /></el-icon>
<span>{{ t('common.logout') }}</span>
</div>
@@ -279,17 +311,18 @@
<script setup>
import { ref, onUnmounted, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
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 } from '@element-plus/icons-vue'
import { ElMessage, ElLoading } from 'element-plus'
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { optimizePrompt } from '@/api/promptOptimizer'
import { getProcessingWorks } from '@/api/userWorks'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const { t } = useI18n()
@@ -382,6 +415,31 @@ const goToSettings = () => {
router.push('/settings')
}
const goToDashboard = () => {
showUserMenu.value = false
router.push('/admin/dashboard')
}
const goToOrders = () => {
showUserMenu.value = false
router.push('/admin/orders')
}
const goToMembers = () => {
showUserMenu.value = false
router.push('/member-management')
}
const goToSystemSettings = () => {
showUserMenu.value = false
router.push('/system-settings')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
}
const logout = () => {
showUserMenu.value = false
userStore.logout()
@@ -396,11 +454,7 @@ const startGenerate = async () => {
return
}
// 检查是否已有任务在进行中
if (inProgress.value) {
ElMessage.warning(t('video.textToVideo.taskInProgress'))
return
}
// 允许多任务并发后端会检查最大任务数限制最多3个
// 验证表单
if (!inputText.value.trim()) {
@@ -458,7 +512,25 @@ const startGenerate = async () => {
}, 2000)
} else {
// 任务创建失败,重置所有状态
ElMessage.error(response.data?.message || t('video.textToVideo.createTaskFailed'))
const errorMsg = response.data?.message || t('video.textToVideo.createTaskFailed')
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(errorMsg)
}
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
@@ -468,7 +540,25 @@ const startGenerate = async () => {
} catch (error) {
console.error('[Create Task Error]', t('video.textToVideo.createTaskFailed'), error)
ElMessage.error(t('video.textToVideo.createTaskFailed'))
const errorMsg = error.response?.data?.message || error.message || ''
// 检测积分不足
if (errorMsg.includes('积分不足') || errorMsg.includes('insufficient') || errorMsg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(t('video.textToVideo.createTaskFailed'))
}
// 异常情况,重置所有状态
inProgress.value = false
isCreatingTask.value = false
@@ -650,17 +740,64 @@ const createSimilar = () => {
}
// 下载视频
const downloadVideo = () => {
if (currentTask.value && currentTask.value.resultUrl) {
const downloadVideo = async () => {
if (!currentTask.value || !currentTask.value.resultUrl) {
ElMessage.error(t('video.textToVideo.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(currentTask.value.resultUrl)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = currentTask.value.resultUrl
link.download = `video_${currentTask.value.taskId}.mp4`
link.href = url
link.download = `video_${currentTask.value.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.textToVideo.downloadStarted'))
} else {
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(currentTask.value.resultUrl, '_blank')
}
}
// 下载历史记录中的视频
const downloadHistoryVideo = async (task) => {
if (!task || !task.resultUrl) {
ElMessage.error(t('video.textToVideo.videoUrlNotAvailable'))
return
}
try {
ElMessage.info('正在准备下载...')
const videoUrl = processHistoryUrl(task.resultUrl)
const response = await fetch(videoUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.textToVideo.downloadStarted'))
} catch (error) {
console.error('下载失败:', error)
window.open(processHistoryUrl(task.resultUrl), '_blank')
}
}
@@ -721,7 +858,8 @@ const loadHistory = async () => {
}
try {
const response = await textToVideoApi.getTasks(0, 5)
// 请求更多条数以确保能筛选出足够的 COMPLETED 任务
const response = await textToVideoApi.getTasks(0, 50)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
@@ -969,6 +1107,20 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 处理"做同款"传递的路由参数
if (route.query.prompt) {
inputText.value = route.query.prompt
if (route.query.aspectRatio) {
aspectRatio.value = route.query.aspectRatio
}
if (route.query.duration) {
duration.value = parseInt(route.query.duration) || 10
}
ElMessage.success(t('video.textToVideo.historyParamsFilled'))
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
loadHistory()
// 延迟恢复任务,避免与创建任务冲突
setTimeout(async () => {
@@ -1093,14 +1245,14 @@ onUnmounted(() => {
transform: scale(1.05);
}
/* 用户菜单样式 */
/* 用户菜单样式 - 深色主题 */
.user-menu-teleport {
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 8px 0;
min-width: 200px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 160px;
overflow: hidden;
z-index: 99999;
}
@@ -1109,36 +1261,24 @@ onUnmounted(() => {
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s ease;
color: #333;
transition: background-color 0.2s ease;
color: white;
font-size: 14px;
}
.menu-item:hover {
background: #f5f7fa;
color: #667eea;
}
.menu-item.logout {
color: #f56565;
}
.menu-item.logout:hover {
background: #fef2f2;
color: #e53e3e;
}
.menu-divider {
height: 1px;
background: #e2e8f0;
margin: 4px 0;
background: #2a2a2a;
}
.menu-item .el-icon {
margin-right: 12px;
margin-right: 8px;
font-size: 16px;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #333;
}
/* 主内容区域 */
.main-content {
flex: 1;
@@ -1719,6 +1859,64 @@ onUnmounted(() => {
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
/* 生成中进度条动态动画 - 使用蓝绿渐变风格 */
.progress-fill-large.animated {
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 不确定进度条(排队中) */
.progress-bar-large.indeterminate {
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 4px;
animation: progress-move 1.5s ease-in-out infinite;
}
/* 进度百分比文字 */
.progress-percentage {
font-size: 14px;
color: #60a5fa;
font-weight: 600;
margin-top: 12px;
text-align: center;
}
/* 历史记录进度条 */
.history-progress-bar {
width: 80%;
max-width: 200px;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
margin: 12px 0;
}
.history-progress-bar.indeterminate .progress-fill-indeterminate {
width: 30%;
height: 100%;
background: linear-gradient(90deg, transparent, #409eff, #67c23a, #409eff, transparent);
border-radius: 3px;
animation: progress-move 1.5s ease-in-out infinite;
}
.history-progress-bar .progress-fill-large {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a, #409eff);
background-size: 200% 100%;
border-radius: 3px;
animation: progress-move 1.5s ease-in-out infinite, progress-gradient 2s linear infinite;
}
/* 完成状态 */
@@ -2109,6 +2307,8 @@ onUnmounted(() => {
.history-actions {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 8px;
margin-top: 0;
}
@@ -2128,4 +2328,39 @@ onUnmounted(() => {
background: #3a3a3a;
border-color: #4a4a4a;
}
.download-btn {
width: 36px;
height: 36px;
background: #fff;
color: #333;
border: none;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.download-btn:hover {
background: #f0f0f0;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
</style>
<!-- scoped 样式用于 @keyframes 动画 -->
<style>
@keyframes progress-move {
0% { transform: translateX(-100%); }
50% { transform: translateX(233%); }
100% { transform: translateX(-100%); }
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
</style>

View File

@@ -330,8 +330,35 @@ const toggleFullscreen = () => {
}
// 操作功能
const downloadVideo = () => {
ElMessage.success('开始下载视频')
const downloadVideo = async () => {
if (!videoData.value.videoUrl) {
ElMessage.warning('视频链接不可用')
return
}
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(videoData.value.videoUrl)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${videoData.value.title || 'video'}_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载已开始')
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(videoData.value.videoUrl, '_blank')
}
}
const deleteVideo = async () => {
@@ -620,9 +647,9 @@ onUnmounted(() => {
}
.action-btn {
background: rgba(0, 0, 0, 0.6);
background: rgba(255, 255, 255, 0.9);
border: none;
color: #fff;
color: #333;
width: 40px;
height: 40px;
border-radius: 50%;
@@ -631,15 +658,18 @@ onUnmounted(() => {
justify-content: center;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.8);
background: #fff;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.delete-btn:hover {
background: rgba(220, 38, 38, 0.8);
background: #fee2e2;
color: #dc2626;
}
/* 右侧详情侧边栏 */

View File

@@ -85,7 +85,7 @@ const goToStoryboardVideo = () => {
left: 0;
width: 100vw;
height: 100vh;
background: url('/images/backgrounds/welcome-bg.svg') center/cover no-repeat;
background: url('/images/backgrounds/welcome_bg.jpg') center/cover no-repeat;
z-index: -1;
}

View File

@@ -47,10 +47,14 @@ export default defineConfig({
}
},
// public 目录配置(确保字体文件等静态资源被复制)
publicDir: 'public',
// 生产环境构建配置
build: {
outDir: 'dist',
assetsDir: 'static',
copyPublicDir: true,
// 代码分割优化
rollupOptions: {
output: {