feat: 添加任务状态级联触发器,优化支付和做同款功能
主要更新: - 添加 MySQL 触发器实现 task_status 表到其他表的状态级联 - 移除控制器中的多表状态检查代码 - 完善做同款功能,支持参数传递 - 支付宝 USD 转 CNY 汇率转换 - 修复状态枚举映射问题 注意: 触发器仅在 task_status 更新时触发,部分代码仍直接更新业务表
This commit is contained in:
BIN
demo/frontend/public/images/backgrounds/login_bg.png
Normal file
BIN
demo/frontend/public/images/backgrounds/login_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
demo/frontend/public/images/backgrounds/welcome_bg.jpg
Normal file
BIN
demo/frontend/public/images/backgrounds/welcome_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '当前任务总数',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
// 方法
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
382
demo/frontend/src/views/ChangePassword.vue
Normal file
382
demo/frontend/src/views/ChangePassword.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
358
demo/frontend/src/views/SetPassword.vue
Normal file
358
demo/frontend/src/views/SetPassword.vue
Normal 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>
|
||||
@@ -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(() => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 右侧详情侧边栏 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
|
||||
// public 目录配置(确保字体文件等静态资源被复制)
|
||||
publicDir: 'public',
|
||||
|
||||
// 生产环境构建配置
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'static',
|
||||
copyPublicDir: true,
|
||||
// 代码分割优化
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user