feat: 添加任务状态级联触发器,优化支付和做同款功能
主要更新: - 添加 MySQL 触发器实现 task_status 表到其他表的状态级联 - 移除控制器中的多表状态检查代码 - 完善做同款功能,支持参数传递 - 支付宝 USD 转 CNY 汇率转换 - 修复状态枚举映射问题 注意: 触发器仅在 task_status 更新时触发,部分代码仍直接更新业务表
This commit is contained in:
109
demo/docs/storyboard-prompt-optimizer.md
Normal file
109
demo/docs/storyboard-prompt-optimizer.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 分镜图提示词优化器 - 系统指令
|
||||
|
||||
你是一个专业的**分镜图生成提示词优化师**。你的任务是将用户的自然语言描述和系统预设要求,整合优化成适合AI绘图模型(Banana)的高质量英文提示词。
|
||||
|
||||
## 输入内容
|
||||
|
||||
你会收到以下信息:
|
||||
|
||||
1. **系统引导词** `[SYSTEM]`:平台预设的风格、画质要求等(可能为空)
|
||||
2. **用户提示** `[USER]`:用户用自然语言描述的图片需求
|
||||
|
||||
## 处理规则
|
||||
|
||||
### 优先级
|
||||
系统安全规则 > 系统画面要求 > 用户个人描述
|
||||
|
||||
### 从用户描述提取
|
||||
- 主体(人物/物体/场景)
|
||||
- 动作/姿态/表情
|
||||
- 环境/背景
|
||||
- 情绪/氛围
|
||||
- 色彩基调
|
||||
|
||||
### 安全过滤
|
||||
自动移除以下内容,不做提示:
|
||||
- 色情/裸露/性暗示
|
||||
- 暴力/血腥/恐怖
|
||||
- 政治敏感/仇恨内容
|
||||
- 真人肖像侵权
|
||||
|
||||
## 输出格式
|
||||
|
||||
**仅输出以下JSON,不要任何额外文字:**
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "英文提示词,逗号分隔,包含主体、场景、风格、光影、画质、构图等",
|
||||
"ratio": "推荐画幅比例:1:1 / 16:9 / 9:16",
|
||||
"note": "简短备注(可选,中英文均可)"
|
||||
}
|
||||
```
|
||||
|
||||
## 提示词结构参考
|
||||
|
||||
按以下顺序组织 prompt:
|
||||
1. **主体描述**:who/what,外貌特征、服装
|
||||
2. **动作姿态**:doing what,表情
|
||||
3. **场景环境**:where,背景细节
|
||||
4. **风格媒介**:anime style / realistic photo / digital art / cinematic 等
|
||||
5. **光影氛围**:lighting, mood, time of day
|
||||
6. **画质标签**:masterpiece, best quality, highly detailed, 8k, sharp focus
|
||||
|
||||
## 默认画质标签
|
||||
|
||||
始终在 prompt 末尾添加:
|
||||
```
|
||||
masterpiece, best quality, highly detailed, sharp focus, professional
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
**输入:**
|
||||
```
|
||||
[SYSTEM] 风格:电影感,高质量
|
||||
[USER] 一个女孩在樱花树下看书
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```json
|
||||
{
|
||||
"prompt": "a young girl reading a book under cherry blossom trees, soft pink petals falling, peaceful spring afternoon, warm sunlight filtering through branches, cinematic composition, soft bokeh background, gentle breeze, serene atmosphere, pastel color palette, masterpiece, best quality, highly detailed, sharp focus, professional",
|
||||
"ratio": "16:9",
|
||||
"note": "已优化为电影感构图,柔和春日氛围"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**输入:**
|
||||
```
|
||||
[SYSTEM]
|
||||
[USER] 科幻城市夜景
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```json
|
||||
{
|
||||
"prompt": "futuristic cyberpunk cityscape at night, towering skyscrapers with neon lights, flying vehicles in the sky, holographic advertisements, rain-slicked streets reflecting colorful lights, dramatic atmosphere, wide angle shot, sci-fi aesthetic, dark blue and purple color scheme, masterpiece, best quality, highly detailed, sharp focus, professional",
|
||||
"ratio": "16:9",
|
||||
"note": "赛博朋克风格夜景,宽屏构图"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**输入:**
|
||||
```
|
||||
[SYSTEM] 二次元插画风格
|
||||
[USER] 可爱的猫咪
|
||||
```
|
||||
|
||||
**输出:**
|
||||
```json
|
||||
{
|
||||
"prompt": "adorable fluffy cat, big sparkling eyes, cute expression, soft fur texture, anime illustration style, pastel colors, warm lighting, cozy atmosphere, kawaii aesthetic, simple clean background, masterpiece, best quality, highly detailed, sharp focus, professional",
|
||||
"ratio": "1:1",
|
||||
"note": "二次元可爱风格,方形构图适合头像"
|
||||
}
|
||||
```
|
||||
108
demo/docs/task_status_cascade_trigger.sql
Normal file
108
demo/docs/task_status_cascade_trigger.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- ============================================
|
||||
-- 任务状态级联更新触发器
|
||||
--
|
||||
-- 功能:当 task_status 表状态更新时,自动同步到:
|
||||
-- - task_queue
|
||||
-- - user_works
|
||||
-- - text_to_video_tasks
|
||||
-- - image_to_video_tasks
|
||||
-- - storyboard_video_tasks
|
||||
--
|
||||
-- 执行方式:在 MySQL 客户端中执行此脚本
|
||||
-- ============================================
|
||||
|
||||
-- 选择数据库
|
||||
USE aigc_platform;
|
||||
|
||||
-- 先删除已存在的触发器
|
||||
DROP TRIGGER IF EXISTS trg_task_status_update;
|
||||
|
||||
-- 修改分隔符
|
||||
DELIMITER //
|
||||
|
||||
-- 创建触发器
|
||||
-- 状态枚举说明:
|
||||
-- task_status/task_queue: PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED, TIMEOUT
|
||||
-- user_works: PROCESSING, COMPLETED, FAILED, DELETED (缺少 PENDING, CANCELLED, TIMEOUT)
|
||||
-- 业务表: PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED (缺少 TIMEOUT)
|
||||
--
|
||||
-- 注意:分镜图(_image后缀)在task_status表中没有独立记录,无需处理
|
||||
CREATE TRIGGER trg_task_status_update
|
||||
AFTER UPDATE ON task_status
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.status <> OLD.status THEN
|
||||
|
||||
-- 1. 更新 task_queue 表(状态枚举一致,直接同步)
|
||||
UPDATE task_queue
|
||||
SET status = NEW.status,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN NEW.error_message ELSE error_message END
|
||||
WHERE task_id = NEW.task_id;
|
||||
|
||||
-- 2. 更新 user_works 表(需要状态映射)
|
||||
-- PENDING -> PROCESSING, CANCELLED/TIMEOUT -> FAILED
|
||||
UPDATE user_works
|
||||
SET status = CASE
|
||||
WHEN NEW.status IN ('PENDING', 'PROCESSING') THEN 'PROCESSING'
|
||||
WHEN NEW.status = 'COMPLETED' THEN 'COMPLETED'
|
||||
WHEN NEW.status IN ('FAILED', 'CANCELLED', 'TIMEOUT') THEN 'FAILED'
|
||||
ELSE status
|
||||
END,
|
||||
updated_at = NOW(),
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
|
||||
-- 3. 更新业务任务表(TIMEOUT 映射为 FAILED)
|
||||
|
||||
-- 文生视频 (txt2vid_*)
|
||||
IF NEW.task_id LIKE 'txt2vid_%' THEN
|
||||
UPDATE text_to_video_tasks
|
||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
-- 图生视频 (img2vid_*)
|
||||
IF NEW.task_id LIKE 'img2vid_%' THEN
|
||||
UPDATE image_to_video_tasks
|
||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
-- 分镜视频 (storyboard_*)
|
||||
IF NEW.task_id LIKE 'storyboard_%' THEN
|
||||
UPDATE storyboard_video_tasks
|
||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END//
|
||||
|
||||
-- 恢复分隔符
|
||||
DELIMITER ;
|
||||
|
||||
-- 验证触发器已创建
|
||||
SHOW TRIGGERS LIKE 'task_status';
|
||||
|
||||
-- ============================================
|
||||
-- 使用说明:
|
||||
--
|
||||
-- 1. 连接到 MySQL 数据库
|
||||
-- 2. 选择对应的数据库: USE your_database_name;
|
||||
-- 3. 执行此脚本
|
||||
--
|
||||
-- 注意事项:
|
||||
-- - 触发器会在 task_status 表的 status 字段更新时自动执行
|
||||
-- - 确保所有表都有 task_id 字段作为关联键
|
||||
-- - 状态值使用字符串存储(如 'COMPLETED', 'FAILED' 等)
|
||||
-- ============================================
|
||||
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)
|
||||
|
||||
// 先创建支付记录
|
||||
console.log('1. 创建支付订单...')
|
||||
const createResponse = await createPayment(paymentData)
|
||||
console.log('创建支付订单响应:', createResponse)
|
||||
|
||||
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)
|
||||
console.log('支付宝二维码:', qrCode)
|
||||
|
||||
// 使用在线API生成二维码图片(直接使用支付宝返回的URL生成二维码)
|
||||
try {
|
||||
console.log('开始生成二维码,内容:', qrCode)
|
||||
// 使用QuickChart API生成二维码
|
||||
qrCodeUrl.value = `https://quickchart.io/qr?text=${encodeURIComponent(qrCode)}&size=200&margin=0&dark=ffffff&light=1a1a1a`
|
||||
|
||||
// 使用QuickChart API生成二维码,完全去除白边
|
||||
// 直接使用支付宝返回的URL作为二维码内容
|
||||
const qrCodeUrl = `https://quickchart.io/qr?text=${encodeURIComponent(qrCode)}&size=200&margin=0&dark=ffffff&light=1a1a1a`
|
||||
console.log('二维码图片URL已生成:', qrCodeUrl.value)
|
||||
|
||||
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('=== 支付流程完成,开始轮询支付状态 ===')
|
||||
// 显示二维码
|
||||
showQrCode.value = true
|
||||
console.log('二维码已显示')
|
||||
|
||||
// 开始轮询支付状态
|
||||
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 || '创建支付订单失败'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理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,7 +42,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>
|
||||
@@ -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">
|
||||
<!-- 管理员功能 -->
|
||||
<template v-if="userStore.isAdmin">
|
||||
<div class="menu-item" @click.stop="goToDashboard">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ t('common.userProfile') }}</span>
|
||||
<span>{{ t('profile.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToMyWorks">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>{{ t('profile.myWorks') }}</span>
|
||||
<div class="menu-item" @click.stop="goToOrders">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ t('profile.orderManagement') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToSubscription">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>{{ t('profile.subscription') }}</span>
|
||||
<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="goToSystemSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('common.settings') }}</span>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-item logout" @click="logout">
|
||||
</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>
|
||||
@@ -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)
|
||||
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 }
|
||||
}
|
||||
|
||||
// 只有超级管理员才能修改角色,且不能修改超级管理员的角色
|
||||
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)
|
||||
|
||||
// 从本地列表中移除
|
||||
const index = memberList.value.findIndex(m => m.id === member.id)
|
||||
if (index > -1) {
|
||||
memberList.value.splice(index, 1)
|
||||
totalMembers.value--
|
||||
}
|
||||
console.log('准备发送DELETE请求, id:', member.id)
|
||||
const response = await memberAPI.deleteMember(member.id)
|
||||
console.log('DELETE请求响应:', response)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
ElMessage.success('删除成功')
|
||||
// 重新加载列表
|
||||
await loadMembers()
|
||||
} else {
|
||||
console.log('删除失败, response.data:', response.data)
|
||||
ElMessage.error(response.data?.message || '删除失败')
|
||||
}
|
||||
} 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
|
||||
if (response.data && response.data.success) {
|
||||
ElMessage.success(response.data.message || '批量删除成功')
|
||||
selectedMembers.value = []
|
||||
|
||||
ElMessage.success('批量删除成功')
|
||||
// 重新加载列表
|
||||
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
|
||||
console.log('准备跳转到 /change-password')
|
||||
router.push('/change-password').then(() => {
|
||||
console.log('跳转成功')
|
||||
}).catch(err => {
|
||||
console.error('跳转失败:', err)
|
||||
alert('跳转失败: ' + err.message)
|
||||
})
|
||||
ElMessage.success('密码修改成功')
|
||||
changePasswordDialogVisible.value = false
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error)
|
||||
const msg = error?.response?.data?.message || '修改密码失败'
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
changePasswordLoading.value = false
|
||||
}
|
||||
}
|
||||
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'
|
||||
}
|
||||
}
|
||||
// 滚动到顶部
|
||||
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
|
||||
}
|
||||
}
|
||||
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">
|
||||
<!-- 管理员功能 -->
|
||||
<template v-if="userStore.isAdmin">
|
||||
<div class="menu-item" @click.stop="goToDashboard">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ t('common.userProfile') }}</span>
|
||||
<span>{{ t('profile.dashboard') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToMyWorks">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>{{ t('profile.myWorks') }}</span>
|
||||
<div class="menu-item" @click.stop="goToOrders">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ t('profile.orderManagement') }}</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="goToSubscription">
|
||||
<el-icon><Star /></el-icon>
|
||||
<span>{{ t('profile.subscription') }}</span>
|
||||
<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="goToSystemSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>{{ t('common.settings') }}</span>
|
||||
<span>{{ t('profile.systemSettings') }}</span>
|
||||
</div>
|
||||
<div class="menu-divider"></div>
|
||||
<div class="menu-item logout" @click="logout">
|
||||
</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>
|
||||
@@ -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)
|
||||
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: {
|
||||
|
||||
10
demo/pom.xml
10
demo/pom.xml
@@ -199,6 +199,16 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- 缓存支持 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<!-- Caffeine 高性能本地缓存 -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
49
demo/src/main/java/com/example/demo/config/CacheConfig.java
Normal file
49
demo/src/main/java/com/example/demo/config/CacheConfig.java
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
/**
|
||||
* 缓存配置类
|
||||
* 使用 Caffeine 作为本地缓存实现,提高系统性能
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
/**
|
||||
* 缓存名称常量
|
||||
*/
|
||||
public static final String USER_CACHE = "userCache";
|
||||
public static final String USER_POINTS_CACHE = "userPointsCache";
|
||||
public static final String USER_WORK_STATS_CACHE = "userWorkStatsCache";
|
||||
public static final String SYSTEM_CONFIG_CACHE = "systemConfigCache";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
|
||||
// 默认缓存配置:最大1000条,5分钟过期
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.maximumSize(1000)
|
||||
.expireAfterWrite(5, TimeUnit.MINUTES)
|
||||
.recordStats()); // 记录统计信息
|
||||
|
||||
// 注册缓存名称
|
||||
cacheManager.setCacheNames(java.util.Arrays.asList(
|
||||
USER_CACHE,
|
||||
USER_POINTS_CACHE,
|
||||
USER_WORK_STATS_CACHE,
|
||||
SYSTEM_CONFIG_CACHE
|
||||
));
|
||||
|
||||
return cacheManager;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ public class CosConfig {
|
||||
@Value("${tencent.cos.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${tencent.cos.prefix:}")
|
||||
private String prefix;
|
||||
|
||||
@Bean
|
||||
public COSClient cosClient() {
|
||||
if (!enabled || secretId.isEmpty() || secretKey.isEmpty()) {
|
||||
@@ -59,4 +62,8 @@ public class CosConfig {
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ public class PayPalConfig {
|
||||
@Value("${paypal.mode:sandbox}")
|
||||
private String mode;
|
||||
|
||||
@Value("${paypal.success-url:http://localhost:8080/payment/paypal/success}")
|
||||
@Value("${paypal.success-url:https://vionow.com/api/payment/paypal/success}")
|
||||
private String successUrl;
|
||||
|
||||
@Value("${paypal.cancel-url:http://localhost:8080/payment/paypal/cancel}")
|
||||
@Value("${paypal.cancel-url:https://vionow.com/api/payment/paypal/cancel}")
|
||||
private String cancelUrl;
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,10 +81,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
|
||||
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
|
||||
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
|
||||
.requestMatchers("/api/dashboard/**").hasRole("ADMIN") // 仪表盘API需要管理员权限
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 管理员API需要管理员权限
|
||||
.requestMatchers("/settings", "/settings/**").hasRole("ADMIN")
|
||||
.requestMatchers("/users/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
|
||||
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
|
||||
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
|
||||
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.formLogin(form -> form
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.web.servlet.LocaleResolver;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
@@ -73,9 +75,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
// 确保路径使用正斜杠(URL格式)
|
||||
String resourceLocation = "file:" + uploadDirPath.toAbsolutePath().toString().replace("\\", "/") + "/";
|
||||
|
||||
// 上传文件缓存7天(视频/图片等媒体文件)
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations(resourceLocation)
|
||||
.setCachePeriod(3600); // 缓存1小时
|
||||
.setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// 静态资源缓存配置(JS/CSS/图片等)
|
||||
registry.addResourceHandler("/static/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// 图片资源缓存30天
|
||||
registry.addResourceHandler("/images/**")
|
||||
.addResourceLocations("classpath:/static/images/")
|
||||
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
|
||||
}
|
||||
|
||||
// CORS配置已移至SecurityConfig,避免冲突
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.repository.TaskStatusRepository;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
import com.example.demo.service.OnlineStatsService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
/**
|
||||
@@ -49,6 +50,9 @@ public class AdminController {
|
||||
@Autowired
|
||||
private TaskStatusRepository taskStatusRepository;
|
||||
|
||||
@Autowired
|
||||
private OnlineStatsService onlineStatsService;
|
||||
|
||||
/**
|
||||
* 给用户增加积分
|
||||
*/
|
||||
@@ -582,5 +586,29 @@ public class AdminController {
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统在线统计
|
||||
* 返回当天访问的独立IP数(通过IP判断在线人数)
|
||||
*/
|
||||
@GetMapping("/online-stats")
|
||||
public ResponseEntity<Map<String, Object>> getOnlineStats() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
try {
|
||||
int todayVisitors = onlineStatsService.getTodayVisitorCount();
|
||||
Map<String, Object> stats = onlineStatsService.getStats();
|
||||
|
||||
response.put("success", true);
|
||||
response.put("todayVisitors", todayVisitors);
|
||||
response.put("date", stats.get("date"));
|
||||
response.put("uptime", stats.get("uptime"));
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("获取在线统计失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "获取在线统计失败: " + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,11 +100,14 @@ public class AlipayCallbackController {
|
||||
outTradeNo, tradeNo, tradeStatus, totalAmount, appId);
|
||||
|
||||
// 查找支付记录
|
||||
logger.info("正在查找支付记录,订单号: {}", outTradeNo);
|
||||
Optional<Payment> paymentOpt = paymentRepository.findByOrderId(outTradeNo);
|
||||
if (!paymentOpt.isPresent()) {
|
||||
logger.error("支付记录不存在: {}", outTradeNo);
|
||||
logger.error("❌ 支付记录不存在: orderId={}", outTradeNo);
|
||||
logger.error("请检查数据库中是否存在该订单号的支付记录");
|
||||
return "failure";
|
||||
}
|
||||
logger.info("✅ 找到支付记录: paymentId={}", paymentOpt.get().getId());
|
||||
|
||||
Payment payment = paymentOpt.get();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.example.demo.model.User;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.service.VerificationCodeService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -187,6 +188,12 @@ public class AuthApiController {
|
||||
|
||||
// 直接创建用户对象并设置所有必要字段
|
||||
user = new User();
|
||||
|
||||
// 生成唯一用户ID(UID + yyMMdd + 4位随机字符)
|
||||
String generatedUserId = UserIdGenerator.generate();
|
||||
user.setUserId(generatedUserId);
|
||||
logger.info("邮箱验证码登录 - 生成用户ID: {}", generatedUserId);
|
||||
|
||||
user.setUsername(username);
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(""); // 邮箱登录不需要密码
|
||||
@@ -377,14 +384,16 @@ public class AuthApiController {
|
||||
|
||||
/**
|
||||
* 修改当前登录用户密码
|
||||
* 支持首次设置密码(isFirstTimeSetup=true时不验证旧密码)
|
||||
*/
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> requestBody,
|
||||
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, Object> requestBody,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String oldPassword = requestBody.get("oldPassword");
|
||||
String newPassword = requestBody.get("newPassword");
|
||||
String oldPassword = (String) requestBody.get("oldPassword");
|
||||
String newPassword = (String) requestBody.get("newPassword");
|
||||
Boolean isFirstTimeSetup = Boolean.TRUE.equals(requestBody.get("isFirstTimeSetup"));
|
||||
|
||||
// 尝试从 Spring Security 上下文中获取当前用户名
|
||||
User user = null;
|
||||
@@ -410,7 +419,9 @@ public class AuthApiController {
|
||||
.body(createErrorResponse("用户未登录或会话已失效"));
|
||||
}
|
||||
|
||||
userService.changePassword(user.getId(), oldPassword, newPassword);
|
||||
// changePassword 方法内部已处理首次设置密码场景
|
||||
// 如果用户没有密码,可以直接设置新密码,不需要验证旧密码
|
||||
userService.changePassword(user.getId(), isFirstTimeSetup ? null : oldPassword, newPassword);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.example.demo.repository.OrderRepository;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.service.DashboardService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/dashboard")
|
||||
@@ -47,42 +48,15 @@ public class DashboardApiController {
|
||||
@Autowired
|
||||
private com.example.demo.service.UserService userService;
|
||||
|
||||
@Autowired
|
||||
private DashboardService dashboardService;
|
||||
|
||||
// 获取仪表盘概览数据
|
||||
@GetMapping("/overview")
|
||||
public ResponseEntity<Map<String, Object>> getDashboardOverview() {
|
||||
try {
|
||||
Map<String, Object> overview = new HashMap<>();
|
||||
|
||||
// 用户总数
|
||||
long totalUsers = userRepository.count();
|
||||
overview.put("totalUsers", totalUsers);
|
||||
|
||||
// 付费用户数(有会员的用户)
|
||||
long paidUsers = userMembershipRepository.countByStatus("ACTIVE");
|
||||
overview.put("paidUsers", paidUsers);
|
||||
|
||||
// 今日收入(今日完成的支付)
|
||||
LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
|
||||
LocalDateTime todayEnd = LocalDateTime.now().withHour(23).withMinute(59).withSecond(59);
|
||||
|
||||
Double todayRevenue = paymentRepository.findTodayRevenue(todayStart, todayEnd);
|
||||
overview.put("todayRevenue", todayRevenue != null ? todayRevenue : 0.0);
|
||||
|
||||
// 总订单数
|
||||
long totalOrders = orderRepository.count();
|
||||
overview.put("totalOrders", totalOrders);
|
||||
|
||||
// 总收入
|
||||
Double totalRevenue = paymentRepository.findTotalRevenue();
|
||||
overview.put("totalRevenue", totalRevenue != null ? totalRevenue : 0.0);
|
||||
|
||||
// 本月收入
|
||||
LocalDateTime monthStart = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
|
||||
LocalDateTime monthEnd = LocalDateTime.now();
|
||||
|
||||
Double monthRevenue = paymentRepository.findRevenueByDateRange(monthStart, monthEnd);
|
||||
overview.put("monthRevenue", monthRevenue != null ? monthRevenue : 0.0);
|
||||
|
||||
// 使用 DashboardService 获取包含同比变化的完整数据
|
||||
Map<String, Object> overview = dashboardService.getDashboardOverview();
|
||||
return ResponseEntity.ok(overview);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -149,8 +123,8 @@ public class DashboardApiController {
|
||||
// 总用户数
|
||||
long totalUsers = userRepository.count();
|
||||
|
||||
// 付费用户数
|
||||
long paidUsers = userMembershipRepository.countByStatus("ACTIVE");
|
||||
// 付费用户数(使用 Payment 表统计,与卡片统计方式一致)
|
||||
long paidUsers = paymentRepository.countDistinctUsersByStatus(com.example.demo.model.PaymentStatus.SUCCESS);
|
||||
|
||||
// 计算转化率
|
||||
double conversionRate = totalUsers > 0 ? (double) paidUsers / totalUsers * 100 : 0.0;
|
||||
@@ -179,7 +153,7 @@ public class DashboardApiController {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取按月转化率数据
|
||||
// 获取按月转化率数据(累计转化率:截止到该月底的累计付费用户/累计总用户)
|
||||
private List<Map<String, Object>> getMonthlyConversionRate(int year) {
|
||||
List<Map<String, Object>> monthlyData = new ArrayList<>();
|
||||
|
||||
@@ -187,20 +161,20 @@ public class DashboardApiController {
|
||||
Map<String, Object> monthData = new HashMap<>();
|
||||
monthData.put("month", month);
|
||||
|
||||
// 计算该月的总用户数(注册时间在该月)
|
||||
LocalDateTime monthStart = LocalDateTime.of(year, month, 1, 0, 0, 0);
|
||||
LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1);
|
||||
// 计算截止到该月底的累计总用户数
|
||||
LocalDateTime monthEnd = LocalDateTime.of(year, month, 1, 0, 0, 0).plusMonths(1).minusSeconds(1);
|
||||
|
||||
long monthTotalUsers = userRepository.countByCreatedAtBetween(monthStart, monthEnd);
|
||||
long cumulativeTotalUsers = userRepository.countByCreatedAtBefore(monthEnd);
|
||||
|
||||
// 计算该月新增的付费用户数(会员开始时间在该月)
|
||||
long monthPaidUsers = userMembershipRepository.countByStartDateBetween(monthStart, monthEnd);
|
||||
// 计算截止到该月底的累计付费用户数(使用 Payment 表统计,与卡片统计方式一致)
|
||||
long cumulativePaidUsers = paymentRepository.countDistinctUsersByStatusAndPaidAtBefore(
|
||||
com.example.demo.model.PaymentStatus.SUCCESS, monthEnd);
|
||||
|
||||
// 计算该月转化率
|
||||
double monthConversionRate = monthTotalUsers > 0 ? (double) monthPaidUsers / monthTotalUsers * 100 : 0.0;
|
||||
// 计算累计转化率
|
||||
double monthConversionRate = cumulativeTotalUsers > 0 ? (double) cumulativePaidUsers / cumulativeTotalUsers * 100 : 0.0;
|
||||
|
||||
monthData.put("totalUsers", monthTotalUsers);
|
||||
monthData.put("paidUsers", monthPaidUsers);
|
||||
monthData.put("totalUsers", cumulativeTotalUsers);
|
||||
monthData.put("paidUsers", cumulativePaidUsers);
|
||||
monthData.put("conversionRate", Math.round(monthConversionRate * 100.0) / 100.0);
|
||||
|
||||
monthlyData.add(monthData);
|
||||
@@ -247,25 +221,38 @@ public class DashboardApiController {
|
||||
@GetMapping("/system-status")
|
||||
public ResponseEntity<Map<String, Object>> getSystemStatus() {
|
||||
try {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
// 当前在线用户(基于最近10分钟内有活动的用户)
|
||||
long onlineUsers = userService.countOnlineUsers();
|
||||
status.put("onlineUsers", onlineUsers);
|
||||
data.put("onlineUsers", onlineUsers);
|
||||
data.put("maxUsers", 500);
|
||||
|
||||
// 系统运行时间
|
||||
status.put("systemUptime", "48小时32分");
|
||||
// 系统运行时间(从JVM启动时间计算)
|
||||
long uptimeMillis = java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime();
|
||||
long uptimeSeconds = uptimeMillis / 1000;
|
||||
data.put("uptime", uptimeSeconds);
|
||||
|
||||
// 格式化的运行时间
|
||||
long hours = uptimeSeconds / 3600;
|
||||
long minutes = (uptimeSeconds % 3600) / 60;
|
||||
data.put("uptimeFormatted", hours + "小时" + minutes + "分");
|
||||
|
||||
// 数据库连接状态
|
||||
status.put("databaseStatus", "正常");
|
||||
data.put("databaseStatus", "正常");
|
||||
|
||||
// 服务状态
|
||||
status.put("serviceStatus", "运行中");
|
||||
data.put("serviceStatus", "运行中");
|
||||
|
||||
return ResponseEntity.ok(status);
|
||||
response.put("success", true);
|
||||
response.put("data", data);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("error", "获取系统状态失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
|
||||
@@ -8,14 +8,15 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@ControllerAdvice
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
@@ -92,6 +93,24 @@ public class GlobalExceptionHandler {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理不支持的请求方法异常(如 POST / 等)
|
||||
* 这类请求通常是扫描器或误操作,静默返回 405 即可
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleMethodNotSupported(
|
||||
HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
|
||||
// 仅记录简单日志,不输出详细堆栈
|
||||
logger.warn("不支持的请求方法: {} {}", request.getMethod(), request.getRequestURI());
|
||||
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "Method Not Allowed");
|
||||
errorResponse.put("path", request.getRequestURI());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理其他所有异常
|
||||
*/
|
||||
|
||||
@@ -243,6 +243,8 @@ public class ImageToVideoApiController {
|
||||
return ResponseEntity.status(403).body(response);
|
||||
}
|
||||
|
||||
// 状态同步已通过数据库触发器实现,无需代码检查
|
||||
|
||||
response.put("success", true);
|
||||
Map<String, Object> taskData = new HashMap<>();
|
||||
taskData.put("id", task.getId());
|
||||
|
||||
@@ -27,6 +27,10 @@ import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/members")
|
||||
@@ -42,16 +46,35 @@ public class MemberApiController {
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
@Autowired
|
||||
private JwtUtils jwtUtils;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// 获取会员列表
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getMembers(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int pageSize,
|
||||
@RequestParam(required = false) String level) {
|
||||
@RequestParam(required = false) String level,
|
||||
@RequestParam(required = false) String status) {
|
||||
|
||||
try {
|
||||
Pageable pageable = PageRequest.of(page - 1, pageSize, Sort.by("createdAt").descending());
|
||||
Page<User> userPage = userRepository.findAll(pageable);
|
||||
|
||||
Page<User> userPage;
|
||||
// 根据 status 参数筛选用户
|
||||
if ("all".equals(status)) {
|
||||
// 显示所有用户
|
||||
userPage = userRepository.findAll(pageable);
|
||||
} else if ("banned".equals(status)) {
|
||||
// 只显示封禁用户
|
||||
userPage = userRepository.findByIsActive(false, pageable);
|
||||
} else {
|
||||
// 默认只显示活跃用户
|
||||
userPage = userRepository.findByIsActive(true, pageable);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> members = userPage.getContent().stream()
|
||||
.map(user -> {
|
||||
@@ -160,8 +183,27 @@ public class MemberApiController {
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> updateMember(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Object> updateData) {
|
||||
@RequestBody Map<String, Object> updateData,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
|
||||
}
|
||||
|
||||
User admin = userRepository.findByUsername(adminUsername).orElse(null);
|
||||
if (admin == null) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
|
||||
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
|
||||
|
||||
if (!isSuperAdmin && !isAdmin) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userRepository.findById(id);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -174,29 +216,69 @@ public class MemberApiController {
|
||||
user.setUsername((String) updateData.get("username"));
|
||||
}
|
||||
if (updateData.containsKey("points")) {
|
||||
user.setPoints((Integer) updateData.get("points"));
|
||||
Object pointsObj = updateData.get("points");
|
||||
if (pointsObj instanceof Number) {
|
||||
user.setPoints(((Number) pointsObj).intValue());
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
// 只有超级管理员可以修改角色,且不能修改超级管理员的角色
|
||||
if (updateData.containsKey("role") && isSuperAdmin) {
|
||||
String newRole = (String) updateData.get("role");
|
||||
// 如果被编辑的用户是超级管理员,跳过角色修改
|
||||
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
// 不做任何操作,保持超级管理员角色
|
||||
} else if ("ROLE_USER".equals(newRole) || "ROLE_ADMIN".equals(newRole)) {
|
||||
// 只允许设置为普通用户或管理员
|
||||
user.setRole(newRole);
|
||||
}
|
||||
// 如果 newRole 是 ROLE_SUPER_ADMIN,忽略(不允许通过此接口设置超级管理员)
|
||||
}
|
||||
|
||||
// 更新会员等级
|
||||
if (updateData.containsKey("level")) {
|
||||
userService.save(user);
|
||||
|
||||
// 更新会员等级和到期时间
|
||||
String levelName = (String) updateData.get("level");
|
||||
String expiryDateStr = (String) updateData.get("expiryDate");
|
||||
|
||||
if (levelName != null) {
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository
|
||||
.findByDisplayName(levelName);
|
||||
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
|
||||
// 更新或创建会员信息
|
||||
// 查找或创建会员信息
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
if (membershipOpt.isPresent()) {
|
||||
UserMembership membership = membershipOpt.get();
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
userMembershipRepository.save(membership);
|
||||
membership = membershipOpt.get();
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setStartDate(java.time.LocalDateTime.now());
|
||||
// 根据会员等级的 durationDays 计算到期时间
|
||||
int durationDays = level.getDurationDays() != null ? level.getDurationDays() : 365;
|
||||
membership.setEndDate(java.time.LocalDateTime.now().plusDays(durationDays));
|
||||
}
|
||||
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atStartOfDay());
|
||||
} catch (Exception e) {
|
||||
// 日期格式错误,忽略
|
||||
}
|
||||
}
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,17 +298,59 @@ public class MemberApiController {
|
||||
|
||||
// 删除会员
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> deleteMember(@PathVariable Long id) {
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deleteMember(
|
||||
@PathVariable Long id,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
|
||||
}
|
||||
|
||||
User admin = userRepository.findByUsername(adminUsername).orElse(null);
|
||||
if (admin == null) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
|
||||
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
|
||||
|
||||
if (!isSuperAdmin && !isAdmin) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userRepository.findById(id);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// 软删除:设置为非活跃状态
|
||||
User user = userOpt.get();
|
||||
user.setIsActive(false);
|
||||
userRepository.save(user);
|
||||
|
||||
// 不能删除自己
|
||||
if (user.getUsername().equals(adminUsername)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能删除自己的账号"));
|
||||
}
|
||||
|
||||
// 不能删除超级管理员
|
||||
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能删除超级管理员账号"));
|
||||
}
|
||||
|
||||
// 普通管理员不能删除其他管理员,只有超级管理员可以
|
||||
if ("ROLE_ADMIN".equals(user.getRole()) && !isSuperAdmin) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "只有超级管理员才能删除管理员账号"));
|
||||
}
|
||||
|
||||
// 先删除关联的会员信息
|
||||
userMembershipRepository.deleteByUserId(user.getId());
|
||||
|
||||
// 清除用户缓存
|
||||
userService.evictUserCache(user.getUsername());
|
||||
|
||||
// 物理删除用户
|
||||
userRepository.delete(user);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -244,21 +368,60 @@ public class MemberApiController {
|
||||
|
||||
// 批量删除会员
|
||||
@DeleteMapping("/batch")
|
||||
public ResponseEntity<Map<String, Object>> deleteMembers(@RequestBody Map<String, List<Long>> request) {
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deleteMembers(
|
||||
@RequestBody Map<String, List<Long>> request,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
|
||||
}
|
||||
|
||||
User admin = userRepository.findByUsername(adminUsername).orElse(null);
|
||||
if (admin == null) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
|
||||
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
|
||||
|
||||
if (!isSuperAdmin && !isAdmin) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
List<Long> ids = request.get("ids");
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "请提供要删除的会员ID列表"));
|
||||
}
|
||||
|
||||
List<User> users = userRepository.findAllById(ids);
|
||||
users.forEach(user -> user.setIsActive(false));
|
||||
userRepository.saveAll(users);
|
||||
|
||||
// 过滤掉自己、超级管理员,普通管理员还需要过滤掉其他管理员
|
||||
final boolean finalIsSuperAdmin = isSuperAdmin;
|
||||
List<User> toDelete = users.stream()
|
||||
.filter(user -> !user.getUsername().equals(adminUsername))
|
||||
.filter(user -> !"ROLE_SUPER_ADMIN".equals(user.getRole()))
|
||||
.filter(user -> finalIsSuperAdmin || !"ROLE_ADMIN".equals(user.getRole()))
|
||||
.toList();
|
||||
|
||||
int skipped = users.size() - toDelete.size();
|
||||
|
||||
// 物理删除:先删除关联的会员信息,清除缓存,再删除用户
|
||||
for (User user : toDelete) {
|
||||
userMembershipRepository.deleteByUserId(user.getId());
|
||||
userService.evictUserCache(user.getUsername()); // 清除缓存
|
||||
}
|
||||
userRepository.deleteAll(toDelete);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "批量删除成功");
|
||||
response.put("deletedCount", users.size());
|
||||
response.put("message", skipped > 0
|
||||
? "批量删除成功,已跳过 " + skipped + " 个管理员账号"
|
||||
: "批量删除成功");
|
||||
response.put("deletedCount", toDelete.size());
|
||||
response.put("skippedCount", skipped);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
@@ -270,6 +433,160 @@ public class MemberApiController {
|
||||
}
|
||||
}
|
||||
|
||||
// 封禁/解封会员
|
||||
@PutMapping("/{id}/ban")
|
||||
public ResponseEntity<Map<String, Object>> toggleBanMember(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, Boolean> request,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
|
||||
}
|
||||
|
||||
User admin = userRepository.findByUsername(adminUsername).orElse(null);
|
||||
if (admin == null) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
|
||||
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
|
||||
|
||||
if (!isSuperAdmin && !isAdmin) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userRepository.findById(id);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
|
||||
// 不能封禁自己
|
||||
if (user.getUsername().equals(adminUsername)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能封禁自己的账号"));
|
||||
}
|
||||
|
||||
// 不能封禁超级管理员
|
||||
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能封禁超级管理员账号"));
|
||||
}
|
||||
|
||||
// 普通管理员不能封禁其他管理员,只有超级管理员可以
|
||||
if ("ROLE_ADMIN".equals(user.getRole()) && !isSuperAdmin) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "只有超级管理员才能封禁管理员账号"));
|
||||
}
|
||||
|
||||
// 获取要设置的状态(true=解封,false=封禁)
|
||||
Boolean isActive = request.get("isActive");
|
||||
if (isActive == null) {
|
||||
isActive = !user.getIsActive(); // 如果没传,则切换状态
|
||||
}
|
||||
|
||||
user.setIsActive(isActive);
|
||||
userService.save(user);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", isActive ? "解封成功" : "封禁成功");
|
||||
response.put("isActive", isActive);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "操作失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置用户角色(仅超级管理员可操作)
|
||||
@PutMapping("/{id}/role")
|
||||
public ResponseEntity<Map<String, Object>> setUserRole(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, String> request,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
try {
|
||||
// 验证超级管理员权限
|
||||
String adminUsername = extractUsernameFromToken(token);
|
||||
if (adminUsername == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
|
||||
}
|
||||
|
||||
User admin = userRepository.findByUsername(adminUsername).orElse(null);
|
||||
if (admin == null || !"ROLE_SUPER_ADMIN".equals(admin.getRole())) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要超级管理员权限"));
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userRepository.findById(id);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
|
||||
// 不能修改自己的角色
|
||||
if (user.getUsername().equals(adminUsername)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能修改自己的角色"));
|
||||
}
|
||||
|
||||
// 不能修改其他超级管理员的角色
|
||||
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能修改超级管理员的角色"));
|
||||
}
|
||||
|
||||
String newRole = request.get("role");
|
||||
if (newRole == null || newRole.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "请指定角色"));
|
||||
}
|
||||
|
||||
// 验证角色有效性
|
||||
if (!"ROLE_USER".equals(newRole) && !"ROLE_ADMIN".equals(newRole)) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "无效的角色"));
|
||||
}
|
||||
|
||||
String oldRole = user.getRole();
|
||||
user.setRole(newRole);
|
||||
userService.save(user);
|
||||
|
||||
String action = "ROLE_ADMIN".equals(newRole) ? "设置为管理员" : "取消管理员权限";
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "用户 " + user.getUsername() + " 已" + action);
|
||||
response.put("oldRole", oldRole);
|
||||
response.put("newRole", newRole);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "操作失败");
|
||||
error.put("message", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从Token中提取用户名
|
||||
private String extractUsernameFromToken(String token) {
|
||||
try {
|
||||
if (token == null || !token.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
String actualToken = token.substring(7);
|
||||
if (jwtUtils.isTokenExpired(actualToken)) {
|
||||
return null;
|
||||
}
|
||||
return jwtUtils.getUsernameFromToken(actualToken);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有会员等级配置(用于系统设置和订阅页面)
|
||||
@GetMapping("/levels")
|
||||
public ResponseEntity<Map<String, Object>> getMembershipLevels() {
|
||||
|
||||
@@ -82,7 +82,7 @@ public class OrderApiController {
|
||||
|
||||
// 获取订单列表
|
||||
Page<Order> orderPage;
|
||||
if (user.getRole().equals("ROLE_ADMIN")) {
|
||||
if ((user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
|
||||
// 管理员可以查看所有订单
|
||||
orderPage = orderService.findAllOrders(pageable, status, type, search);
|
||||
} else {
|
||||
@@ -176,7 +176,7 @@ public class OrderApiController {
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "无权限访问此订单");
|
||||
@@ -254,7 +254,7 @@ public class OrderApiController {
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限操作此订单"));
|
||||
}
|
||||
@@ -297,7 +297,7 @@ public class OrderApiController {
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限操作此订单"));
|
||||
}
|
||||
@@ -331,7 +331,7 @@ public class OrderApiController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以发货
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限操作此订单"));
|
||||
}
|
||||
@@ -364,7 +364,7 @@ public class OrderApiController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以完成订单
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限操作此订单"));
|
||||
}
|
||||
@@ -457,7 +457,7 @@ public class OrderApiController {
|
||||
|
||||
// 获取统计数据
|
||||
Map<String, Object> stats;
|
||||
if (user.getRole().equals("ROLE_ADMIN")) {
|
||||
if ((user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
|
||||
// 管理员查看所有订单统计
|
||||
stats = orderService.getOrderStats();
|
||||
} else {
|
||||
@@ -491,7 +491,7 @@ public class OrderApiController {
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限删除此订单"));
|
||||
}
|
||||
@@ -522,7 +522,7 @@ public class OrderApiController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以批量删除
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("无权限批量删除订单"));
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public class OrderController {
|
||||
Order order = orderOpt.get();
|
||||
|
||||
// 检查权限:用户只能查看自己的订单,管理员可以查看所有订单
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
model.addAttribute("error", "无权限访问此订单");
|
||||
return "orders/detail";
|
||||
}
|
||||
@@ -230,7 +230,7 @@ public class OrderController {
|
||||
Order order = orderOpt.get();
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
model.addAttribute("error", "无权限操作此订单");
|
||||
return "redirect:/orders";
|
||||
}
|
||||
@@ -273,7 +273,7 @@ public class OrderController {
|
||||
Order order = orderOpt.get();
|
||||
|
||||
// 检查权限
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
|
||||
model.addAttribute("error", "无权限操作此订单");
|
||||
return "redirect:/orders";
|
||||
}
|
||||
@@ -303,7 +303,7 @@ public class OrderController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以发货
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
|
||||
model.addAttribute("error", "无权限操作此订单");
|
||||
return "redirect:/orders/" + id;
|
||||
}
|
||||
@@ -332,7 +332,7 @@ public class OrderController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以完成订单
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
|
||||
model.addAttribute("error", "无权限操作此订单");
|
||||
return "redirect:/orders/" + id;
|
||||
}
|
||||
@@ -416,7 +416,7 @@ public class OrderController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以访问
|
||||
if (!user.getRole().equals("ROLE_ADMIN")) {
|
||||
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
|
||||
model.addAttribute("error", "无权限访问");
|
||||
return "redirect:/orders";
|
||||
}
|
||||
|
||||
@@ -37,10 +37,13 @@ public class PayPalController {
|
||||
|
||||
/**
|
||||
* 创建PayPal支付
|
||||
* 支持两种模式:
|
||||
* 1. 传入 paymentId:使用已有的支付记录
|
||||
* 2. 不传入 paymentId:创建新的支付记录
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建PayPal支付", description = "创建PayPal支付订单并返回支付URL")
|
||||
public ResponseEntity<Map<String, Object>> createPayment(@RequestBody Map<String, String> request) {
|
||||
public ResponseEntity<Map<String, Object>> createPayment(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
logger.info("=== 创建PayPal支付请求 ===");
|
||||
logger.info("请求参数: {}", request);
|
||||
@@ -52,19 +55,72 @@ public class PayPalController {
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
|
||||
String username = request.get("username");
|
||||
String orderId = request.get("orderId");
|
||||
String amount = request.get("amount");
|
||||
String method = request.get("method");
|
||||
Payment payment;
|
||||
|
||||
// 创建支付记录
|
||||
Payment payment = paymentService.createPayment(username, orderId, amount, method);
|
||||
// 检查是否传入了已有的 paymentId
|
||||
Object paymentIdObj = request.get("paymentId");
|
||||
if (paymentIdObj != null) {
|
||||
Long paymentId = Long.valueOf(paymentIdObj.toString());
|
||||
logger.info("使用已有的支付记录,paymentId: {}", paymentId);
|
||||
|
||||
Optional<Payment> existingPayment = paymentService.findById(paymentId);
|
||||
if (existingPayment.isEmpty()) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "支付记录不存在");
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
payment = existingPayment.get();
|
||||
} else {
|
||||
// 旧模式:创建新的支付记录
|
||||
String username = (String) request.get("username");
|
||||
String orderId = (String) request.get("orderId");
|
||||
String amount = request.get("amount") != null ? request.get("amount").toString() : null;
|
||||
String method = (String) request.get("method");
|
||||
|
||||
payment = paymentService.createPayment(username, orderId, amount, method);
|
||||
}
|
||||
|
||||
// 调用 PayPal API 创建支付
|
||||
String paypalUrl = null;
|
||||
String paypalPaymentId = null;
|
||||
|
||||
if (payPalService != null) {
|
||||
try {
|
||||
Map<String, Object> paypalResult = payPalService.createPayment(payment);
|
||||
if (paypalResult.containsKey("paymentUrl")) {
|
||||
paypalUrl = paypalResult.get("paymentUrl").toString();
|
||||
}
|
||||
if (paypalResult.containsKey("paypalPaymentId")) {
|
||||
paypalPaymentId = paypalResult.get("paypalPaymentId").toString();
|
||||
}
|
||||
logger.info("PayPal API 返回: url={}, paypalId={}", paypalUrl, paypalPaymentId);
|
||||
} catch (Exception e) {
|
||||
logger.error("调用 PayPal API 失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 PayPal API 未配置或调用失败,返回错误
|
||||
if (paypalUrl == null || paypalUrl.isEmpty()) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "PayPal服务暂不可用,请使用支付宝支付");
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
|
||||
// 保存 PayPal 的 paymentId 到数据库
|
||||
if (paypalPaymentId != null) {
|
||||
payment.setExternalTransactionId(paypalPaymentId);
|
||||
payment.setPaymentUrl(paypalUrl);
|
||||
paymentService.save(payment);
|
||||
logger.info("已保存 PayPal paymentId: {} 到支付记录: {}", paypalPaymentId, payment.getId());
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("paymentId", payment.getId());
|
||||
response.put("paymentUrl", payment.getPaymentUrl());
|
||||
response.put("externalTransactionId", payment.getExternalTransactionId());
|
||||
response.put("paymentUrl", paypalUrl);
|
||||
response.put("externalTransactionId", paypalPaymentId);
|
||||
|
||||
logger.info("✅ PayPal支付创建成功: {}", response);
|
||||
|
||||
|
||||
@@ -540,65 +540,7 @@ public class PaymentApiController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/alipay/notify")
|
||||
public String alipayNotify(HttpServletRequest request) {
|
||||
logger.info("========== [API] 收到支付宝回调请求 ==========");
|
||||
logger.info("请求方法: {}", request.getMethod());
|
||||
logger.info("请求URL: {}", request.getRequestURL());
|
||||
logger.info("Content-Type: {}", request.getContentType());
|
||||
try {
|
||||
Map<String, String> params = new java.util.HashMap<>();
|
||||
|
||||
request.getParameterMap().forEach((key, values) -> {
|
||||
if (values != null && values.length > 0) {
|
||||
params.put(key, values[0]);
|
||||
}
|
||||
});
|
||||
|
||||
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||
try {
|
||||
StringBuilder body = new StringBuilder();
|
||||
try (java.io.BufferedReader reader = request.getReader()) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
body.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (body.length() > 0) {
|
||||
String bodyStr = body.toString();
|
||||
if (bodyStr.contains("=")) {
|
||||
String[] pairs = bodyStr.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=", 2);
|
||||
if (keyValue.length == 2) {
|
||||
try {
|
||||
params.put(
|
||||
java.net.URLDecoder.decode(keyValue[0], "UTF-8"),
|
||||
java.net.URLDecoder.decode(keyValue[1], "UTF-8")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.warn("解析参数失败: {}", pair, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("读取请求体失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("解析到的参数: {}", params);
|
||||
boolean success = alipayService.handleNotify(params);
|
||||
logger.info("处理结果: {}", success ? "success" : "fail");
|
||||
logger.info("========== [API] 支付宝回调处理完成 ==========");
|
||||
return success ? "success" : "fail";
|
||||
} catch (Exception e) {
|
||||
logger.error("========== [API] 处理支付宝异步通知失败 ==========", e);
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
// 注意:支付宝异步通知接口已移至 AlipayCallbackController,避免路由冲突
|
||||
|
||||
/**
|
||||
* 创建支付宝支付
|
||||
@@ -657,7 +599,7 @@ public class PaymentApiController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以删除
|
||||
if (!"ROLE_ADMIN".equals(user.getRole())) {
|
||||
if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(createErrorResponse("无权限删除支付记录"));
|
||||
}
|
||||
@@ -688,7 +630,7 @@ public class PaymentApiController {
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
// 只有管理员可以删除
|
||||
if (!"ROLE_ADMIN".equals(user.getRole())) {
|
||||
if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_SUPER_ADMIN".equals(user.getRole())) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(createErrorResponse("无权限批量删除支付记录"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 用于兼容前端直接请求 /api-management 的占位接口,
|
||||
* 避免被 GlobalExceptionHandler 记录 404 日志。
|
||||
*/
|
||||
@RestController
|
||||
public class SpaForwardController {
|
||||
|
||||
@GetMapping("/api-management")
|
||||
public ResponseEntity<Void> apiManagementPlaceholder() {
|
||||
// 不返回任何内容,仅表示后端该路径存在
|
||||
return ResponseEntity.noContent().build(); // HTTP 204
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ public class StoryboardVideoApiController {
|
||||
.body(Map.of("success", false, "message", "无权访问此任务"));
|
||||
}
|
||||
|
||||
// 状态同步已通过数据库触发器实现,无需代码检查
|
||||
|
||||
Map<String, Object> taskData = new HashMap<>();
|
||||
taskData.put("taskId", task.getTaskId());
|
||||
taskData.put("status", task.getStatus());
|
||||
|
||||
@@ -216,6 +216,8 @@ public class TextToVideoApiController {
|
||||
return ResponseEntity.status(404).body(response);
|
||||
}
|
||||
|
||||
// 状态同步已通过数据库触发器实现,无需代码检查
|
||||
|
||||
Map<String, Object> statusData = new HashMap<>();
|
||||
statusData.put("taskId", task.getTaskId());
|
||||
statusData.put("status", task.getStatus());
|
||||
|
||||
@@ -85,13 +85,15 @@ public class UserWorkApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的作品列表(只返回有resultUrl的作品)
|
||||
* 获取我的作品列表
|
||||
* @param includeProcessing 是否包含正在排队和生成中的作品,默认为true
|
||||
*/
|
||||
@GetMapping("/my-works")
|
||||
public ResponseEntity<Map<String, Object>> getMyWorks(
|
||||
@RequestHeader("Authorization") String token,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "1000") int size) {
|
||||
@RequestParam(defaultValue = "1000") int size,
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -107,7 +109,13 @@ public class UserWorkApiController {
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0) size = 1000; // 不设上限,默认1000条
|
||||
|
||||
Page<UserWork> works = userWorkService.getUserWorks(username, page, size);
|
||||
// 根据参数决定是否包含正在进行中的作品
|
||||
Page<UserWork> works;
|
||||
if (includeProcessing) {
|
||||
works = userWorkService.getAllUserWorks(username, page, size);
|
||||
} else {
|
||||
works = userWorkService.getUserWorks(username, page, size);
|
||||
}
|
||||
Map<String, Object> workStats = userWorkService.getUserWorkStats(username);
|
||||
|
||||
// 调试日志:检查返回的作品数据
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.demo.filter;
|
||||
|
||||
import com.example.demo.service.OnlineStatsService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 访客追踪过滤器
|
||||
* 记录每个请求的IP地址用于统计在线人数
|
||||
*/
|
||||
@Component
|
||||
public class VisitorTrackingFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired
|
||||
private OnlineStatsService onlineStatsService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// 获取真实IP地址(考虑代理情况)
|
||||
String ip = getClientIP(request);
|
||||
|
||||
// 记录访问
|
||||
if (ip != null && !ip.isEmpty()) {
|
||||
onlineStatsService.recordVisit(ip);
|
||||
}
|
||||
|
||||
// 继续过滤链
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
* 考虑代理服务器的情况
|
||||
*/
|
||||
private String getClientIP(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// 如果是多个代理,取第一个IP
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.example.demo.interceptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -10,7 +8,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.service.UserService;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -51,14 +48,11 @@ public class UserActivityInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 异步更新用户活跃时间
|
||||
* 使用专门的方法,不触发缓存清除
|
||||
*/
|
||||
private void updateUserActiveTimeAsync(String username) {
|
||||
try {
|
||||
User user = userService.findByUsernameOrNull(username);
|
||||
if (user != null) {
|
||||
user.setLastActiveTime(LocalDateTime.now());
|
||||
userService.save(user);
|
||||
}
|
||||
userService.updateLastActiveTime(username);
|
||||
} catch (Exception e) {
|
||||
// 忽略更新失败
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ public interface ImageToVideoTaskRepository extends JpaRepository<ImageToVideoTa
|
||||
*/
|
||||
long countByUsername(String username);
|
||||
|
||||
/**
|
||||
* 统计用户进行中的任务数量(PENDING 或 PROCESSING)
|
||||
*/
|
||||
@Query("SELECT COUNT(t) FROM ImageToVideoTask t WHERE t.username = :username AND t.status IN ('PENDING', 'PROCESSING')")
|
||||
long countProcessingTasksByUsername(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 根据状态查找任务列表
|
||||
*/
|
||||
|
||||
@@ -90,4 +90,16 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
*/
|
||||
@Query("SELECT COALESCE(SUM(p.amount), 0) FROM Payment p WHERE p.status = :status AND p.paidAt BETWEEN :startTime AND :endTime")
|
||||
java.math.BigDecimal sumAmountByStatusAndPaidAtBetween(@Param("status") PaymentStatus status, @Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计指定时间范围内成功支付的不同用户数
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT p.user.id) FROM Payment p WHERE p.status = :status AND p.paidAt BETWEEN :startTime AND :endTime")
|
||||
long countDistinctUsersByStatusAndPaidAtBetween(@Param("status") PaymentStatus status, @Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计指定时间之前成功支付的不同用户数(用于累计统计)
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT p.user.id) FROM Payment p WHERE p.status = :status AND p.paidAt < :beforeTime")
|
||||
long countDistinctUsersByStatusAndPaidAtBefore(@Param("status") PaymentStatus status, @Param("beforeTime") java.time.LocalDateTime beforeTime);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ public interface TextToVideoTaskRepository extends JpaRepository<TextToVideoTask
|
||||
*/
|
||||
long countByUsername(String username);
|
||||
|
||||
/**
|
||||
* 统计用户进行中的任务数量(PENDING 或 PROCESSING)
|
||||
*/
|
||||
@Query("SELECT COUNT(t) FROM TextToVideoTask t WHERE t.username = :username AND t.status IN ('PENDING', 'PROCESSING')")
|
||||
long countProcessingTasksByUsername(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 根据状态查找任务列表
|
||||
*/
|
||||
|
||||
@@ -15,4 +15,12 @@ public interface UserMembershipRepository extends JpaRepository<UserMembership,
|
||||
long countByStatus(String status);
|
||||
|
||||
long countByStartDateBetween(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* 统计指定时间之前开始的会员数(用于累计统计)
|
||||
*/
|
||||
long countByStartDateBefore(LocalDateTime beforeTime);
|
||||
|
||||
// 根据用户ID删除会员信息
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,16 @@ package com.example.demo.repository;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
// 分页查询活跃用户(isActive=true)
|
||||
Page<User> findByIsActive(Boolean isActive, Pageable pageable);
|
||||
Optional<User> findByUsername(String username);
|
||||
Optional<User> findByNickname(String nickname);
|
||||
Optional<User> findByEmail(String email);
|
||||
@@ -28,6 +33,11 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
* 统计今日新增用户数
|
||||
*/
|
||||
long countByCreatedAtAfter(LocalDateTime startTime);
|
||||
|
||||
/**
|
||||
* 统计指定时间之前创建的用户数(用于累计统计)
|
||||
*/
|
||||
long countByCreatedAtBefore(LocalDateTime beforeTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
/**
|
||||
* 根据用户名查找作品
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED' ORDER BY uw.createdAt DESC")
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
||||
|
||||
/**
|
||||
@@ -41,10 +41,9 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
|
||||
/**
|
||||
* 根据用户名查找正在进行中和排队中的作品
|
||||
* 增加时间限制:只返回最近24小时内的任务,避免返回陈旧的僵尸任务
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') AND uw.createdAt > :afterTime ORDER BY uw.createdAt DESC")
|
||||
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username, @Param("afterTime") LocalDateTime afterTime);
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') ORDER BY uw.createdAt DESC")
|
||||
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 查找所有PROCESSING状态的作品(用于系统重启清理)
|
||||
@@ -98,6 +97,13 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
@Query("SELECT COUNT(uw) FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED'")
|
||||
long countByUsername(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 统计用户进行中的任务数量(所有类型:文生视频、图生视频、分镜视频等)
|
||||
* 包括 PROCESSING 和 PENDING 状态
|
||||
*/
|
||||
@Query("SELECT COUNT(uw) FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING')")
|
||||
long countProcessingTasksByUsername(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 统计用户公开作品数量
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.example.demo.security;
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
/**
|
||||
* 明文密码编码器(仅用于测试,生产环境不要使用)
|
||||
* 注意:已移除 @Component 注解,避免与 BCryptPasswordEncoder 冲突
|
||||
*/
|
||||
public class PlainTextPasswordEncoder implements PasswordEncoder {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -133,7 +133,16 @@ public class AlipayService {
|
||||
// 设置业务参数
|
||||
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
|
||||
model.setOutTradeNo(payment.getOrderId());
|
||||
model.setTotalAmount(payment.getAmount().toString());
|
||||
|
||||
// 如果是美元,按汇率转换为人民币(支付宝只支持CNY)
|
||||
java.math.BigDecimal amount = payment.getAmount();
|
||||
String currency = payment.getCurrency();
|
||||
if ("USD".equalsIgnoreCase(currency)) {
|
||||
java.math.BigDecimal exchangeRate = new java.math.BigDecimal("7.2"); // USD -> CNY 汇率
|
||||
amount = amount.multiply(exchangeRate).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||
logger.info("货币转换: {} USD -> {} CNY (汇率: 7.2)", payment.getAmount(), amount);
|
||||
}
|
||||
model.setTotalAmount(amount.toString());
|
||||
model.setSubject(payment.getDescription() != null ? payment.getDescription() : "AIGC会员订阅");
|
||||
model.setBody(payment.getDescription() != null ? payment.getDescription() : "AIGC平台会员订阅服务");
|
||||
model.setTimeoutExpress("5m");
|
||||
@@ -201,6 +210,21 @@ public class AlipayService {
|
||||
} else {
|
||||
String subCode = (String) precreateResponse.get("sub_code");
|
||||
String subMsg = (String) precreateResponse.get("sub_msg");
|
||||
|
||||
// 如果交易已经成功,不需要再生成二维码
|
||||
if ("ACQ.TRADE_HAS_SUCCESS".equals(subCode)) {
|
||||
logger.info("交易已成功支付,订单号:{},无需再生成二维码", payment.getOrderId());
|
||||
// 更新支付状态为成功
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
paymentRepository.save(payment);
|
||||
// 返回已支付成功的信息
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("alreadyPaid", true);
|
||||
result.put("message", "该订单已支付成功");
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -11,12 +11,9 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -156,14 +153,12 @@ public class CosService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片字节数组到COS(存储在images目录下)
|
||||
* 上传图片字节数组到COS(存储在配置的目录下)
|
||||
*/
|
||||
private String uploadImageBytes(byte[] bytes, String filename, String contentType) {
|
||||
try {
|
||||
// 构建对象键(图片使用 images 目录)
|
||||
LocalDate now = LocalDate.now();
|
||||
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
String key = "images/" + datePath + "/" + filename;
|
||||
// 添加前缀目录
|
||||
String key = buildObjectKey(filename);
|
||||
|
||||
// 设置元数据
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
@@ -282,13 +277,18 @@ public class CosService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成对象键(带日期目录结构)
|
||||
* 例如: videos/2025/01/14/uuid.mp4
|
||||
* 生成对象键(添加配置的前缀目录)
|
||||
*/
|
||||
private String buildObjectKey(String filename) {
|
||||
LocalDate now = LocalDate.now();
|
||||
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
return "videos/" + datePath + "/" + filename;
|
||||
String prefix = cosConfig.getPrefix();
|
||||
if (prefix != null && !prefix.isEmpty()) {
|
||||
// 确保前缀以 / 结尾
|
||||
if (!prefix.endsWith("/")) {
|
||||
prefix = prefix + "/";
|
||||
}
|
||||
return prefix + filename;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.example.demo.service;
|
||||
|
||||
import com.example.demo.model.Order;
|
||||
import com.example.demo.model.OrderStatus;
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.repository.OrderRepository;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
@@ -39,8 +38,8 @@ public class DashboardService {
|
||||
overview.put("totalUsers", totalUsers);
|
||||
|
||||
// 付费用户数(有成功支付记录的不同用户数)- 优化:直接在数据库层面统计
|
||||
long payingUsers = paymentRepository.countDistinctUsersByStatus(PaymentStatus.SUCCESS);
|
||||
overview.put("payingUsers", payingUsers);
|
||||
long paidUsers = paymentRepository.countDistinctUsersByStatus(PaymentStatus.SUCCESS);
|
||||
overview.put("paidUsers", paidUsers);
|
||||
|
||||
// 今日收入(今日成功支付的金额)- 优化:直接在数据库层面求和
|
||||
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
|
||||
@@ -48,16 +47,54 @@ public class DashboardService {
|
||||
|
||||
BigDecimal todayRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
|
||||
PaymentStatus.SUCCESS, todayStart, todayEnd);
|
||||
overview.put("todayRevenue", todayRevenue);
|
||||
overview.put("todayRevenue", todayRevenue != null ? todayRevenue : BigDecimal.ZERO);
|
||||
|
||||
// 转化率(付费用户数 / 总用户数)
|
||||
double conversionRate = totalUsers > 0 ? (double) payingUsers / totalUsers * 100 : 0;
|
||||
double conversionRate = totalUsers > 0 ? (double) paidUsers / totalUsers * 100 : 0;
|
||||
overview.put("conversionRate", Math.round(conversionRate * 100.0) / 100.0);
|
||||
|
||||
// 今日新增用户 - 优化:直接在数据库层面统计
|
||||
long todayNewUsers = userRepository.countByCreatedAtBetween(todayStart, todayEnd);
|
||||
overview.put("todayNewUsers", todayNewUsers);
|
||||
|
||||
// ===== 计算环比变化(当前值 vs 上月底值)=====
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
// 上月底时间点
|
||||
LocalDate lastMonthEnd = today.withDayOfMonth(1).minusDays(1);
|
||||
LocalDateTime lastMonthEndTime = lastMonthEnd.atTime(23, 59, 59);
|
||||
|
||||
// 用户总数环比:当前总用户数 vs 上月底总用户数
|
||||
long lastMonthTotalUsers = userRepository.countByCreatedAtBefore(lastMonthEndTime.plusSeconds(1));
|
||||
double totalUsersChange = lastMonthTotalUsers > 0 ?
|
||||
((double) totalUsers - lastMonthTotalUsers) / lastMonthTotalUsers * 100 :
|
||||
(totalUsers > 0 ? 100 : 0);
|
||||
overview.put("totalUsersChange", Math.round(totalUsersChange * 10.0) / 10.0);
|
||||
|
||||
// 付费用户环比:当前付费用户数 vs 上月底付费用户数
|
||||
long lastMonthPaidUsers = paymentRepository.countDistinctUsersByStatusAndPaidAtBefore(
|
||||
PaymentStatus.SUCCESS, lastMonthEndTime.plusSeconds(1));
|
||||
double paidUsersChange = lastMonthPaidUsers > 0 ?
|
||||
((double) paidUsers - lastMonthPaidUsers) / lastMonthPaidUsers * 100 :
|
||||
(paidUsers > 0 ? 100 : 0);
|
||||
overview.put("paidUsersChange", Math.round(paidUsersChange * 10.0) / 10.0);
|
||||
|
||||
// 昨日收入(用于对比今日收入)
|
||||
LocalDateTime yesterdayStart = today.minusDays(1).atStartOfDay();
|
||||
LocalDateTime yesterdayEnd = today.minusDays(1).atTime(23, 59, 59);
|
||||
BigDecimal yesterdayRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
|
||||
PaymentStatus.SUCCESS, yesterdayStart, yesterdayEnd);
|
||||
if (yesterdayRevenue == null) yesterdayRevenue = BigDecimal.ZERO;
|
||||
BigDecimal todayRevenueValue = todayRevenue != null ? todayRevenue : BigDecimal.ZERO;
|
||||
|
||||
// 收入变化率
|
||||
double todayRevenueChange = yesterdayRevenue.compareTo(BigDecimal.ZERO) > 0 ?
|
||||
todayRevenueValue.subtract(yesterdayRevenue)
|
||||
.divide(yesterdayRevenue, 4, java.math.RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100)).doubleValue() :
|
||||
(todayRevenueValue.compareTo(BigDecimal.ZERO) > 0 ? 100 : 0);
|
||||
overview.put("todayRevenueChange", Math.round(todayRevenueChange * 10.0) / 10.0);
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +205,151 @@ public class ImageGridService {
|
||||
return compressedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接分镜图:将用户上传的原图和AI生成的分镜图拼接在一起
|
||||
*
|
||||
* 规则:
|
||||
* - 如果原图是16:9(横向),创建9:16的画布,原图在左,生成图在右
|
||||
* - 如果原图是9:16(纵向),创建16:9的画布,原图在上,生成图在下
|
||||
*
|
||||
* @param originalImageUrl 用户上传的原图URL或Base64
|
||||
* @param generatedImageUrl AI生成的分镜图URL或Base64
|
||||
* @return 拼接后的图片Base64
|
||||
*/
|
||||
public String mergeStoryboardImages(String originalImageUrl, String generatedImageUrl) {
|
||||
if (originalImageUrl == null || originalImageUrl.isEmpty()) {
|
||||
throw new IllegalArgumentException("原图不能为空");
|
||||
}
|
||||
if (generatedImageUrl == null || generatedImageUrl.isEmpty()) {
|
||||
throw new IllegalArgumentException("生成图不能为空");
|
||||
}
|
||||
|
||||
try {
|
||||
// 加载原图和生成图
|
||||
BufferedImage originalImage = loadImageFromUrl(originalImageUrl);
|
||||
BufferedImage generatedImage = loadImageFromUrl(generatedImageUrl);
|
||||
|
||||
if (originalImage == null) {
|
||||
throw new RuntimeException("无法加载原图");
|
||||
}
|
||||
if (generatedImage == null) {
|
||||
throw new RuntimeException("无法加载生成图");
|
||||
}
|
||||
|
||||
int origWidth = originalImage.getWidth();
|
||||
int origHeight = originalImage.getHeight();
|
||||
double aspectRatio = (double) origWidth / origHeight;
|
||||
|
||||
logger.info("分镜图拼接: 原图尺寸={}x{}, 宽高比={:.2f}, 生成图尺寸={}x{}",
|
||||
origWidth, origHeight, aspectRatio,
|
||||
generatedImage.getWidth(), generatedImage.getHeight());
|
||||
|
||||
BufferedImage resultImage;
|
||||
Graphics2D g;
|
||||
|
||||
// 判断原图是横向(16:9)还是纵向(9:16)
|
||||
if (aspectRatio >= 1.0) {
|
||||
// 横向图片(16:9或更宽):创建9:16的画布,左右拼接
|
||||
// 画布宽度 = 原图宽度,画布高度 = 画布宽度 * 16 / 9
|
||||
int canvasWidth = origWidth;
|
||||
int canvasHeight = (int) (canvasWidth * 16.0 / 9.0);
|
||||
|
||||
// 每张图片占画布宽度的一半
|
||||
int halfWidth = canvasWidth / 2;
|
||||
int imageHeight = canvasHeight;
|
||||
|
||||
resultImage = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_RGB);
|
||||
g = resultImage.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
|
||||
// 白色背景
|
||||
g.setColor(java.awt.Color.WHITE);
|
||||
g.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 左侧放原图(缩放并居中)
|
||||
drawImageCentered(g, originalImage, 0, 0, halfWidth, imageHeight);
|
||||
|
||||
// 右侧放生成图(缩放并居中)
|
||||
drawImageCentered(g, generatedImage, halfWidth, 0, halfWidth, imageHeight);
|
||||
|
||||
logger.info("横向拼接完成: 画布={}x{}, 每张图={}x{}", canvasWidth, canvasHeight, halfWidth, imageHeight);
|
||||
|
||||
} else {
|
||||
// 纵向图片(9:16或更窄):创建16:9的画布,上下拼接
|
||||
// 画布高度 = 原图高度,画布宽度 = 画布高度 * 16 / 9
|
||||
int canvasHeight = origHeight;
|
||||
int canvasWidth = (int) (canvasHeight * 16.0 / 9.0);
|
||||
|
||||
// 每张图片占画布高度的一半
|
||||
int halfHeight = canvasHeight / 2;
|
||||
int imageWidth = canvasWidth;
|
||||
|
||||
resultImage = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_RGB);
|
||||
g = resultImage.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
|
||||
// 白色背景
|
||||
g.setColor(java.awt.Color.WHITE);
|
||||
g.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 上侧放原图(缩放并居中)
|
||||
drawImageCentered(g, originalImage, 0, 0, imageWidth, halfHeight);
|
||||
|
||||
// 下侧放生成图(缩放并居中)
|
||||
drawImageCentered(g, generatedImage, 0, halfHeight, imageWidth, halfHeight);
|
||||
|
||||
logger.info("纵向拼接完成: 画布={}x{}, 每张图={}x{}", canvasWidth, canvasHeight, imageWidth, halfHeight);
|
||||
}
|
||||
|
||||
g.dispose();
|
||||
|
||||
// 压缩并转换为Base64
|
||||
BufferedImage compressedImage = compressGridImage(resultImage, 2048);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
javax.imageio.ImageWriter writer = javax.imageio.ImageIO.getImageWritersByFormatName("jpg").next();
|
||||
javax.imageio.ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
if (param.canWriteCompressed()) {
|
||||
param.setCompressionMode(javax.imageio.ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(0.85f);
|
||||
}
|
||||
javax.imageio.IIOImage iioImage = new javax.imageio.IIOImage(compressedImage, null, null);
|
||||
writer.setOutput(javax.imageio.ImageIO.createImageOutputStream(baos));
|
||||
writer.write(null, iioImage, param);
|
||||
writer.dispose();
|
||||
|
||||
byte[] imageBytes = baos.toByteArray();
|
||||
String base64 = Base64.getEncoder().encodeToString(imageBytes);
|
||||
|
||||
logger.info("分镜图拼接完成: 最终尺寸={}x{}, 大小={} KB",
|
||||
compressedImage.getWidth(), compressedImage.getHeight(), imageBytes.length / 1024);
|
||||
|
||||
return "data:image/jpeg;base64," + base64;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("拼接分镜图失败", e);
|
||||
throw new RuntimeException("分镜图拼接失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定区域内居中绘制图片(保持比例)
|
||||
*/
|
||||
private void drawImageCentered(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
|
||||
double scaleX = (double) width / img.getWidth();
|
||||
double scaleY = (double) height / img.getHeight();
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
|
||||
int scaledWidth = (int) (img.getWidth() * scale);
|
||||
int scaledHeight = (int) (img.getHeight() * scale);
|
||||
int imgX = x + (width - scaledWidth) / 2;
|
||||
int imgY = y + (height - scaledHeight) / 2;
|
||||
|
||||
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL加载图片
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,6 @@ public class ImageToVideoService {
|
||||
@Value("${app.video.output.path:/outputs}")
|
||||
private String outputPath;
|
||||
|
||||
|
||||
/**
|
||||
* 创建图生视频任务
|
||||
*/
|
||||
@@ -68,6 +67,9 @@ public class ImageToVideoService {
|
||||
String aspectRatio, int duration, boolean hdMode) {
|
||||
|
||||
try {
|
||||
// 检查用户所有类型任务的总数(统一检查)
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
@@ -338,6 +340,16 @@ public class ImageToVideoService {
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 同步更新 UserWork 表的状态和结果URL
|
||||
try {
|
||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
logger.info("图生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
logger.info("图生视频任务完成: {}", task.getTaskId());
|
||||
return;
|
||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 在线统计服务
|
||||
* 通过IP地址统计当天访问的独立用户数
|
||||
*/
|
||||
@Service
|
||||
public class OnlineStatsService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OnlineStatsService.class);
|
||||
|
||||
// 存储当天访问过的IP地址(使用Set去重)
|
||||
private final Set<String> todayVisitors = ConcurrentHashMap.newKeySet();
|
||||
|
||||
// 记录当前日期,用于判断是否需要重置
|
||||
private volatile LocalDate currentDate = LocalDate.now();
|
||||
|
||||
// 系统启动时间
|
||||
private final LocalDateTime startTime = LocalDateTime.now();
|
||||
|
||||
/**
|
||||
* 记录访问IP
|
||||
* @param ip 访问者IP地址
|
||||
*/
|
||||
public void recordVisit(String ip) {
|
||||
if (ip == null || ip.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要重置(新的一天)
|
||||
checkAndResetIfNewDay();
|
||||
|
||||
// 添加IP到今日访客列表
|
||||
todayVisitors.add(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当天在线人数(独立IP数)
|
||||
* @return 当天访问的独立IP数量
|
||||
*/
|
||||
public int getTodayVisitorCount() {
|
||||
checkAndResetIfNewDay();
|
||||
return todayVisitors.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是新的一天,如果是则重置统计
|
||||
*/
|
||||
private void checkAndResetIfNewDay() {
|
||||
LocalDate today = LocalDate.now();
|
||||
if (!today.equals(currentDate)) {
|
||||
synchronized (this) {
|
||||
if (!today.equals(currentDate)) {
|
||||
logger.info("新的一天开始,重置在线统计。昨日访客数: {}", todayVisitors.size());
|
||||
todayVisitors.clear();
|
||||
currentDate = today;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每天凌晨0点重置统计
|
||||
*/
|
||||
@Scheduled(cron = "0 0 0 * * ?")
|
||||
public void resetDailyStats() {
|
||||
logger.info("定时任务:重置每日在线统计。昨日访客数: {}", todayVisitors.size());
|
||||
todayVisitors.clear();
|
||||
currentDate = LocalDate.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统运行时间(格式化为 X小时X分)
|
||||
* @return 运行时间字符串
|
||||
*/
|
||||
public String getUptime() {
|
||||
Duration duration = Duration.between(startTime, LocalDateTime.now());
|
||||
long hours = duration.toHours();
|
||||
long minutes = duration.toMinutes() % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + "小时" + minutes + "分";
|
||||
} else {
|
||||
return minutes + "分钟";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
* @return 包含统计数据的Map
|
||||
*/
|
||||
public Map<String, Object> getStats() {
|
||||
checkAndResetIfNewDay();
|
||||
Map<String, Object> stats = new ConcurrentHashMap<>();
|
||||
stats.put("todayVisitors", todayVisitors.size());
|
||||
stats.put("date", currentDate.toString());
|
||||
stats.put("uptime", getUptime());
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -394,18 +394,13 @@ public class OrderService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除订单(软删除,仅管理员可操作)
|
||||
* 删除订单(仅管理员可操作)
|
||||
*/
|
||||
public void deleteOrder(Long orderId) {
|
||||
try {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在:" + orderId));
|
||||
|
||||
// 只有已取消或已退款的订单才能删除
|
||||
if (order.getStatus() != OrderStatus.CANCELLED && order.getStatus() != OrderStatus.REFUNDED) {
|
||||
throw new RuntimeException("只有已取消或已退款的订单才能删除");
|
||||
}
|
||||
|
||||
orderRepository.delete(order);
|
||||
logger.info("订单删除成功,订单号:{}", order.getOrderNumber());
|
||||
|
||||
|
||||
@@ -13,518 +13,242 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.example.demo.model.Order;
|
||||
import com.example.demo.model.OrderItem;
|
||||
import com.example.demo.model.OrderStatus;
|
||||
import com.example.demo.model.OrderType;
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.*;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class PaymentService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(PaymentService.class);
|
||||
@Autowired private PaymentRepository paymentRepository;
|
||||
@Autowired private OrderService orderService;
|
||||
@Autowired private UserService userService;
|
||||
@Autowired private AlipayService alipayService;
|
||||
@Autowired(required = false) private PayPalService payPalService;
|
||||
|
||||
@Autowired
|
||||
private PaymentRepository paymentRepository;
|
||||
public Payment save(Payment payment) { return paymentRepository.save(payment); }
|
||||
@Transactional(readOnly = true) public Optional<Payment> findById(Long id) { return paymentRepository.findByIdWithUser(id); }
|
||||
@Transactional(readOnly = true) public Optional<Payment> findByOrderId(String orderId) { return paymentRepository.findByOrderId(orderId); }
|
||||
@Transactional(readOnly = true) public Optional<Payment> findByExternalTransactionId(String id) { return paymentRepository.findByExternalTransactionId(id); }
|
||||
@Transactional(readOnly = true) public List<Payment> findByUserId(Long userId) { return paymentRepository.findByUserIdOrderByCreatedAtDesc(userId); }
|
||||
@Transactional(readOnly = true) public List<Payment> findAll() { return paymentRepository.findAll(); }
|
||||
@Transactional(readOnly = true) public List<Payment> findByStatus(PaymentStatus status) { return paymentRepository.findByStatus(status); }
|
||||
@Transactional(readOnly = true) public long countByUserId(Long userId) { return paymentRepository.countByUserId(userId); }
|
||||
@Transactional(readOnly = true) public long countByStatus(PaymentStatus status) { return paymentRepository.countByStatus(status); }
|
||||
@Transactional(readOnly = true) public long countByUserIdAndStatus(Long userId, PaymentStatus status) { return paymentRepository.countByUserIdAndStatus(userId, status); }
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private AlipayService alipayService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private PayPalService payPalService;
|
||||
|
||||
/**
|
||||
* 保存支付记录
|
||||
*/
|
||||
public Payment save(Payment payment) {
|
||||
try {
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
logger.info("支付记录保存成功,支付ID:{}", savedPayment.getId());
|
||||
return savedPayment;
|
||||
} catch (Exception e) {
|
||||
logger.error("保存支付记录失败:", e);
|
||||
throw new RuntimeException("保存支付记录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查找支付记录(包含User信息,避免LazyInitializationException)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Payment> findById(Long id) {
|
||||
return paymentRepository.findByIdWithUser(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单ID查找支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Payment> findByOrderId(String orderId) {
|
||||
return paymentRepository.findByOrderId(orderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据外部交易ID查找支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Payment> findByExternalTransactionId(String externalTransactionId) {
|
||||
return paymentRepository.findByExternalTransactionId(externalTransactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查找支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Payment> findByUserId(Long userId) {
|
||||
return paymentRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找所有支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Payment> findAll() {
|
||||
return paymentRepository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据状态查找支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Payment> findByStatus(PaymentStatus status) {
|
||||
return paymentRepository.findByStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付状态
|
||||
*/
|
||||
public Payment updatePaymentStatus(Long paymentId, PaymentStatus newStatus) {
|
||||
try {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
|
||||
|
||||
PaymentStatus oldStatus = payment.getStatus();
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
payment.setStatus(newStatus);
|
||||
|
||||
if (newStatus == PaymentStatus.SUCCESS) {
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
|
||||
// 更新关联订单状态
|
||||
if (payment.getOrder() != null) {
|
||||
orderService.confirmPayment(payment.getOrder().getId(), payment.getExternalTransactionId());
|
||||
}
|
||||
if (newStatus == PaymentStatus.SUCCESS) payment.setPaidAt(LocalDateTime.now());
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
Payment updatedPayment = paymentRepository.save(payment);
|
||||
logger.info("支付状态更新成功,支付ID:{},状态:{} -> {}",
|
||||
paymentId, oldStatus, newStatus);
|
||||
|
||||
return updatedPayment;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("更新支付状态失败:", e);
|
||||
throw new RuntimeException("更新支付状态失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认支付成功
|
||||
*/
|
||||
public Payment confirmPaymentSuccess(Long paymentId, String externalTransactionId) {
|
||||
try {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
|
||||
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
logger.info("支付记录已经是成功状态,跳过重复处理: paymentId={}", paymentId);
|
||||
return payment;
|
||||
}
|
||||
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
|
||||
Payment confirmedPayment = paymentRepository.save(payment);
|
||||
// 支付成功后创建订单
|
||||
try {
|
||||
createOrderForPayment(savedPayment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但创建订单失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 更新关联订单状态
|
||||
// 支付成功后增加用户积分
|
||||
try {
|
||||
addPointsForPayment(savedPayment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但增加积分失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
return savedPayment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为支付成功的记录创建订单
|
||||
*/
|
||||
private void createOrderForPayment(Payment payment) {
|
||||
if (payment == null || payment.getUser() == null) {
|
||||
logger.warn("无法创建订单: payment或user为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经关联了订单
|
||||
if (payment.getOrder() != null) {
|
||||
orderService.confirmPayment(payment.getOrder().getId(), externalTransactionId);
|
||||
} else {
|
||||
// 如果没有关联订单,自动创建一个订单
|
||||
createOrderFromPayment(confirmedPayment);
|
||||
logger.info("支付记录已关联订单,跳过创建: paymentId={}, orderId={}", payment.getId(), payment.getOrder().getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据支付金额增加积分
|
||||
addPointsForPayment(confirmedPayment);
|
||||
try {
|
||||
// 创建订单
|
||||
Order order = new Order();
|
||||
order.setUser(payment.getUser());
|
||||
order.setOrderNumber("ORD" + System.currentTimeMillis() + payment.getId());
|
||||
order.setTotalAmount(payment.getAmount());
|
||||
order.setCurrency(payment.getCurrency() != null ? payment.getCurrency() : "CNY");
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
order.setNotes(payment.getDescription() != null ? payment.getDescription() : "会员订阅");
|
||||
|
||||
logger.info("支付确认成功,支付ID:{},外部交易ID:{}", paymentId, externalTransactionId);
|
||||
return confirmedPayment;
|
||||
// 根据金额设置订单描述
|
||||
BigDecimal amount = payment.getAmount();
|
||||
if (amount != null) {
|
||||
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
|
||||
order.setNotes("专业版会员订阅 - " + amount + "元");
|
||||
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
|
||||
order.setNotes("标准版会员订阅 - " + amount + "元");
|
||||
}
|
||||
}
|
||||
|
||||
Order savedOrder = orderService.createOrder(order);
|
||||
|
||||
// 关联支付记录和订单
|
||||
payment.setOrder(savedOrder);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
logger.info("✅ 订单创建成功: orderId={}, orderNumber={}, paymentId={}",
|
||||
savedOrder.getId(), savedOrder.getOrderNumber(), payment.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("确认支付成功失败:", e);
|
||||
throw new RuntimeException("确认支付成功失败:" + e.getMessage());
|
||||
logger.error("创建订单失败: paymentId={}, error={}", payment.getId(), e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认支付失败
|
||||
* 根据支付金额增加用户积分
|
||||
* 标准版(59元) -> 200积分
|
||||
* 专业版(259元) -> 1000积分
|
||||
*/
|
||||
private void addPointsForPayment(Payment payment) {
|
||||
if (payment == null || payment.getUser() == null) {
|
||||
logger.warn("无法增加积分: payment或user为空");
|
||||
return;
|
||||
}
|
||||
|
||||
java.math.BigDecimal amount = payment.getAmount();
|
||||
if (amount == null) {
|
||||
logger.warn("无法增加积分: 支付金额为空, paymentId={}", payment.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据金额计算积分
|
||||
int points = 0;
|
||||
String planName = "";
|
||||
|
||||
// 专业版订阅 (259元以上) -> 1000积分
|
||||
if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
|
||||
points = 1000;
|
||||
planName = "专业版";
|
||||
}
|
||||
// 标准版订阅 (59-258元) -> 200积分
|
||||
else if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0) {
|
||||
points = 200;
|
||||
planName = "标准版";
|
||||
}
|
||||
// 其他金额不增加积分
|
||||
else {
|
||||
logger.info("支付金额不在套餐范围内,不增加积分: amount={}", amount);
|
||||
return;
|
||||
}
|
||||
|
||||
// 增加积分
|
||||
Long userId = payment.getUser().getId();
|
||||
logger.info("开始为用户增加积分: userId={}, points={}, plan={}, paymentId={}", userId, points, planName, payment.getId());
|
||||
|
||||
userService.addPoints(userId, points);
|
||||
|
||||
logger.info("✅ 积分增加成功: userId={}, addedPoints={}, plan={}", userId, points, planName);
|
||||
}
|
||||
|
||||
public Payment confirmPaymentFailure(Long paymentId, String failureReason) {
|
||||
try {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
|
||||
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
if (failureReason != null && !failureReason.isEmpty()) {
|
||||
payment.setDescription((payment.getDescription() != null ? payment.getDescription() + "\n" : "") +
|
||||
"失败原因:" + failureReason);
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
Payment failedPayment = paymentRepository.save(payment);
|
||||
logger.info("支付确认失败,支付ID:{},失败原因:{}", paymentId, failureReason);
|
||||
|
||||
return failedPayment;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("确认支付失败失败:", e);
|
||||
throw new RuntimeException("确认支付失败失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单支付
|
||||
*/
|
||||
public Payment createOrderPayment(Order order, PaymentMethod paymentMethod) {
|
||||
try {
|
||||
Payment payment = new Payment();
|
||||
payment.setOrderId(order.getOrderNumber());
|
||||
payment.setAmount(order.getTotalAmount());
|
||||
payment.setCurrency(order.getCurrency());
|
||||
payment.setPaymentMethod(paymentMethod);
|
||||
payment.setDescription("订单支付 - " + order.getOrderNumber());
|
||||
payment.setUser(order.getUser());
|
||||
payment.setOrder(order);
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
logger.info("订单支付创建成功,订单号:{},支付ID:{}", order.getOrderNumber(), savedPayment.getId());
|
||||
|
||||
return savedPayment;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建订单支付失败:", e);
|
||||
throw new RuntimeException("创建订单支付失败:" + e.getMessage());
|
||||
}
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计支付记录数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long countByUserId(Long userId) {
|
||||
return paymentRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计指定状态的支付记录数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long countByStatus(PaymentStatus status) {
|
||||
return paymentRepository.countByStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计用户指定状态的支付记录数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long countByUserIdAndStatus(Long userId, PaymentStatus status) {
|
||||
return paymentRepository.countByUserIdAndStatus(userId, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付记录
|
||||
*/
|
||||
public void deletePayment(Long paymentId) {
|
||||
try {
|
||||
Payment payment = paymentRepository.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
|
||||
|
||||
// 只有失败的支付记录才能删除
|
||||
if (payment.getStatus() != PaymentStatus.FAILED) {
|
||||
throw new RuntimeException("只有失败的支付记录才能删除");
|
||||
}
|
||||
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
paymentRepository.delete(payment);
|
||||
logger.info("支付记录删除成功,支付ID:{}", paymentId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("删除支付记录失败:", e);
|
||||
throw new RuntimeException("删除支付记录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户名查找支付记录
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Payment> findByUsername(String username) {
|
||||
try {
|
||||
logger.info("PaymentService: 开始查找用户 {} 的支付记录", username);
|
||||
|
||||
// 先查找用户
|
||||
User user = userService.findByUsername(username);
|
||||
if (user == null) {
|
||||
logger.error("PaymentService: 用户 {} 不存在", username);
|
||||
throw new RuntimeException("用户不存在: " + username);
|
||||
if (user == null) throw new RuntimeException("User not found");
|
||||
return paymentRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
}
|
||||
|
||||
logger.info("PaymentService: 找到用户 {}, ID: {}", username, user.getId());
|
||||
|
||||
// 查找支付记录
|
||||
List<Payment> payments = paymentRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
|
||||
logger.info("PaymentService: 用户 {} 的支付记录数量: {}", username, payments.size());
|
||||
|
||||
return payments;
|
||||
} catch (Exception e) {
|
||||
logger.error("PaymentService: 根据用户名查找支付记录失败,用户名: {}, 错误: {}", username, e.getMessage(), e);
|
||||
throw new RuntimeException("查找支付记录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付
|
||||
*/
|
||||
@Transactional
|
||||
public Payment createPayment(String username, String orderId, String amountStr, String method) {
|
||||
try {
|
||||
logger.info("创建支付 - 用户名: '{}', 订单ID: {}, 金额: {}, 支付方式: {}", username, orderId, amountStr, method);
|
||||
|
||||
User user;
|
||||
try {
|
||||
logger.info("PaymentService - 尝试查找用户: '{}'", username);
|
||||
user = userService.findByUsername(username);
|
||||
logger.info("PaymentService - 用户查找结果: {}", user != null ? "找到用户 '" + user.getUsername() + "'" : "未找到用户");
|
||||
} catch (Exception e) {
|
||||
logger.error("PaymentService - 用户查找异常: {}", username, e);
|
||||
// 如果是匿名用户,创建一个临时用户记录
|
||||
if (username.startsWith("anonymous_")) {
|
||||
user = createAnonymousUser(username);
|
||||
} else {
|
||||
throw new RuntimeException("用户不存在: " + username);
|
||||
// 检查是否已存在相同 orderId 的支付记录
|
||||
Optional<Payment> existing = paymentRepository.findByOrderId(orderId);
|
||||
if (existing.isPresent()) {
|
||||
Payment existingPayment = existing.get();
|
||||
// 如果已存在且状态是 PENDING,直接返回
|
||||
if (existingPayment.getStatus() == PaymentStatus.PENDING) {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
}
|
||||
// 如果是其他状态,生成新的 orderId
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
logger.info("已存在相同orderId但状态为{},生成新orderId: {}", existingPayment.getStatus(), orderId);
|
||||
}
|
||||
|
||||
logger.info("找到用户: {}", user.getUsername());
|
||||
|
||||
BigDecimal amount = new BigDecimal(amountStr);
|
||||
PaymentMethod paymentMethod = PaymentMethod.valueOf(method);
|
||||
|
||||
logger.info("金额: {}, 支付方式: {}", amount, paymentMethod);
|
||||
|
||||
User user = null;
|
||||
if (username != null) { try { user = userService.findByUsername(username); } catch (Exception e) {} }
|
||||
if (user == null) { user = userService.findByUsernameOrNull(username != null ? username : "anon"); if (user == null) user = createAnonymousUser(username != null ? username : "anon"); }
|
||||
Payment payment = new Payment();
|
||||
payment.setUser(user);
|
||||
payment.setOrderId(orderId);
|
||||
payment.setAmount(amount);
|
||||
payment.setCurrency("CNY"); // 设置默认货币为人民币
|
||||
payment.setPaymentMethod(paymentMethod);
|
||||
payment.setAmount(new BigDecimal(amountStr));
|
||||
payment.setCurrency("CNY");
|
||||
payment.setPaymentMethod(PaymentMethod.valueOf(method));
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
payment.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
Payment savedPayment = save(payment);
|
||||
logger.info("支付记录创建成功: {}", savedPayment.getId());
|
||||
|
||||
// 根据支付方式调用相应的支付服务
|
||||
if (paymentMethod == PaymentMethod.ALIPAY) {
|
||||
try {
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(savedPayment);
|
||||
if (paymentResult.containsKey("qrCode")) {
|
||||
savedPayment.setPaymentUrl(paymentResult.get("qrCode").toString());
|
||||
}
|
||||
save(savedPayment);
|
||||
logger.info("支付宝二维码生成成功: {}", paymentResult.get("qrCode"));
|
||||
} catch (Exception e) {
|
||||
logger.error("调用支付宝支付接口失败:", e);
|
||||
// 不抛出异常,让前端处理
|
||||
}
|
||||
} else if (paymentMethod == PaymentMethod.PAYPAL) {
|
||||
try {
|
||||
if (payPalService == null) {
|
||||
throw new RuntimeException("PayPal服务未配置");
|
||||
}
|
||||
Map<String, Object> paymentResult = payPalService.createPayment(savedPayment);
|
||||
if (paymentResult.containsKey("paymentUrl")) {
|
||||
savedPayment.setPaymentUrl(paymentResult.get("paymentUrl").toString());
|
||||
}
|
||||
if (paymentResult.containsKey("paypalPaymentId")) {
|
||||
savedPayment.setExternalTransactionId(paymentResult.get("paypalPaymentId").toString());
|
||||
}
|
||||
save(savedPayment);
|
||||
logger.info("PayPal支付链接生成成功: {}", paymentResult.get("paymentUrl"));
|
||||
} catch (Exception e) {
|
||||
logger.error("调用PayPal支付接口失败:", e);
|
||||
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
|
||||
}
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
return savedPayment;
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付失败:", e);
|
||||
throw new RuntimeException("创建支付失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建匿名用户
|
||||
*/
|
||||
private User createAnonymousUser(String username) {
|
||||
try {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setEmail(username + "@anonymous.com");
|
||||
user.setPasswordHash("anonymous");
|
||||
user.setEmail(username + "@anon.com");
|
||||
user.setPasswordHash("anon");
|
||||
user.setRole("ROLE_USER");
|
||||
|
||||
return userService.save(user);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建匿名用户失败:", e);
|
||||
throw new RuntimeException("创建匿名用户失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户支付统计
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> getUserPaymentStats(String username) {
|
||||
try {
|
||||
User user = userService.findByUsername(username);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
Long userId = user.getId();
|
||||
|
||||
if (user == null) throw new RuntimeException("User not found");
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("totalPayments", paymentRepository.countByUserId(userId));
|
||||
stats.put("successfulPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.SUCCESS));
|
||||
stats.put("pendingPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.PENDING));
|
||||
stats.put("failedPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.FAILED));
|
||||
stats.put("cancelledPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.CANCELLED));
|
||||
|
||||
stats.put("totalPayments", paymentRepository.countByUserId(user.getId()));
|
||||
stats.put("successfulPayments", paymentRepository.countByUserIdAndStatus(user.getId(), PaymentStatus.SUCCESS));
|
||||
return stats;
|
||||
} catch (Exception e) {
|
||||
logger.error("获取用户支付统计失败:", e);
|
||||
throw new RuntimeException("获取支付统计失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付信息增加积分
|
||||
*/
|
||||
private void addPointsForPayment(Payment payment) {
|
||||
try {
|
||||
BigDecimal amount = payment.getAmount();
|
||||
String description = payment.getDescription() != null ? payment.getDescription() : "";
|
||||
Integer pointsToAdd = 0;
|
||||
|
||||
// 优先从描述中识别套餐类型
|
||||
if (description.contains("标准版") || description.contains("standard")) {
|
||||
// 标准版订阅 - 200积分
|
||||
pointsToAdd = 200;
|
||||
logger.info("识别到标准版订阅,奖励 200 积分");
|
||||
} else if (description.contains("专业版") || description.contains("premium")) {
|
||||
// 专业版订阅 - 1000积分
|
||||
pointsToAdd = 1000;
|
||||
logger.info("识别到专业版订阅,奖励 1000 积分");
|
||||
} else {
|
||||
// 如果描述中没有套餐信息,根据金额判断
|
||||
// 标准版订阅 (59-258元) - 200积分
|
||||
if (amount.compareTo(new BigDecimal("59.00")) >= 0 && amount.compareTo(new BigDecimal("259.00")) < 0) {
|
||||
pointsToAdd = 200;
|
||||
logger.info("根据金额 {} 判断为标准版订阅,奖励 200 积分", amount);
|
||||
}
|
||||
// 专业版订阅 (259元以上) - 1000积分
|
||||
else if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
|
||||
pointsToAdd = 1000;
|
||||
logger.info("根据金额 {} 判断为专业版订阅,奖励 1000 积分", amount);
|
||||
} else {
|
||||
logger.warn("支付金额 {} 不在已知套餐范围内,不增加积分", amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (pointsToAdd > 0) {
|
||||
userService.addPoints(payment.getUser().getId(), pointsToAdd);
|
||||
logger.info("✅ 用户 {} 支付 {} 元,成功获得 {} 积分",
|
||||
payment.getUser().getUsername(), amount, pointsToAdd);
|
||||
} else {
|
||||
// 如果金额不在套餐范围内,给予基础积分(1元=1积分)
|
||||
// 这样可以避免用户支付后没有任何积分的情况
|
||||
int basePoints = amount.intValue(); // 1元=1积分
|
||||
if (basePoints > 0) {
|
||||
userService.addPoints(payment.getUser().getId(), basePoints);
|
||||
logger.info("✅ 用户 {} 支付 {} 元(非套餐金额),获得基础积分 {} 积分(按1元=1积分计算)",
|
||||
payment.getUser().getUsername(), amount, basePoints);
|
||||
} else {
|
||||
logger.warn("⚠️ 用户 {} 支付 {} 元,金额过小未获得积分(描述: {})",
|
||||
payment.getUser().getUsername(), amount, description);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ 增加积分失败:", e);
|
||||
// 不抛出异常,避免影响支付流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从支付记录自动创建订单
|
||||
*/
|
||||
private void createOrderFromPayment(Payment payment) {
|
||||
try {
|
||||
// 生成订单号
|
||||
String orderNumber = "ORD" + System.currentTimeMillis();
|
||||
|
||||
// 创建订单
|
||||
Order order = new Order();
|
||||
order.setUser(payment.getUser());
|
||||
order.setOrderNumber(orderNumber);
|
||||
order.setTotalAmount(payment.getAmount());
|
||||
order.setCurrency("CNY");
|
||||
order.setStatus(OrderStatus.PAID); // 支付成功,订单状态为已支付
|
||||
order.setOrderType(OrderType.PAYMENT); // 订单类型为支付订单
|
||||
order.setCreatedAt(LocalDateTime.now());
|
||||
order.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
// 保存订单
|
||||
Order savedOrder = orderService.save(order);
|
||||
|
||||
// 创建订单项
|
||||
OrderItem orderItem = new OrderItem();
|
||||
orderItem.setOrder(savedOrder);
|
||||
orderItem.setProductName("支付服务 - " + payment.getPaymentMethod().name());
|
||||
orderItem.setProductDescription("通过" + payment.getPaymentMethod().name() + "完成的支付服务");
|
||||
orderItem.setQuantity(1);
|
||||
orderItem.setUnitPrice(payment.getAmount());
|
||||
orderItem.setSubtotal(payment.getAmount());
|
||||
|
||||
// 保存订单项
|
||||
orderService.saveOrderItem(orderItem);
|
||||
|
||||
// 更新支付记录,关联到新创建的订单
|
||||
payment.setOrder(savedOrder);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
logger.info("从支付记录自动创建订单成功,支付ID:{},订单ID:{},订单号:{}",
|
||||
payment.getId(), savedOrder.getId(), orderNumber);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("从支付记录创建订单失败:", e);
|
||||
throw new RuntimeException("创建订单失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,11 @@ public class StoryboardVideoService {
|
||||
@Autowired
|
||||
private CosService cosService;
|
||||
|
||||
// 默认生成6张分镜图
|
||||
private static final int DEFAULT_STORYBOARD_IMAGES = 6;
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
// 默认生成1张分镜图
|
||||
private static final int DEFAULT_STORYBOARD_IMAGES = 1;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@@ -99,6 +102,9 @@ public class StoryboardVideoService {
|
||||
throw new IllegalArgumentException("文本描述不能为空");
|
||||
}
|
||||
|
||||
// 检查用户所有类型任务的总数(统一检查)
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
@@ -113,8 +119,25 @@ public class StoryboardVideoService {
|
||||
task.setImageModel(imageModel);
|
||||
}
|
||||
|
||||
// 上传用户参考图片到COS
|
||||
if (imageUrl != null && !imageUrl.isEmpty()) {
|
||||
task.setImageUrl(imageUrl);
|
||||
String finalImageUrl = imageUrl;
|
||||
// 如果是Base64图片且COS启用,上传到COS
|
||||
if (imageUrl.startsWith("data:image") && cosService.isEnabled()) {
|
||||
try {
|
||||
logger.info("开始上传用户参考图片到COS: taskId={}", taskId);
|
||||
String cosUrl = cosService.uploadBase64Image(imageUrl, "ref_" + taskId + ".png");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
finalImageUrl = cosUrl;
|
||||
logger.info("用户参考图片上传COS成功: taskId={}, url={}", taskId, cosUrl);
|
||||
} else {
|
||||
logger.warn("用户参考图片上传COS失败,使用原始URL: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("用户参考图片上传COS异常: taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
task.setImageUrl(finalImageUrl);
|
||||
}
|
||||
|
||||
// 保存任务(快速完成,事务立即提交)
|
||||
@@ -162,14 +185,27 @@ public class StoryboardVideoService {
|
||||
|
||||
// 判断是否有参考图片
|
||||
boolean hasReferenceImage = imageUrl != null && !imageUrl.isEmpty();
|
||||
logger.info("任务参数 - prompt: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
|
||||
prompt, aspectRatio, hdMode, hasReferenceImage, imageModel);
|
||||
|
||||
// 从系统设置读取分镜图系统引导词,并拼接到用户提示词前面
|
||||
String finalPrompt = prompt;
|
||||
try {
|
||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
||||
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
||||
finalPrompt = storyboardSystemPrompt.trim() + ", " + prompt;
|
||||
logger.info("已添加系统引导词,最终提示词长度: {}", finalPrompt.length());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取系统引导词失败,使用原始提示词: {}", e.getMessage());
|
||||
}
|
||||
logger.info("任务参数 - 原始提示词: {}, 最终提示词长度: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
|
||||
prompt, finalPrompt.length(), aspectRatio, hdMode, hasReferenceImage, imageModel);
|
||||
|
||||
// 更新任务状态为处理中(使用 TransactionTemplate 确保事务正确关闭)
|
||||
updateTaskStatusWithTransactionTemplate(taskId);
|
||||
|
||||
// 调用AI生图API,生成多张分镜图
|
||||
logger.info("开始生成{}张分镜图({}模式)...", DEFAULT_STORYBOARD_IMAGES, hasReferenceImage ? "图生图" : "文生图");
|
||||
// 调用AI生图API,生成分镜图
|
||||
logger.info("开始生成分镜图({}模式)...", hasReferenceImage ? "图生图" : "文生图");
|
||||
|
||||
// 收集所有图片URL
|
||||
List<String> imageUrls = new ArrayList<>();
|
||||
@@ -192,11 +228,12 @@ public class StoryboardVideoService {
|
||||
}
|
||||
|
||||
// 根据是否有参考图片,调用不同的API
|
||||
// 使用拼接后的提示词 finalPrompt
|
||||
Map<String, Object> apiResponse;
|
||||
if (hasReferenceImage) {
|
||||
// 有参考图片,使用图生图API
|
||||
// 有参考图片,使用图生图API(bananaAPI)
|
||||
apiResponse = realAIService.submitImageToImageTask(
|
||||
prompt,
|
||||
finalPrompt,
|
||||
imageUrl,
|
||||
aspectRatio,
|
||||
hdMode
|
||||
@@ -204,7 +241,7 @@ public class StoryboardVideoService {
|
||||
} else {
|
||||
// 无参考图片,使用文生图API
|
||||
apiResponse = realAIService.submitTextToImageTask(
|
||||
prompt,
|
||||
finalPrompt,
|
||||
aspectRatio,
|
||||
1, // 每次生成1张图片
|
||||
hdMode,
|
||||
@@ -284,19 +321,19 @@ public class StoryboardVideoService {
|
||||
}
|
||||
}
|
||||
|
||||
// 严格检查:必须生成6张图片才能拼接
|
||||
// 严格检查:必须生成指定数量的图片
|
||||
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("只生成了%d张图片,需要%d张才能拼接分镜图",
|
||||
String errorMsg = String.format("只生成了%d张图片,需要%d张",
|
||||
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
|
||||
logger.error(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
logger.info("成功生成{}张分镜图,开始拼接...", imageUrls.size());
|
||||
logger.info("成功生成{}张分镜图", imageUrls.size());
|
||||
|
||||
// 确保不超过6张图片(如果多于6张,只取前6张)
|
||||
// 确保不超过指定数量
|
||||
if (imageUrls.size() > DEFAULT_STORYBOARD_IMAGES) {
|
||||
logger.warn("生成了{}张图片,多于预期的{}张,只取前{}张进行拼接",
|
||||
logger.warn("生成了{}张图片,多于预期的{}张,只取前{}张",
|
||||
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES, DEFAULT_STORYBOARD_IMAGES);
|
||||
imageUrls = imageUrls.subList(0, DEFAULT_STORYBOARD_IMAGES);
|
||||
}
|
||||
@@ -308,22 +345,38 @@ public class StoryboardVideoService {
|
||||
// 参考sora2实现:确保所有图片格式一致
|
||||
List<String> validatedImages = validateAndNormalizeImages(imageUrls);
|
||||
|
||||
// 验证后的图片数量也要满足要求(必须6张)
|
||||
// 验证后的图片数量也要满足要求
|
||||
if (validatedImages.size() < DEFAULT_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("验证后只有%d张图片,需要%d张才能拼接分镜图",
|
||||
String errorMsg = String.format("验证后只有%d张图片,需要%d张",
|
||||
validatedImages.size(), DEFAULT_STORYBOARD_IMAGES);
|
||||
logger.error(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
|
||||
|
||||
// 拼接多张图片成网格(支持4-6张图片的灵活拼接)
|
||||
// 使用验证后的图片列表(都是Base64格式)
|
||||
// 处理分镜图
|
||||
String mergedImageUrl;
|
||||
long mergeStartTime = System.currentTimeMillis();
|
||||
String mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0); // 0表示自动计算列数
|
||||
|
||||
// 如果有参考图片,将原图和生成图拼接在一起
|
||||
if (hasReferenceImage && validatedImages.size() >= 1) {
|
||||
String generatedImage = validatedImages.get(0);
|
||||
logger.info("有参考图片,开始拼接原图和生成图...");
|
||||
// 根据原图比例决定拼接方式:
|
||||
// - 16:9横向图:创建9:16画布,左原图右生成图
|
||||
// - 9:16纵向图:创建16:9画布,上原图下生成图
|
||||
mergedImageUrl = imageGridService.mergeStoryboardImages(imageUrl, generatedImage);
|
||||
logger.info("原图和生成图拼接完成");
|
||||
} else if (validatedImages.size() == 1) {
|
||||
// 无参考图片,只有1张图片,直接使用
|
||||
mergedImageUrl = validatedImages.get(0);
|
||||
logger.info("只有1张分镜图,直接使用");
|
||||
} else {
|
||||
// 多张图片,拼接成网格
|
||||
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
|
||||
mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0);
|
||||
}
|
||||
long mergeTime = System.currentTimeMillis() - mergeStartTime;
|
||||
logger.info("图片网格拼接完成,耗时: {}ms", mergeTime);
|
||||
logger.info("分镜图处理完成,耗时: {}ms", mergeTime);
|
||||
|
||||
// 检查拼接后的图片URL是否有效
|
||||
if (mergedImageUrl == null || mergedImageUrl.isEmpty()) {
|
||||
|
||||
@@ -1650,11 +1650,13 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 更新任务为完成状态(使用独立事务,快速完成)
|
||||
* 使用 TransactionTemplate 确保事务正确执行(因为私有方法无法使用 @Transactional)
|
||||
* COS上传在事务外执行,避免事务超时
|
||||
*/
|
||||
private void updateTaskAsCompleted(TaskQueue taskQueue, String resultUrl) {
|
||||
String taskId = taskQueue.getTaskId();
|
||||
|
||||
// 第一步:在事务中完成数据库更新(不包含COS上传)
|
||||
try {
|
||||
// 使用 TransactionTemplate 确保在事务中执行
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
try {
|
||||
// 使用悲观锁查询,防止多线程并发重复处理
|
||||
@@ -1678,87 +1680,116 @@ public class TaskQueueService {
|
||||
|
||||
// 扣除冻结的积分(内部已处理重复扣除的情况)
|
||||
try {
|
||||
userService.deductFrozenPoints(taskQueue.getTaskId());
|
||||
userService.deductFrozenPoints(taskId);
|
||||
} catch (Exception e) {
|
||||
// 积分扣除失败不影响任务完成状态
|
||||
}
|
||||
|
||||
// 更新原始任务状态
|
||||
// 更新原始任务状态(先用原始URL)
|
||||
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
|
||||
|
||||
// 上传视频到COS(如果启用)
|
||||
String finalResultUrl = resultUrl;
|
||||
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
|
||||
// 创建/更新用户作品
|
||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||
try {
|
||||
logger.info("开始上传视频到COS: taskId={}", taskQueue.getTaskId());
|
||||
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
finalResultUrl = cosUrl;
|
||||
// 更新任务的resultUrl为COS URL
|
||||
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
|
||||
} else {
|
||||
logger.warn("COS上传失败,使用原始URL: taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
} catch (Exception cosException) {
|
||||
logger.error("上传视频到COS失败,使用原始URL: taskId={}", taskQueue.getTaskId(), cosException);
|
||||
// COS上传失败不影响任务完成,继续使用原始URL
|
||||
}
|
||||
}
|
||||
|
||||
// 创建/更新用户作品 - 在最后执行,避免影响主要流程
|
||||
// 只有在 resultUrl 有效时才更新为 COMPLETED
|
||||
// 如果 resultUrl 为空,不做处理(等待超时机制处理)
|
||||
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
|
||||
try {
|
||||
userWorkService.createWorkFromTask(taskQueue.getTaskId(), finalResultUrl);
|
||||
userWorkService.createWorkFromTask(taskId, resultUrl);
|
||||
} catch (Exception workException) {
|
||||
// 如果是重复创建异常,静默处理
|
||||
if (workException.getMessage() == null ||
|
||||
(!workException.getMessage().contains("已存在") &&
|
||||
!workException.getMessage().contains("Duplicate entry"))) {
|
||||
logger.warn("创建/更新用户作品失败: {}", taskQueue.getTaskId(), workException);
|
||||
logger.warn("创建/更新用户作品失败: {}", taskId, workException);
|
||||
}
|
||||
// 作品创建失败不影响任务完成状态
|
||||
}
|
||||
} else {
|
||||
logger.warn("任务返回完成但 resultUrl 为空,保持 user_works 状态不变(等待超时机制处理): {}", taskQueue.getTaskId());
|
||||
}
|
||||
|
||||
// 更新 task_status 表中的状态(保留记录)
|
||||
// 只有在 resultUrl 有效时才更新为 COMPLETED
|
||||
// 如果 resultUrl 为空,保持原状态不变(等待超时机制处理)
|
||||
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
|
||||
// 更新 task_status 表中的状态
|
||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||
try {
|
||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskQueue.getTaskId());
|
||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||
if (taskStatus != null) {
|
||||
taskStatus.setStatus(TaskStatus.Status.COMPLETED);
|
||||
taskStatus.setProgress(100);
|
||||
taskStatus.setResultUrl(finalResultUrl);
|
||||
taskStatus.setResultUrl(resultUrl);
|
||||
taskStatus.setCompletedAt(java.time.LocalDateTime.now());
|
||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskQueue.getTaskId());
|
||||
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskId);
|
||||
}
|
||||
} catch (Exception statusException) {
|
||||
logger.warn("更新 task_status 状态失败: {}", taskQueue.getTaskId(), statusException);
|
||||
logger.warn("更新 task_status 状态失败: {}", taskId, statusException);
|
||||
}
|
||||
}
|
||||
|
||||
// 任务完成后从 task_queue 中删除记录(task_status 保留)
|
||||
// 任务完成后从 task_queue 中删除记录
|
||||
try {
|
||||
taskQueueRepository.delete(freshTaskQueue);
|
||||
logger.info("任务完成,已从 task_queue 中删除: {}", taskQueue.getTaskId());
|
||||
logger.info("任务完成,已从 task_queue 中删除: {}", taskId);
|
||||
} catch (Exception deleteException) {
|
||||
logger.warn("删除 task_queue 记录失败: {}", taskQueue.getTaskId(), deleteException);
|
||||
logger.warn("删除 task_queue 记录失败: {}", taskId, deleteException);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("更新任务完成状态失败: {}", taskQueue.getTaskId(), e);
|
||||
logger.error("更新任务完成状态失败: {}", taskId, e);
|
||||
status.setRollbackOnly();
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("执行更新任务完成状态事务失败: {}", taskQueue.getTaskId(), e);
|
||||
// 如果原始任务状态更新失败,至少保证队列状态正确
|
||||
logger.error("执行更新任务完成状态事务失败: {}", taskId, e);
|
||||
return; // 事务失败,不继续COS上传
|
||||
}
|
||||
|
||||
// 第二步:在事务外执行COS上传(避免事务超时)
|
||||
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
|
||||
try {
|
||||
logger.info("开始上传视频到COS(事务外): taskId={}", taskId);
|
||||
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
logger.info("COS上传成功: taskId={}, cosUrl={}", taskId, cosUrl);
|
||||
// 用新事务更新URL为COS URL
|
||||
updateResultUrlToCos(taskQueue, cosUrl);
|
||||
} else {
|
||||
logger.warn("COS上传返回空URL,使用原始URL: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception cosException) {
|
||||
logger.error("上传视频到COS失败,使用原始URL: taskId={}", taskId, cosException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新结果URL为COS URL(独立事务)
|
||||
*/
|
||||
private void updateResultUrlToCos(TaskQueue taskQueue, String cosUrl) {
|
||||
String taskId = taskQueue.getTaskId();
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
try {
|
||||
// 更新原始任务的resultUrl
|
||||
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
|
||||
|
||||
// 更新用户作品的resultUrl
|
||||
try {
|
||||
userWorkService.createWorkFromTask(taskId, cosUrl);
|
||||
} catch (Exception e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 更新task_status的resultUrl
|
||||
try {
|
||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||
if (taskStatus != null) {
|
||||
taskStatus.setResultUrl(cosUrl);
|
||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新task_status COS URL失败: {}", taskId, e);
|
||||
}
|
||||
|
||||
logger.info("已更新resultUrl为COS URL: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("更新COS URL失败: {}", taskId, e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("执行更新COS URL事务失败: {}", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,8 @@ public class TaskStatusPollingService {
|
||||
public void pollTaskStatus(TaskStatus task) {
|
||||
logger.info("轮询任务状态: taskId={}, externalTaskId={}", task.getTaskId(), task.getExternalTaskId());
|
||||
|
||||
String[] pendingAction = null;
|
||||
|
||||
try {
|
||||
// 使用正确的 API 端点:GET /v2/videos/generations/{task_id}
|
||||
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
@@ -249,8 +251,8 @@ public class TaskStatusPollingService {
|
||||
if (response.getStatus() == 200) {
|
||||
JsonNode responseJson = objectMapper.readTree(response.getBody());
|
||||
logger.info("轮询任务状态成功: taskId={}, response={}", task.getTaskId(), response.getBody());
|
||||
// 更新任务状态(使用单独的事务方法)
|
||||
updateTaskStatusWithTransaction(task, responseJson);
|
||||
// 更新任务状态(使用单独的事务方法),返回需要后续处理的信息
|
||||
pendingAction = updateTaskStatusWithTransaction(task, responseJson);
|
||||
} else {
|
||||
logger.warn("查询任务状态失败: taskId={}, status={}, response={}",
|
||||
task.getTaskId(), response.getStatus(), response.getBody());
|
||||
@@ -263,14 +265,32 @@ public class TaskStatusPollingService {
|
||||
// 更新轮询次数(使用单独的事务方法)
|
||||
incrementPollCountWithTransaction(task);
|
||||
}
|
||||
|
||||
// 在事务外处理后续操作(避免事务超时)
|
||||
if (pendingAction != null) {
|
||||
try {
|
||||
String actionType = pendingAction[0];
|
||||
String taskId = pendingAction[1];
|
||||
String data = pendingAction[2];
|
||||
|
||||
if ("COMPLETED".equals(actionType)) {
|
||||
taskQueueService.handleTaskCompletionByTaskId(taskId, data);
|
||||
} else if ("FAILED".equals(actionType)) {
|
||||
taskQueueService.handleTaskFailureByTaskId(taskId, data);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("处理任务后续操作失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态(单独的事务方法)
|
||||
* 更新任务状态(单独的事务方法)- 只更新 TaskStatus 表
|
||||
* 返回值:null=无需后续处理, [0]=完成需处理, [1]=失败需处理
|
||||
*/
|
||||
@Transactional
|
||||
public void updateTaskStatusWithTransaction(TaskStatus task, JsonNode responseJson) {
|
||||
updateTaskStatus(task, responseJson);
|
||||
public String[] updateTaskStatusWithTransaction(TaskStatus task, JsonNode responseJson) {
|
||||
return updateTaskStatusOnly(task, responseJson);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,9 +303,10 @@ public class TaskStatusPollingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
* 更新任务状态 - 只更新 TaskStatus 表,返回后续需要处理的信息
|
||||
* 返回:[taskId, resultUrl] 表示完成,[taskId, null, errorMessage] 表示失败,null 表示无需后续处理
|
||||
*/
|
||||
private void updateTaskStatus(TaskStatus task, JsonNode responseJson) {
|
||||
private String[] updateTaskStatusOnly(TaskStatus task, JsonNode responseJson) {
|
||||
try {
|
||||
String status = responseJson.path("status").asText();
|
||||
String progressStr = responseJson.path("progress").asText("0%");
|
||||
@@ -304,22 +325,21 @@ public class TaskStatusPollingService {
|
||||
task.markAsCompleted(resultUrl);
|
||||
logger.info("任务完成: taskId={}, resultUrl={}", task.getTaskId(), resultUrl);
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
// 返回任务信息,在事务外处理
|
||||
return new String[]{"COMPLETED", task.getTaskId(), resultUrl};
|
||||
} else {
|
||||
logger.warn("任务状态为成功但 resultUrl 为空,保持 PROCESSING: taskId={}", task.getTaskId());
|
||||
taskStatusRepository.save(task);
|
||||
}
|
||||
return; // 已保存,直接返回
|
||||
return null;
|
||||
|
||||
case "failed":
|
||||
case "error":
|
||||
task.markAsFailed(errorMessage);
|
||||
logger.warn("任务失败: taskId={}, error={}", task.getTaskId(), errorMessage);
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), errorMessage);
|
||||
return; // 已保存,直接返回
|
||||
// 返回任务信息,在事务外处理
|
||||
return new String[]{"FAILED", task.getTaskId(), errorMessage};
|
||||
|
||||
case "processing":
|
||||
case "in_progress":
|
||||
@@ -334,9 +354,11 @@ public class TaskStatusPollingService {
|
||||
}
|
||||
|
||||
taskStatusRepository.save(task);
|
||||
return null;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("更新任务状态时发生错误: taskId={}, error={}", task.getTaskId(), e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ public class TextToVideoService {
|
||||
throw new IllegalArgumentException("视频时长必须在1-60秒之间");
|
||||
}
|
||||
|
||||
// 检查用户所有类型任务的总数(统一检查)
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
@@ -269,6 +272,16 @@ public class TextToVideoService {
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 同步更新 UserWork 表的状态和结果URL
|
||||
try {
|
||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
logger.info("文生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
logger.info("文生视频任务完成: {}", task.getTaskId());
|
||||
return;
|
||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||
|
||||
@@ -4,11 +4,15 @@ import java.time.LocalDateTime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.example.demo.config.CacheConfig;
|
||||
import com.example.demo.model.PointsFreezeRecord;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.repository.PointsFreezeRecordRepository;
|
||||
@@ -25,16 +29,19 @@ public class UserService {
|
||||
private final PointsFreezeRecordRepository pointsFreezeRecordRepository;
|
||||
private final com.example.demo.repository.OrderRepository orderRepository;
|
||||
private final com.example.demo.repository.PaymentRepository paymentRepository;
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,
|
||||
PointsFreezeRecordRepository pointsFreezeRecordRepository,
|
||||
com.example.demo.repository.OrderRepository orderRepository,
|
||||
com.example.demo.repository.PaymentRepository paymentRepository) {
|
||||
com.example.demo.repository.PaymentRepository paymentRepository,
|
||||
CacheManager cacheManager) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.cacheManager = cacheManager;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -74,11 +81,13 @@ public class UserService {
|
||||
return userRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
|
||||
}
|
||||
|
||||
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
|
||||
@Transactional(readOnly = true)
|
||||
public User findByUsername(String username) {
|
||||
return userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
|
||||
}
|
||||
|
||||
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
|
||||
@Transactional(readOnly = true)
|
||||
public User findByUsernameOrNull(String username) {
|
||||
return userRepository.findByUsername(username).orElse(null);
|
||||
@@ -97,7 +106,8 @@ public class UserService {
|
||||
@Transactional
|
||||
public User update(Long id, String username, String email, String rawPasswordNullable, String role) {
|
||||
User user = findById(id);
|
||||
if (!user.getUsername().equals(username) && userRepository.existsByUsername(username)) {
|
||||
String oldUsername = user.getUsername();
|
||||
if (!oldUsername.equals(username) && userRepository.existsByUsername(username)) {
|
||||
throw new IllegalArgumentException("用户名已存在");
|
||||
}
|
||||
if (!user.getEmail().equals(email) && userRepository.existsByEmail(email)) {
|
||||
@@ -111,11 +121,35 @@ public class UserService {
|
||||
if (rawPasswordNullable != null && !rawPasswordNullable.isBlank()) {
|
||||
user.setPasswordHash(rawPasswordNullable);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
// 手动清除旧用户名和新用户名的缓存
|
||||
evictUserCache(oldUsername);
|
||||
if (!oldUsername.equals(username)) {
|
||||
evictUserCache(username);
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动清除用户缓存
|
||||
* 注意:使用 CacheManager 直接操作,避免同类内部调用 @CacheEvict 不生效的问题
|
||||
*/
|
||||
public void evictUserCache(String username) {
|
||||
Cache cache = cacheManager.getCache(CacheConfig.USER_CACHE);
|
||||
if (cache != null && username != null) {
|
||||
cache.evict(username);
|
||||
logger.debug("清除用户缓存: {}", username);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
// 先获取用户名用于清除缓存
|
||||
userRepository.findById(id).ifPresent(user -> {
|
||||
evictUserCache(user.getUsername());
|
||||
});
|
||||
userRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -129,8 +163,9 @@ public class UserService {
|
||||
/**
|
||||
* 修改指定用户的密码
|
||||
*
|
||||
* 如果用户已经有密码,则需要提供正确的原密码;
|
||||
* 如果用户当前没有设置密码(例如仅使用邮箱验证码登录),则可以直接设置新密码。
|
||||
* 原密码为可选:
|
||||
* - 如果提供了原密码,则验证原密码是否正确
|
||||
* - 如果未提供原密码,则直接设置新密码
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||||
@@ -139,23 +174,31 @@ public class UserService {
|
||||
if (newPassword == null || newPassword.isBlank()) {
|
||||
throw new IllegalArgumentException("新密码不能为空");
|
||||
}
|
||||
if (newPassword.length() < 6) {
|
||||
throw new IllegalArgumentException("新密码长度不能少于6位");
|
||||
if (newPassword.length() < 8) {
|
||||
throw new IllegalArgumentException("新密码长度不能少于8位");
|
||||
}
|
||||
// 验证密码必须包含英文字母和数字
|
||||
if (!newPassword.matches(".*[a-zA-Z].*")) {
|
||||
throw new IllegalArgumentException("新密码必须包含英文字母");
|
||||
}
|
||||
if (!newPassword.matches(".*[0-9].*")) {
|
||||
throw new IllegalArgumentException("新密码必须包含数字");
|
||||
}
|
||||
|
||||
String currentPasswordHash = user.getPasswordHash();
|
||||
// 如果已经设置过密码,则需要校验原密码
|
||||
// 如果提供了原密码,则需要验证
|
||||
if (oldPassword != null && !oldPassword.isBlank()) {
|
||||
if (currentPasswordHash != null && !currentPasswordHash.isBlank()) {
|
||||
if (oldPassword == null || oldPassword.isBlank()) {
|
||||
throw new IllegalArgumentException("原密码不能为空");
|
||||
}
|
||||
if (!checkPassword(oldPassword, currentPasswordHash)) {
|
||||
throw new IllegalArgumentException("原密码不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果未提供原密码,直接设置新密码
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
userRepository.save(user);
|
||||
evictUserCache(user.getUsername()); // 清除缓存
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +227,22 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
public User save(User user) {
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
evictUserCache(user.getUsername()); // 清除缓存
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户活跃时间(不清除缓存,避免频繁清除影响性能)
|
||||
* 此方法专门用于 UserActivityInterceptor,仅更新 lastActiveTime 字段
|
||||
*/
|
||||
@Transactional
|
||||
public void updateLastActiveTime(String username) {
|
||||
userRepository.findByUsername(username).ifPresent(user -> {
|
||||
user.setLastActiveTime(java.time.LocalDateTime.now());
|
||||
userRepository.save(user);
|
||||
// 注意:不清除缓存,因为 lastActiveTime 不是缓存的关键数据
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,6 +250,7 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
public User addPoints(Long userId, Integer points) {
|
||||
java.util.Objects.requireNonNull(userId, "用户ID不能为空");
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
|
||||
@@ -201,7 +260,9 @@ public class UserService {
|
||||
}
|
||||
|
||||
user.setPoints(newPoints);
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
evictUserCache(user.getUsername()); // 清除缓存
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +279,9 @@ public class UserService {
|
||||
}
|
||||
|
||||
user.setPoints(newPoints);
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
evictUserCache(user.getUsername()); // 清除缓存
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +305,7 @@ public class UserService {
|
||||
// 增加冻结积分
|
||||
user.setFrozenPoints(user.getFrozenPoints() + points);
|
||||
userRepository.save(user);
|
||||
evictUserCache(username); // 清除缓存
|
||||
|
||||
// 创建冻结记录
|
||||
PointsFreezeRecord record = new PointsFreezeRecord(username, taskId, taskType, points, reason);
|
||||
@@ -278,6 +342,7 @@ public class UserService {
|
||||
user.setPoints(user.getPoints() - record.getFreezePoints());
|
||||
user.setFrozenPoints(user.getFrozenPoints() - record.getFreezePoints());
|
||||
userRepository.save(user);
|
||||
evictUserCache(record.getUsername()); // 清除缓存
|
||||
|
||||
// 更新冻结记录状态
|
||||
record.updateStatus(PointsFreezeRecord.FreezeStatus.DEDUCTED);
|
||||
@@ -303,6 +368,7 @@ public class UserService {
|
||||
// 减少冻结积分(总积分不变)
|
||||
user.setFrozenPoints(user.getFrozenPoints() - record.getFreezePoints());
|
||||
userRepository.save(user);
|
||||
evictUserCache(record.getUsername()); // 清除缓存
|
||||
|
||||
// 更新冻结记录状态
|
||||
record.updateStatus(PointsFreezeRecord.FreezeStatus.RETURNED);
|
||||
@@ -319,6 +385,7 @@ public class UserService {
|
||||
|
||||
user.setPoints(user.getPoints() + points);
|
||||
userRepository.save(user);
|
||||
evictUserCache(username); // 清除缓存
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,6 +398,7 @@ public class UserService {
|
||||
|
||||
user.setPoints(points);
|
||||
userRepository.save(user);
|
||||
evictUserCache(username); // 清除缓存
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,31 @@ public class UserWorkService {
|
||||
@Autowired
|
||||
private StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
// 每个用户最多同时执行的任务数
|
||||
private static final int MAX_CONCURRENT_TASKS = 3;
|
||||
|
||||
/**
|
||||
* 检查用户是否可以创建新任务
|
||||
* @param username 用户名
|
||||
* @throws IllegalStateException 如果用户已达到最大并发任务数
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public void checkMaxConcurrentTasks(String username) {
|
||||
long processingCount = userWorkRepository.countProcessingTasksByUsername(username);
|
||||
if (processingCount >= MAX_CONCURRENT_TASKS) {
|
||||
throw new IllegalStateException("您当前有 " + processingCount + " 个任务正在执行,最多同时执行 " + MAX_CONCURRENT_TASKS + " 个任务,请等待部分任务完成后再提交");
|
||||
}
|
||||
logger.info("用户 {} 当前进行中任务数: {}/{}", username, processingCount, MAX_CONCURRENT_TASKS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户当前进行中的任务数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long getProcessingTaskCount(String username) {
|
||||
return userWorkRepository.countProcessingTasksByUsername(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务创建作品
|
||||
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
|
||||
@@ -355,15 +380,22 @@ public class UserWorkService {
|
||||
return userWorkRepository.findByUsernameWithResultUrlOrderByCreatedAtDesc(username, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有作品列表(分页),包括正在排队和生成中的作品
|
||||
* 用于"我的作品"页面,显示所有状态的作品
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserWork> getAllUserWorks(String username, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return userWorkRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户正在进行中的作品(包括PROCESSING和PENDING状态)
|
||||
* 只返回最近24小时内的任务,避免返回陈旧的僵尸任务
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public java.util.List<UserWork> getProcessingWorks(String username) {
|
||||
// 只查询最近24小时内的任务
|
||||
LocalDateTime afterTime = LocalDateTime.now().minusHours(24);
|
||||
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username, afterTime);
|
||||
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,6 +484,26 @@ public class UserWorkService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务完成时更新作品状态和结果URL
|
||||
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void updateWorkOnComplete(String taskId, String resultUrl, UserWork.WorkStatus status) {
|
||||
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
|
||||
if (workOpt.isPresent()) {
|
||||
UserWork work = workOpt.get();
|
||||
work.setStatus(status);
|
||||
work.setResultUrl(resultUrl);
|
||||
work.setUpdatedAt(LocalDateTime.now());
|
||||
userWorkRepository.save(work);
|
||||
logger.info("任务完成,更新作品: taskId={}, status={}, resultUrl={}",
|
||||
taskId, status, resultUrl != null ? resultUrl.substring(0, Math.min(50, resultUrl.length())) + "..." : "null");
|
||||
} else {
|
||||
logger.warn("未找到对应的作品记录: taskId={}", taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开作品列表
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#Updated by API Key Management
|
||||
#Mon Nov 24 17:13:13 CST 2025
|
||||
#Sat Dec 06 10:23:35 CST 2025
|
||||
ai.api.base-url=https\://ai.comfly.chat
|
||||
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
ai.image.api.base-url=https\://ai.comfly.chat
|
||||
@@ -7,21 +7,26 @@ ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
alipay.app-id=9021000157616562
|
||||
alipay.charset=UTF-8
|
||||
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
alipay.notify-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/notify
|
||||
alipay.notify-url=https\://vionow.com/api/payments/alipay/notify
|
||||
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg\=\=
|
||||
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
|
||||
alipay.return-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/return
|
||||
alipay.return-url=https\://vionow.com/api/payments/alipay/return
|
||||
alipay.server-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
alipay.sign-type=RSA2
|
||||
app.ffmpeg.path=C\:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
|
||||
app.temp.dir=./temp
|
||||
jwt.expiration=86400000
|
||||
jwt.expiration=7200000
|
||||
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
|
||||
logging.level.com.example.demo=DEBUG
|
||||
logging.level.org.hibernate.SQL=WARN
|
||||
logging.level.org.hibernate.orm.jdbc.bind=WARN
|
||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
logging.level.org.springframework.security=WARN
|
||||
paypal.cancel-url=https\://vionow.com/api/payment/paypal/cancel
|
||||
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
|
||||
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
|
||||
paypal.mode=sandbox
|
||||
paypal.success-url=https\://vionow.com/api/payment/paypal/success
|
||||
server.port=8080
|
||||
server.tomcat.accept-count=100
|
||||
server.tomcat.connection-timeout=20000
|
||||
@@ -56,11 +61,5 @@ tencent.ses.region=ap-hongkong
|
||||
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
||||
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
||||
tencent.ses.template-id=154360
|
||||
# ============================================
|
||||
# PayPal支付配置(沙箱测试环境)
|
||||
# ============================================
|
||||
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
|
||||
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
|
||||
paypal.mode=sandbox
|
||||
paypal.success-url=http://localhost:8080/api/payment/paypal/success
|
||||
paypal.cancel-url=http://localhost:8080/api/payment/paypal/cancel
|
||||
|
||||
alipay.domain=https\://vionow.com
|
||||
|
||||
@@ -17,6 +17,20 @@ server.tomcat.max-http-post-size=600MB
|
||||
# JPA配置 - 禁用open-in-view避免视图层执行SQL查询
|
||||
spring.jpa.open-in-view=false
|
||||
|
||||
# HikariCP连接池配置
|
||||
# 连接泄漏检测阈值(毫秒),设置为0禁用检测,避免长时间任务触发误报
|
||||
spring.datasource.hikari.leak-detection-threshold=0
|
||||
# 最大连接池大小
|
||||
spring.datasource.hikari.maximum-pool-size=20
|
||||
# 最小空闲连接数
|
||||
spring.datasource.hikari.minimum-idle=5
|
||||
# 连接超时(毫秒)
|
||||
spring.datasource.hikari.connection-timeout=30000
|
||||
# 空闲连接超时(毫秒)
|
||||
spring.datasource.hikari.idle-timeout=600000
|
||||
# 连接最大存活时间(毫秒)
|
||||
spring.datasource.hikari.max-lifetime=1800000
|
||||
|
||||
# 应用配置
|
||||
app.upload.path=uploads
|
||||
app.video.output.path=outputs
|
||||
@@ -52,6 +66,8 @@ tencent.cos.secret-key=Xrxywju0wfAf3QiqlT2ZvGYgeS6WjnjT
|
||||
tencent.cos.region=ap-nanjing
|
||||
# COS存储桶名称(例如:my-bucket-1234567890)
|
||||
tencent.cos.bucket-name=test-1323844400
|
||||
# COS文件夹前缀(所有文件存储在此目录下)
|
||||
tencent.cos.prefix=test-sx
|
||||
|
||||
# ============================================
|
||||
# PayPal支付配置
|
||||
@@ -69,3 +85,18 @@ paypal.success-url=https://vionow.com/api/payment/paypal/success
|
||||
# 支付取消回调URL
|
||||
paypal.cancel-url=https://vionow.com/api/payment/paypal/cancel
|
||||
|
||||
# ============================================
|
||||
# GZIP 压缩配置(提升传输性能)
|
||||
# ============================================
|
||||
server.compression.enabled=true
|
||||
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml
|
||||
server.compression.min-response-size=1024
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
# 关闭 Spring Security DEBUG 日志
|
||||
logging.level.org.springframework.security=INFO
|
||||
# 减少 Tomcat HTTP 解析错误日志(扫描器/HTTPS误连等导致的)
|
||||
logging.level.org.apache.coyote.http11.Http11Processor=ERROR
|
||||
logging.level.org.apache.coyote.AbstractProtocol=ERROR
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
-- 注意:生产环境部署时,此文件应保持为空或仅包含必要的系统配置数据
|
||||
|
||||
-- ============================================
|
||||
-- 管理员权限自动设置
|
||||
-- 超级管理员权限自动设置
|
||||
-- ============================================
|
||||
-- 应用启动时自动将 984523799@qq.com 设置为管理员
|
||||
-- 如果该用户存在,则更新其角色为管理员
|
||||
-- 应用启动时自动将 984523799@qq.com 设置为超级管理员
|
||||
-- 如果该用户存在,则更新其角色为超级管理员
|
||||
UPDATE users
|
||||
SET role = 'ROLE_ADMIN',
|
||||
SET role = 'ROLE_SUPER_ADMIN',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE email = '984523799@qq.com';
|
||||
|
||||
48
demo/src/main/resources/db/migration/V2__add_indexes.sql
Normal file
48
demo/src/main/resources/db/migration/V2__add_indexes.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- 性能优化索引(MySQL 兼容)
|
||||
-- 注意:此文件需要手动在数据库中执行,如果索引已存在会报错(可忽略)
|
||||
|
||||
-- users 表索引
|
||||
ALTER TABLE users ADD INDEX idx_users_phone (phone);
|
||||
ALTER TABLE users ADD INDEX idx_users_is_active (is_active);
|
||||
ALTER TABLE users ADD INDEX idx_users_last_active_time (last_active_time);
|
||||
ALTER TABLE users ADD INDEX idx_users_created_at (created_at);
|
||||
|
||||
-- payments 表索引
|
||||
ALTER TABLE payments ADD INDEX idx_payments_user_id (user_id);
|
||||
ALTER TABLE payments ADD INDEX idx_payments_status (status);
|
||||
ALTER TABLE payments ADD INDEX idx_payments_created_at (created_at);
|
||||
ALTER TABLE payments ADD INDEX idx_payments_user_status (user_id, status);
|
||||
|
||||
-- orders 表索引
|
||||
ALTER TABLE orders ADD INDEX idx_orders_user_id (user_id);
|
||||
ALTER TABLE orders ADD INDEX idx_orders_status (status);
|
||||
ALTER TABLE orders ADD INDEX idx_orders_created_at (created_at);
|
||||
ALTER TABLE orders ADD INDEX idx_orders_user_status (user_id, status);
|
||||
|
||||
-- user_works 表索引(高频查询优化)
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_username (username);
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_status (status);
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_task_id (task_id);
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_created_at (created_at);
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_username_status (username, status);
|
||||
ALTER TABLE user_works ADD INDEX idx_user_works_is_public_status (is_public, status);
|
||||
|
||||
-- text_to_video_tasks 表索引
|
||||
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_task_id (task_id);
|
||||
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_username (username);
|
||||
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_status (status);
|
||||
|
||||
-- image_to_video_tasks 表索引
|
||||
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_task_id (task_id);
|
||||
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_username (username);
|
||||
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_status (status);
|
||||
|
||||
-- storyboard_video_tasks 表索引
|
||||
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_task_id (task_id);
|
||||
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_username (username);
|
||||
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_status (status);
|
||||
|
||||
-- points_freeze_records 表索引
|
||||
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_task_id (task_id);
|
||||
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_username (username);
|
||||
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_status (status);
|
||||
@@ -0,0 +1,126 @@
|
||||
-- ============================================
|
||||
-- 任务状态级联更新触发器
|
||||
-- 当 task_status 表状态更新时,自动同步到其他关联表
|
||||
-- ============================================
|
||||
|
||||
DELIMITER //
|
||||
|
||||
-- 删除已存在的触发器(如果存在)
|
||||
DROP TRIGGER IF EXISTS trg_task_status_update//
|
||||
|
||||
-- 创建触发器:当 task_status 更新时,级联更新其他表
|
||||
CREATE TRIGGER trg_task_status_update
|
||||
AFTER UPDATE ON task_status
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- 只在状态发生变化时触发
|
||||
IF NEW.status <> OLD.status THEN
|
||||
|
||||
-- 更新 task_queue 表
|
||||
UPDATE task_queue
|
||||
SET status = NEW.status,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END
|
||||
WHERE task_id = NEW.task_id;
|
||||
|
||||
-- 更新 user_works 表
|
||||
UPDATE user_works
|
||||
SET status = CASE
|
||||
WHEN NEW.status = 'COMPLETED' THEN 'COMPLETED'
|
||||
WHEN NEW.status = 'FAILED' THEN 'FAILED'
|
||||
WHEN NEW.status = 'PROCESSING' THEN 'PROCESSING'
|
||||
WHEN NEW.status = 'PENDING' THEN 'PENDING'
|
||||
WHEN NEW.status = 'CANCELLED' THEN 'CANCELLED'
|
||||
ELSE status
|
||||
END,
|
||||
updated_at = NOW(),
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
|
||||
-- 更新 text_to_video_tasks 表(根据 task_id 前缀判断)
|
||||
IF NEW.task_id LIKE 'txt2vid_%' THEN
|
||||
UPDATE text_to_video_tasks
|
||||
SET status = NEW.status,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
-- 更新 image_to_video_tasks 表
|
||||
IF NEW.task_id LIKE 'img2vid_%' THEN
|
||||
UPDATE image_to_video_tasks
|
||||
SET status = NEW.status,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
-- 更新 storyboard_video_tasks 表(分镜视频,排除分镜图)
|
||||
IF NEW.task_id LIKE 'storyboard_%' AND NEW.task_id NOT LIKE '%_image' THEN
|
||||
UPDATE storyboard_video_tasks
|
||||
SET status = NEW.status,
|
||||
updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
|
||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||
WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
|
||||
-- 分镜图任务(taskId 以 _image 结尾)
|
||||
IF NEW.task_id LIKE '%_image' THEN
|
||||
-- 分镜图关联到主任务,更新主任务的时间戳和错误信息
|
||||
UPDATE storyboard_video_tasks
|
||||
SET updated_at = NOW(),
|
||||
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END
|
||||
WHERE task_id = REPLACE(NEW.task_id, '_image', '');
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
END//
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- ============================================
|
||||
-- 可选:反向触发器(业务表更新时同步到 task_status)
|
||||
-- 根据需要启用
|
||||
-- ============================================
|
||||
|
||||
/*
|
||||
DELIMITER //
|
||||
|
||||
-- text_to_video_tasks 状态更新时同步
|
||||
DROP TRIGGER IF EXISTS trg_text_to_video_status_update//
|
||||
CREATE TRIGGER trg_text_to_video_status_update
|
||||
AFTER UPDATE ON text_to_video_tasks
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.status <> OLD.status THEN
|
||||
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
END//
|
||||
|
||||
-- image_to_video_tasks 状态更新时同步
|
||||
DROP TRIGGER IF EXISTS trg_image_to_video_status_update//
|
||||
CREATE TRIGGER trg_image_to_video_status_update
|
||||
AFTER UPDATE ON image_to_video_tasks
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.status <> OLD.status THEN
|
||||
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
END//
|
||||
|
||||
-- storyboard_video_tasks 状态更新时同步
|
||||
DROP TRIGGER IF EXISTS trg_storyboard_status_update//
|
||||
CREATE TRIGGER trg_storyboard_status_update
|
||||
AFTER UPDATE ON storyboard_video_tasks
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF NEW.status <> OLD.status THEN
|
||||
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
|
||||
END IF;
|
||||
END//
|
||||
|
||||
DELIMITER ;
|
||||
*/
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 修复外键约束,添加级联删除
|
||||
|
||||
-- 1. 修复 payments 表的外键约束
|
||||
ALTER TABLE payments DROP FOREIGN KEY payments_ibfk_1;
|
||||
ALTER TABLE payments ADD CONSTRAINT payments_user_fk
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- 2. 修复 orders 表的外键约束
|
||||
ALTER TABLE orders DROP FOREIGN KEY orders_ibfk_1;
|
||||
ALTER TABLE orders ADD CONSTRAINT orders_user_fk
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
@@ -1,15 +1,15 @@
|
||||
# 支付配置
|
||||
# 支付宝配置 - 请替换为您的实际配置
|
||||
alipay.app-id=您的APPID
|
||||
alipay.private-key=您的应用私钥
|
||||
alipay.public-key=支付宝公钥
|
||||
# 支付宝沙箱配置
|
||||
alipay.app-id=9021000157616562
|
||||
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg==
|
||||
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
|
||||
alipay.server-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
alipay.gateway-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
alipay.domain=https://curtly-aphorismatic-ginger.ngrok-free.dev
|
||||
alipay.domain=https://vionow.com
|
||||
alipay.charset=UTF-8
|
||||
alipay.sign-type=RSA2
|
||||
alipay.notify-url=https://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/notify
|
||||
alipay.return-url=https://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/return
|
||||
alipay.notify-url=https://vionow.com/api/payments/alipay/notify
|
||||
alipay.return-url=https://vionow.com/api/payments/alipay/return
|
||||
alipay.app-cert-path=classpath:cert/alipay/appCertPublicKey.crt
|
||||
alipay.ali-pay-cert-path=classpath:cert/alipay/alipayCertPublicKey_RSA2.crt
|
||||
alipay.ali-pay-root-cert-path=classpath:cert/alipay/alipayRootCert.crt
|
||||
|
||||
@@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS payments (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
paid_at TIMESTAMP NULL,
|
||||
user_id BIGINT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
@@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
delivered_at TIMESTAMP NULL,
|
||||
cancelled_at TIMESTAMP NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org" th:fragment="layout(title, content)">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="${pageTitle} + ' - ' + ${siteName}">AIGC Demo</title>
|
||||
<title th:replace="${title}">AIGC Demo</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
@@ -168,22 +168,22 @@
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{/}" th:classappend="${#httpServletRequest.requestURI == '/'} ? 'active' : ''">
|
||||
<a class="nav-link" th:href="@{/}" th:classappend="${#httpServletRequest != null && #httpServletRequest.requestURI == '/'} ? 'active' : ''">
|
||||
<i class="fas fa-home me-1"></i>首页
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
|
||||
<a class="nav-link" th:href="@{/settings}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/settings')} ? 'active' : ''">
|
||||
<a class="nav-link" th:href="@{/settings}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/settings')} ? 'active' : ''">
|
||||
<i class="fas fa-gear me-1"></i>系统设置
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
|
||||
<a class="nav-link" th:href="@{/users}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/users')} ? 'active' : ''">
|
||||
<a class="nav-link" th:href="@{/users}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/users')} ? 'active' : ''">
|
||||
<i class="fas fa-users me-1"></i>用户管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" sec:authorize="isAuthenticated()">
|
||||
<a class="nav-link" th:href="@{/payment/create}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/payment')} ? 'active' : ''">
|
||||
<a class="nav-link" th:href="@{/payment/create}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/payment')} ? 'active' : ''">
|
||||
<i class="fas fa-credit-card me-1"></i>支付管理
|
||||
</a>
|
||||
</li>
|
||||
@@ -265,7 +265,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div th:fragment="content">
|
||||
<div th:replace="${content}">
|
||||
<!-- Page specific content will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user