项目重构: 整理目录结构, 更新前后端代码, 添加测试和数据库迁移

This commit is contained in:
AIGC Developer
2025-12-30 10:24:19 +08:00
parent 5344148a1c
commit 38630dbb66
117 changed files with 1987 additions and 1316 deletions

View File

@@ -1,80 +0,0 @@
#!/bin/bash
# Linux服务器部署命令脚本
# 使用方法: bash LINUX_DEPLOY_COMMANDS.sh
set -e # 遇到错误立即退出
echo "============================================================"
echo "开始部署 Spring Boot + Vue 项目"
echo "============================================================"
# 进入项目目录
cd /home/ubuntu/spring-vue-app || cd ~/spring-vue-app || exit 1
echo ""
echo "步骤 1: 检查 Dockerfile"
echo "============================================================"
if [ -f "backend/Dockerfile" ]; then
echo "✅ 找到 backend/Dockerfile"
echo "检查使用的镜像:"
grep "FROM" backend/Dockerfile | head -2
else
echo "❌ 未找到 backend/Dockerfile"
exit 1
fi
echo ""
echo "步骤 2: 构建 Docker 镜像"
echo "============================================================"
sudo docker-compose build --no-cache
echo ""
echo "步骤 3: 启动服务"
echo "============================================================"
sudo docker-compose up -d
echo ""
echo "步骤 4: 等待服务启动30秒"
echo "============================================================"
sleep 30
echo ""
echo "步骤 5: 检查服务状态"
echo "============================================================"
sudo docker-compose ps
echo ""
echo "步骤 6: 健康检查"
echo "============================================================"
# 检查后端健康状态不依赖Actuator直接检查根路径
if curl -f http://localhost:8080/ > /dev/null 2>&1; then
echo "✅ 后端服务健康检查通过"
else
echo "⚠️ 后端服务可能还在启动中,请稍后检查"
echo "查看日志: sudo docker-compose logs backend"
fi
# 检查前端
if curl -f http://localhost/ > /dev/null 2>&1; then
echo "✅ 前端服务健康检查通过"
else
echo "⚠️ 前端服务可能还在启动中,请稍后检查"
echo "查看日志: sudo docker-compose logs frontend"
fi
echo ""
echo "============================================================"
echo "✅ 部署完成!"
echo "============================================================"
echo ""
echo "访问地址:"
echo " 前端: http://localhost"
echo " 后端: http://localhost:8080"
echo ""
echo "常用命令:"
echo " 查看日志: sudo docker-compose logs -f"
echo " 停止服务: sudo docker-compose down"
echo " 重启服务: sudo docker-compose restart"
echo "============================================================"

Binary file not shown.

View File

@@ -1,8 +0,0 @@
-- 为 storyboard_video_tasks 表添加 duration 字段
-- 执行时间2025-11-19
ALTER TABLE storyboard_video_tasks
ADD COLUMN duration INT NOT NULL DEFAULT 10 COMMENT '视频时长(秒)' AFTER hd_mode;
-- 更新现有记录设置默认值为10秒
UPDATE storyboard_video_tasks SET duration = 10 WHERE duration IS NULL OR duration = 0;

View File

@@ -1,9 +0,0 @@
-- 修复 TaskStatus 表的 result_url 字段长度问题
-- 日期: 2025-11-19
-- 原因: Base64 编码的分镜图超过 MEDIUMTEXT 限制
-- 修改 task_status 表的 result_url 字段为 LONGTEXT
ALTER TABLE task_status MODIFY COLUMN result_url LONGTEXT COMMENT '任务结果URL或Base64数据';
-- 验证修改
SHOW FULL COLUMNS FROM task_status WHERE Field = 'result_url';

View File

@@ -1,55 +0,0 @@
-- =====================================================
-- 用户错误日志表
-- 用于记录和统计用户操作过程中产生的错误
-- =====================================================
CREATE TABLE IF NOT EXISTS user_error_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) COMMENT '用户名(可为空,未登录用户)',
error_type VARCHAR(50) NOT NULL COMMENT '错误类型枚举',
error_code VARCHAR(50) COMMENT '错误代码',
error_message TEXT COMMENT '错误消息',
error_source VARCHAR(100) NOT NULL COMMENT '错误来源(服务类名或接口路径)',
task_id VARCHAR(100) COMMENT '关联的任务ID',
task_type VARCHAR(50) COMMENT '任务类型',
request_path VARCHAR(500) COMMENT '请求路径',
request_method VARCHAR(10) COMMENT '请求方法 GET/POST等',
request_params TEXT COMMENT '请求参数JSON格式',
stack_trace TEXT COMMENT '堆栈跟踪',
ip_address VARCHAR(50) COMMENT 'IP地址',
user_agent VARCHAR(500) COMMENT '用户代理',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_username (username),
INDEX idx_error_type (error_type),
INDEX idx_created_at (created_at),
INDEX idx_error_source (error_source),
INDEX idx_task_id (task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户错误日志表';
-- =====================================================
-- 错误类型说明:
-- TASK_SUBMIT_ERROR - 任务提交失败
-- TASK_PROCESSING_ERROR - 任务处理失败
-- TASK_TIMEOUT - 任务超时
-- TASK_CANCELLED - 任务取消
-- API_CALL_ERROR - API调用失败
-- API_RESPONSE_ERROR - API响应异常
-- API_TIMEOUT - API超时
-- PAYMENT_ERROR - 支付失败
-- PAYMENT_CALLBACK_ERROR- 支付回调异常
-- REFUND_ERROR - 退款失败
-- AUTH_ERROR - 认证失败
-- TOKEN_EXPIRED - Token过期
-- PERMISSION_DENIED - 权限不足
-- DATA_VALIDATION_ERROR - 数据验证失败
-- DATA_NOT_FOUND - 数据未找到
-- DATA_CONFLICT - 数据冲突
-- FILE_UPLOAD_ERROR - 文件上传失败
-- FILE_DOWNLOAD_ERROR - 文件下载失败
-- FILE_PROCESS_ERROR - 文件处理失败
-- SYSTEM_ERROR - 系统错误
-- DATABASE_ERROR - 数据库错误
-- NETWORK_ERROR - 网络错误
-- UNKNOWN - 未知错误
-- =====================================================

View File

@@ -1,38 +0,0 @@
-- ============================================
-- 数据库结构更新脚本 (完整版)
-- 日期: 2025-12-03
-- 说明: 将存储Base64图片的字段从TEXT升级为LONGTEXT
-- ============================================
-- 选择数据库
USE aigc_platform;
-- ============================================
-- 1. storyboard_video_tasks 表 (分镜视频任务)
-- ============================================
ALTER TABLE storyboard_video_tasks MODIFY COLUMN image_url LONGTEXT;
ALTER TABLE storyboard_video_tasks MODIFY COLUMN result_url LONGTEXT;
ALTER TABLE storyboard_video_tasks MODIFY COLUMN storyboard_images LONGTEXT;
ALTER TABLE storyboard_video_tasks MODIFY COLUMN video_urls LONGTEXT;
-- ============================================
-- 2. user_works 表 (用户作品)
-- ============================================
ALTER TABLE user_works MODIFY COLUMN result_url LONGTEXT;
ALTER TABLE user_works MODIFY COLUMN thumbnail_url LONGTEXT;
-- ============================================
-- 3. image_to_video_tasks 表 (图生视频任务)
-- ============================================
ALTER TABLE image_to_video_tasks MODIFY COLUMN result_url LONGTEXT;
-- ============================================
-- 验证结果
-- ============================================
SELECT '=== 验证修改结果 ===' AS info;
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'aigc_platform'
AND COLUMN_NAME IN ('image_url', 'result_url', 'thumbnail_url', 'storyboard_images', 'video_urls')
AND TABLE_NAME IN ('storyboard_video_tasks', 'user_works', 'image_to_video_tasks')
ORDER BY TABLE_NAME, COLUMN_NAME;

View File

@@ -1,14 +1,14 @@
<svg width="194" height="41" viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="black"/>
<g clip-path="url(#clip0_1233_5144)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0_linear_1233_5144)"/>
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM93.1291 38.6957C91.2536 38.6957 89.6317 38.2973 88.2633 37.5004C86.903 36.6955 85.8526 35.5766 85.112 34.1439C84.3715 32.7031 84.0012 31.0328 84.0012 29.1332C84.0012 27.2175 84.3715 25.5432 85.112 24.1105C85.8526 22.6697 86.903 21.5508 88.2633 20.7539C89.6317 19.949 91.2536 19.5466 93.1291 19.5466C95.0046 19.5466 96.6225 19.949 97.9828 20.7539C99.3511 21.5508 100.406 22.6697 101.146 24.1105C101.887 25.5432 102.257 27.2175 102.257 29.1332C102.257 31.0328 101.887 32.7031 101.146 34.1439C100.406 35.5766 99.3511 36.6955 97.9828 37.5004C96.6225 38.2973 95.0046 38.6957 93.1291 38.6957ZM93.1532 34.7113C94.0065 34.7113 94.7188 34.4699 95.2903 33.9869C95.8618 33.4959 96.2924 32.8278 96.5822 31.9826C96.88 31.1375 97.0289 30.1756 97.0289 29.097C97.0289 28.0184 96.88 27.0565 96.5822 26.2113C96.2924 25.3662 95.8618 24.6981 95.2903 24.2071C94.7188 23.7161 94.0065 23.4706 93.1532 23.4706C92.292 23.4706 91.5675 23.7161 90.9799 24.2071C90.4004 24.6981 89.9617 25.3662 89.6639 26.2113C89.3741 27.0565 89.2292 28.0184 89.2292 29.097C89.2292 30.1756 89.3741 31.1375 89.6639 31.9826C89.9617 32.8278 90.4004 33.4959 90.9799 33.9869C91.5675 34.4699 92.292 34.7113 93.1532 34.7113ZM110.745 27.6119V38.3335H105.601V19.788H110.503V23.0601H110.721C111.131 21.9815 111.819 21.1282 112.785 20.5004C113.751 19.8645 114.922 19.5466 116.299 19.5466C117.587 19.5466 118.71 19.8283 119.667 20.3917C120.625 20.9552 121.37 21.7601 121.901 22.8065C122.432 23.8449 122.698 25.0844 122.698 26.5253V38.3335H117.555V27.4429C117.563 26.3079 117.273 25.4225 116.685 24.7866C116.098 24.1427 115.289 23.8207 114.258 23.8207C113.566 23.8207 112.954 23.9696 112.423 24.2674C111.9 24.5653 111.489 24.9999 111.192 25.5714C110.902 26.1349 110.753 26.815 110.745 27.6119ZM135.131 38.6957C133.256 38.6957 131.634 38.2973 130.265 37.5004C128.905 36.6955 127.855 35.5766 127.114 34.1439C126.373 32.7031 126.003 31.0328 126.003 29.1332C126.003 27.2175 126.373 25.5432 127.114 24.1105C127.855 22.6697 128.905 21.5508 130.265 20.7539C131.634 19.949 133.256 19.5466 135.131 19.5466C137.007 19.5466 138.624 19.949 139.985 20.7539C141.353 21.5508 142.408 22.6697 143.148 24.1105C143.889 25.5432 144.259 27.2175 144.259 29.1332C144.259 31.0328 143.889 32.7031 143.148 34.1439C142.408 35.5766 141.353 36.6955 139.985 37.5004C138.624 38.2973 137.007 38.6957 135.131 38.6957ZM135.155 34.7113C136.008 34.7113 136.721 34.4699 137.292 33.9869C137.864 33.4959 138.294 32.8278 138.584 31.9826C138.882 31.1375 139.031 30.1756 139.031 29.097C139.031 28.0184 138.882 27.0565 138.584 26.2113C138.294 25.3662 137.864 24.6981 137.292 24.2071C136.721 23.7161 136.008 23.4706 135.155 23.4706C134.294 23.4706 133.569 23.7161 132.982 24.2071C132.402 24.6981 131.964 25.3662 131.666 26.2113C131.376 27.0565 131.231 28.0184 131.231 29.097C131.231 30.1756 131.376 31.1375 131.666 31.9826C131.964 32.8278 132.402 33.4959 132.982 33.9869C133.569 34.4699 134.294 34.7113 135.155 34.7113ZM150.797 38.3335L145.75 19.788H150.954L153.827 32.2483H153.996L156.991 19.788H162.098L165.141 32.1758H165.298L168.123 19.788H173.315L168.28 38.3335H162.835L159.647 26.6701H159.418L156.23 38.3335H150.797Z" fill="#1D2129"/>
<g clip-path="url(#clip0_445_10776)">
<path d="M5.74048 1.6455C2.43981 1.6455 0.0100346 1.57286 0.000244138 1.6455C0.000244144 1.71889 2.11281 5.37372 4.7063 9.58593C7.28518 13.8128 12.3555 22.0903 15.9543 27.9609L20.8684 35.9883C21.8795 37.6395 23.6773 38.6455 25.6135 38.6455L26.4299 38.6455C26.4299 38.6455 31.0566 38.7202 31.9963 37.2227C32.2253 36.8286 32.3459 36.3806 32.3459 35.9248L32.3459 21.8994L31.1799 21.8994L31.1799 26.5967C31.1799 31.1021 31.1657 31.3081 30.8889 31.5869C30.7286 31.7483 30.4661 31.8799 30.3059 31.8799C29.9854 31.8798 29.7815 31.5857 27.1448 27.2568C26.256 25.8038 24.0844 22.237 22.2922 19.3311C20.5147 16.4251 17.3532 11.2594 15.2698 7.85449L11.467 1.64551L5.74048 1.6455ZM31.6614 2.99609C31.4428 4.17012 30.5249 6.06336 29.6653 7.10547C28.456 8.60245 26.4303 9.83514 24.5071 10.2314C23.4726 10.4516 23.1959 10.5986 23.8079 10.5986C24.0266 10.5987 24.58 10.701 25.0315 10.833C28.2661 11.7576 31.0493 14.7672 31.6467 17.9814L31.8362 18.9795L32.011 18.1133C32.5647 15.3247 34.4441 12.8149 36.9065 11.5674C38.0138 10.995 39.2814 10.5986 39.9807 10.5986C40.6216 10.5986 40.4029 10.4956 39.2083 10.2461C37.4599 9.87917 36.1048 9.11605 34.6624 7.66309C33.2637 6.26884 32.5495 5.02109 32.0833 3.17187L31.8215 2.11523L31.6614 2.99609Z" fill="url(#paint0_linear_445_10776)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1233_5144" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<linearGradient id="paint0_linear_445_10776" x1="23.4864" y1="5.39407" x2="32.9094" y2="11.1241" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0_1233_5144">
<clipPath id="clip0_445_10776">
<rect width="40.3334" height="40.3334" fill="white"/>
</clipPath>
</defs>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,15 +1,15 @@
<svg width="194" height="41" viewBox="0 0 194 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.6165 13.6062L60.593 32.3932H60.8224L66.8111 13.6062H72.6065L64.0824 38.3335H57.3452L48.8089 13.6062H54.6165ZM75.4862 38.3335V19.788H80.6296V38.3335H75.4862ZM78.07 17.3974C77.3053 17.3974 76.6493 17.1439 76.1019 16.6368C75.5626 16.1216 75.293 15.5058 75.293 14.7895C75.293 14.0811 75.5626 13.4734 76.1019 12.9663C76.6493 12.4512 77.3053 12.1936 78.07 12.1936C78.8346 12.1936 79.4866 12.4512 80.0259 12.9663C80.5733 13.4734 80.8469 14.0811 80.8469 14.7895C80.8469 15.5058 80.5733 16.1216 80.0259 16.6368C79.4866 17.1439 78.8346 17.3974 78.07 17.3974ZM91.5836 38.6353C90.175 38.6353 88.8992 38.2731 87.7562 37.5487C86.6213 36.8162 85.7198 35.7416 85.0517 34.325C84.3916 32.9003 84.0616 31.1536 84.0616 29.0849C84.0616 26.9599 84.4037 25.1931 85.0879 23.7845C85.7721 22.3678 86.6816 21.3093 87.8166 20.6091C88.9596 19.9007 90.2112 19.5466 91.5716 19.5466C92.6099 19.5466 93.4752 19.7236 94.1674 20.0778C94.8677 20.4239 95.4312 20.8586 95.8578 21.3818C96.2924 21.8969 96.6225 22.404 96.8478 22.9031H97.0048V13.6062H102.136V38.3335H97.0652V35.3633H96.8478C96.6064 35.8785 96.2643 36.3896 95.8216 36.8967C95.3869 37.3958 94.8194 37.8103 94.1191 38.1403C93.4269 38.4703 92.5817 38.6353 91.5836 38.6353ZM93.2136 34.5423C94.0427 34.5423 94.743 34.3169 95.3145 33.8662C95.894 33.4074 96.3367 32.7674 96.6426 31.9464C96.9565 31.1254 97.1135 30.1635 97.1135 29.0608C97.1135 27.958 96.9605 27.0002 96.6547 26.1872C96.3488 25.3742 95.9061 24.7464 95.3265 24.3037C94.747 23.861 94.0427 23.6396 93.2136 23.6396C92.3684 23.6396 91.6561 23.869 91.0765 24.3278C90.497 24.7866 90.0583 25.4225 89.7605 26.2355C89.4627 27.0485 89.3137 27.9902 89.3137 29.0608C89.3137 30.1394 89.4627 31.0932 89.7605 31.9223C90.0663 32.7433 90.505 33.3872 91.0765 33.8541C91.6561 34.3129 92.3684 34.5423 93.2136 34.5423ZM106.462 38.3335V13.6062H122.834V17.9166H111.69V23.8086H121.747V28.119H111.69V38.3335H106.462ZM131.397 13.6062V38.3335H126.254V13.6062H131.397ZM143.897 38.6957C142.021 38.6957 140.399 38.2973 139.031 37.5004C137.671 36.6955 136.62 35.5766 135.88 34.1439C135.139 32.7031 134.769 31.0328 134.769 29.1332C134.769 27.2175 135.139 25.5432 135.88 24.1105C136.62 22.6697 137.671 21.5508 139.031 20.7539C140.399 19.949 142.021 19.5466 143.897 19.5466C145.772 19.5466 147.39 19.949 148.75 20.7539C150.119 21.5508 151.173 22.6697 151.914 24.1105C152.654 25.5432 153.025 27.2175 153.025 29.1332C153.025 31.0328 152.654 32.7031 151.914 34.1439C151.173 35.5766 150.119 36.6955 148.75 37.5004C147.39 38.2973 145.772 38.6957 143.897 38.6957ZM143.921 34.7113C144.774 34.7113 145.486 34.4699 146.058 33.9869C146.629 33.4959 147.06 32.8278 147.35 31.9826C147.648 31.1375 147.797 30.1756 147.797 29.097C147.797 28.0184 147.648 27.0565 147.35 26.2113C147.06 25.3662 146.629 24.6981 146.058 24.2071C145.486 23.7161 144.774 23.4706 143.921 23.4706C143.06 23.4706 142.335 23.7161 141.748 24.2071C141.168 24.6981 140.729 25.3662 140.431 26.2113C140.142 27.0565 139.997 28.0184 139.997 29.097C139.997 30.1756 140.142 31.1375 140.431 31.9826C140.729 32.8278 141.168 33.4959 141.748 33.9869C142.335 34.4699 143.06 34.7113 143.921 34.7113ZM159.562 38.3335L154.516 19.788H159.719L162.593 32.2483H162.762L165.756 19.788H170.864L173.906 32.1758H174.063L176.888 19.788H182.08L177.045 38.3335H171.6L168.413 26.6701H168.183L164.996 38.3335H159.562Z" fill="white"/>
<g clip-path="url(#clip0_1233_5144)">
<path d="M5.7406 1.64568C2.43935 1.64568 0.00938034 1.57298 0.000366208 1.64568C0.000366202 1.71906 2.11292 5.37389 4.70642 9.58611C7.28533 13.813 12.3557 22.0905 15.9545 27.9611L20.8685 35.9875C21.8796 37.6388 23.6773 38.6457 25.6136 38.6457L26.4301 38.6457C26.4301 38.6457 31.0568 38.7204 31.9965 37.2228C32.2255 36.8288 32.346 36.3807 32.3461 35.925L32.3461 21.8996L31.1801 21.8996L31.1801 26.5969C31.1801 31.1005 31.1655 31.3074 30.889 31.5861C30.7288 31.7476 30.4663 31.8801 30.306 31.8801C29.9855 31.8801 29.7811 31.5866 27.1439 27.257C26.2551 25.8039 24.0844 22.2371 22.2924 19.3312C20.5148 16.4253 17.3534 11.2596 15.2699 7.85467L11.4672 1.64568L5.7406 1.64568ZM31.6615 2.99627C31.443 4.1703 30.525 6.06354 29.6654 7.10564C28.4561 8.60263 26.4304 9.83531 24.5072 10.2316C23.4727 10.4518 23.196 10.5988 23.808 10.5988C24.0267 10.5989 24.5801 10.7012 25.0316 10.8332C28.2662 11.7578 31.0495 14.7674 31.6468 17.9816L31.8363 18.9797L32.0111 18.1135C32.5648 15.3249 34.4443 12.8151 36.9066 11.5676C38.0139 10.9952 39.2815 10.5988 39.9808 10.5988C40.6217 10.5988 40.403 10.4957 39.2084 10.2463C37.46 9.87934 36.1049 9.11623 34.6625 7.66326C33.2638 6.26901 32.5496 5.02127 32.0834 3.17205L31.8217 2.11541L31.6615 2.99627Z" fill="url(#paint0_linear_1233_5144)"/>
<svg width="101" height="21" viewBox="0 0 101 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.4343 7.58893L31.5459 17.3698H31.6653L34.7831 7.58893H37.8003L33.3625 20.4624H29.855L25.4108 7.58893H28.4343ZM39.2995 20.4624V10.8073H41.9773V20.4624H39.2995ZM40.6447 9.56269C40.2466 9.56269 39.905 9.43069 39.6201 9.16668C39.3393 8.89848 39.1989 8.5779 39.1989 8.20494C39.1989 7.83617 39.3393 7.51978 39.6201 7.25577C39.905 6.98758 40.2466 6.85348 40.6447 6.85348C41.0428 6.85348 41.3822 6.98758 41.663 7.25577C41.9479 7.51978 42.0904 7.83617 42.0904 8.20494C42.0904 8.5779 41.9479 8.89848 41.663 9.16668C41.3822 9.43069 41.0428 9.56269 40.6447 9.56269ZM48.4847 20.651C47.5083 20.651 46.6639 20.4435 45.9515 20.0287C45.2433 19.6096 44.6964 19.0271 44.3109 18.2812C43.9254 17.5311 43.7326 16.6615 43.7326 15.6726C43.7326 14.6752 43.9254 13.8036 44.3109 13.0576C44.6964 12.3075 45.2433 11.725 45.9515 11.3102C46.6639 10.8911 47.5083 10.6816 48.4847 10.6816C49.4611 10.6816 50.3034 10.8911 51.0116 11.3102C51.724 11.725 52.273 12.3075 52.6585 13.0576C53.0441 13.8036 53.2368 14.6752 53.2368 15.6726C53.2368 16.6615 53.0441 17.5311 52.6585 18.2812C52.273 19.0271 51.724 19.6096 51.0116 20.0287C50.3034 20.4435 49.4611 20.651 48.4847 20.651ZM48.4973 18.5766C48.9415 18.5766 49.3124 18.4509 49.6099 18.1995C49.9074 17.9439 50.1316 17.596 50.2825 17.156C50.4375 16.716 50.5151 16.2152 50.5151 15.6537C50.5151 15.0922 50.4375 14.5914 50.2825 14.1514C50.1316 13.7114 49.9074 13.3636 49.6099 13.1079C49.3124 12.8523 48.9415 12.7245 48.4973 12.7245C48.0489 12.7245 47.6717 12.8523 47.3658 13.1079C47.0641 13.3636 46.8357 13.7114 46.6807 14.1514C46.5298 14.5914 46.4544 15.0922 46.4544 15.6537C46.4544 16.2152 46.5298 16.716 46.6807 17.156C46.8357 17.596 47.0641 17.9439 47.3658 18.1995C47.6717 18.4509 48.0489 18.5766 48.4973 18.5766ZM57.6558 14.8805V20.4624H54.978V10.8073H57.5301V12.5108H57.6432C57.857 11.9492 58.2153 11.505 58.7181 11.1782C59.221 10.8471 59.8307 10.6816 60.5473 10.6816C61.2178 10.6816 61.8024 10.8282 62.3011 11.1216C62.7998 11.4149 63.1874 11.834 63.464 12.3788C63.7405 12.9193 63.8788 13.5647 63.8788 14.3148V20.4624H61.2011V14.7925C61.2052 14.2017 61.0544 13.7407 60.7485 13.4096C60.4426 13.0744 60.0214 12.9068 59.485 12.9068C59.1246 12.9068 58.8061 12.9843 58.5296 13.1394C58.2572 13.2944 58.0434 13.5207 57.8884 13.8182C57.7375 14.1116 57.66 14.4657 57.6558 14.8805ZM70.3517 20.651C69.3753 20.651 68.5309 20.4435 67.8185 20.0287C67.1103 19.6096 66.5634 19.0271 66.1779 18.2812C65.7924 17.5311 65.5996 16.6615 65.5996 15.6726C65.5996 14.6752 65.7924 13.8036 66.1779 13.0576C66.5634 12.3075 67.1103 11.725 67.8185 11.3102C68.5309 10.8911 69.3753 10.6816 70.3517 10.6816C71.3281 10.6816 72.1704 10.8911 72.8786 11.3102C73.591 11.725 74.14 12.3075 74.5255 13.0576C74.9111 13.8036 75.1038 14.6752 75.1038 15.6726C75.1038 16.6615 74.9111 17.5311 74.5255 18.2812C74.14 19.0271 73.591 19.6096 72.8786 20.0287C72.1704 20.4435 71.3281 20.651 70.3517 20.651ZM70.3643 18.5766C70.8085 18.5766 71.1794 18.4509 71.4769 18.1995C71.7744 17.9439 71.9986 17.596 72.1495 17.156C72.3045 16.716 72.3821 16.2152 72.3821 15.6537C72.3821 15.0922 72.3045 14.5914 72.1495 14.1514C71.9986 13.7114 71.7744 13.3636 71.4769 13.1079C71.1794 12.8523 70.8085 12.7245 70.3643 12.7245C69.9159 12.7245 69.5387 12.8523 69.2328 13.1079C68.9311 13.3636 68.7027 13.7114 68.5477 14.1514C68.3968 14.5914 68.3214 15.0922 68.3214 15.6537C68.3214 16.2152 68.3968 16.716 68.5477 17.156C68.7027 17.596 68.9311 17.9439 69.2328 18.1995C69.5387 18.4509 69.9159 18.5766 70.3643 18.5766ZM78.5076 20.4624L75.8801 10.8073H78.5894L80.0854 17.2943H80.1734L81.7323 10.8073H84.3912L85.9753 17.2566H86.057L87.5279 10.8073H90.2308L87.6096 20.4624H84.7747L83.1152 14.3902H82.9958L81.3363 20.4624H78.5076Z" fill="white"/>
<g clip-path="url(#clip0_287_18768)">
<path d="M2.98853 0.856444C1.27414 0.856441 0.0111275 0.81906 0.000244139 0.856443C0.000244136 0.894647 1.10026 2.79734 2.45044 4.99023C3.79308 7.19083 6.43228 11.5012 8.30591 14.5576L10.8645 18.7363C11.3909 19.5959 12.3272 20.1201 13.3352 20.1201L13.76 20.1201C13.76 20.1201 16.1679 20.1583 16.6575 19.3789C16.7767 19.1737 16.8401 18.9404 16.8401 18.7031L16.8401 11.4014L16.2327 11.4014L16.2327 13.8467C16.2327 16.1919 16.2254 16.2992 16.0813 16.4443C15.9979 16.5284 15.861 16.5977 15.7776 16.5977C15.6109 16.5975 15.5042 16.4431 14.1321 14.1904C13.6694 13.434 12.5387 11.5774 11.6057 10.0645C10.6803 8.55154 9.03419 5.86157 7.94946 4.08887L5.96997 0.856444L2.98853 0.856444ZM16.4836 1.55957C16.3699 2.17078 15.8921 3.15667 15.4446 3.69922C14.815 4.47857 13.7603 5.12082 12.759 5.32715C12.2205 5.44176 12.0762 5.51758 12.3948 5.51758C12.5085 5.51759 12.7965 5.57095 13.0315 5.63965C14.7155 6.12103 16.1648 7.68795 16.4758 9.36133L16.5745 9.88086L16.6653 9.42969C16.9536 7.97804 17.9323 6.67195 19.2141 6.02246C19.7906 5.72446 20.4506 5.51758 20.8147 5.51758C21.1484 5.51757 21.0343 5.46388 20.4124 5.33398C19.5023 5.14293 18.797 4.74562 18.0461 3.98926C17.318 3.26338 16.9461 2.61411 16.7034 1.65137L16.5667 1.10156L16.4836 1.55957Z" fill="url(#paint0_linear_287_18768)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1233_5144" x1="23.4862" y1="5.3947" x2="32.9093" y2="11.1248" gradientUnits="userSpaceOnUse">
<linearGradient id="paint0_linear_287_18768" x1="12.2274" y1="2.80849" x2="17.1333" y2="5.79162" gradientUnits="userSpaceOnUse">
<stop offset="0.0001" stop-color="#33DDE5"/>
<stop offset="1" stop-color="#0F9CFF"/>
</linearGradient>
<clipPath id="clip0_1233_5144">
<rect width="40.3334" height="40.3334" fill="white"/>
<clipPath id="clip0_287_18768">
<rect width="20.9983" height="20.9983" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -57,6 +57,40 @@ export const imageToVideoApi = {
})
},
/**
* 通过图片URL创建图生视频任务用于"做同款"功能)
* @param {Object} params - 任务参数
* @param {string} params.imageUrl - 图片URL
* @param {string} params.prompt - 描述文字
* @param {string} params.aspectRatio - 视频比例
* @param {number} params.duration - 视频时长
* @param {boolean} params.hdMode - 是否高清模式
* @returns {Promise} API响应
*/
createTaskByUrl(params) {
if (!params) {
throw new Error('参数不能为空')
}
if (!params.imageUrl) {
throw new Error('图片URL不能为空')
}
if (!params.prompt || params.prompt.trim() === '') {
throw new Error('描述文字不能为空')
}
return request({
url: '/image-to-video/create-by-url',
method: 'POST',
data: {
imageUrl: params.imageUrl,
prompt: params.prompt.trim(),
aspectRatio: params.aspectRatio || '16:9',
duration: params.duration || 5,
hdMode: params.hdMode || false
}
})
},
/**
* 获取用户任务列表
* @param {number} page - 页码

View File

@@ -4,7 +4,7 @@
<!-- Logo -->
<div class="navbar-brand">
<router-link to="/" class="brand-link">
<img src="/images/backgrounds/logo.svg" alt="Logo" class="brand-logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" class="brand-logo" />
</router-link>
</div>

View File

@@ -140,14 +140,39 @@ const qrCodeUrl = ref('') // 二维码URL
const showQrCode = ref(false) // 是否显示二维码
let paymentPollingTimer = null
let isPaymentStarted = false // 防止重复调用
let lastPlanType = '' // 记录上一次的套餐类型
// 从orderId中提取套餐类型如 SUB_standard_xxx -> standard
const getPlanTypeFromOrderId = (orderId) => {
if (!orderId) return ''
const parts = orderId.split('_')
return parts.length >= 2 ? parts[1] : ''
}
// 监听 modelValue 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
// 当模态框打开时,自动开始支付流程(只调用一次)
if (newVal && !isPaymentStarted) {
isPaymentStarted = true
handlePay()
// 当模态框打开时,检查是否需要创建新订单
if (newVal) {
const currentPlanType = getPlanTypeFromOrderId(props.orderId)
// 如果套餐类型变化了重置paymentId
if (currentPlanType !== lastPlanType) {
console.log('套餐变化,重置 paymentId旧套餐:', lastPlanType, '新套餐:', currentPlanType)
currentPaymentId.value = null
lastPlanType = currentPlanType
} else {
console.log('同一套餐,复用 paymentId:', currentPaymentId.value)
}
qrCodeUrl.value = ''
showQrCode.value = false
// 只有选择支付宝时才自动生成二维码PayPal需要用户点击按钮
if (!isPaymentStarted && selectedMethod.value === 'alipay') {
isPaymentStarted = true
handlePay()
}
}
// 关闭时重置标志
if (!newVal) {
@@ -158,7 +183,7 @@ watch(() => props.modelValue, (newVal) => {
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
// 如果模态框关闭,停止轮询并重置状态
// 如果模态框关闭,停止轮询并重置二维码状态但保留paymentId和lastPlanType用于同套餐复用
if (!newVal) {
stopPaymentPolling()
qrCodeUrl.value = ''
@@ -167,13 +192,62 @@ watch(visible, (newVal) => {
})
// 选择支付方式
const selectMethod = (method) => {
// 如果切换了支付方式,需要重置 paymentId因为支付方式不同需要创建新的支付记录
const selectMethod = async (method) => {
if (selectedMethod.value !== method) {
currentPaymentId.value = null
console.log('切换支付方式,重置 paymentId')
console.log('切换支付方式:', selectedMethod.value, '->', method, '复用 paymentId:', currentPaymentId.value)
selectedMethod.value = method
// 重置二维码显示
qrCodeUrl.value = ''
showQrCode.value = false
// 如果已有支付记录更新支付方式和描述复用paymentId
if (currentPaymentId.value) {
try {
const newDescription = `${props.title} - ${method === 'paypal' ? 'PayPal' : '支付宝'}支付`
const newMethod = method === 'paypal' ? 'PAYPAL' : 'ALIPAY'
console.log('=== 开始更新支付方式 ===')
console.log('paymentId:', currentPaymentId.value)
console.log('newMethod:', newMethod)
console.log('newDescription:', newDescription)
const response = await fetch(`/api/payments/${currentPaymentId.value}/method`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
method: newMethod,
description: newDescription
})
})
console.log('API响应状态:', response.status)
const responseData = await response.json()
console.log('API响应数据:', responseData)
if (response.ok && responseData.success) {
console.log('✅ 支付方式更新成功复用paymentId:', currentPaymentId.value)
// 如果切换到支付宝,生成新二维码
if (method === 'alipay') {
await handleAlipayPayment()
}
} else {
console.error('❌ 支付方式更新失败:', responseData.message || response.statusText)
}
} catch (error) {
console.error('❌ 更新支付方式异常:', error)
}
} else {
// 没有支付记录,如果是支付宝则自动创建
if (method === 'alipay') {
isPaymentStarted = true
await handlePay()
}
}
}
selectedMethod.value = method
}
// 处理支付
@@ -183,10 +257,9 @@ const handlePay = async () => {
// 如果还没有创建支付记录,先创建
if (!currentPaymentId.value) {
// 生成唯一的订单ID加上时间戳避免重复
const uniqueOrderId = `${props.orderId}_${Date.now()}`
// 直接使用传入的orderId不再添加时间戳Subscription.vue已经处理了
const paymentData = {
orderId: uniqueOrderId,
orderId: props.orderId,
amount: props.amount.toString(),
method: selectedMethod.value === 'paypal' ? 'PAYPAL' : 'ALIPAY',
description: `${props.title} - ${selectedMethod.value === 'paypal' ? 'PayPal' : '支付宝'}支付`
@@ -204,6 +277,27 @@ const handlePay = async () => {
} else {
throw new Error(createResponse.data?.message || '创建支付记录失败')
}
} else {
// 已有支付记录,确保支付方式和描述与当前选择一致
try {
const newDescription = `${props.title} - ${selectedMethod.value === 'paypal' ? 'PayPal' : '支付宝'}支付`
const newMethod = selectedMethod.value === 'paypal' ? 'PAYPAL' : 'ALIPAY'
await fetch(`/api/payments/${currentPaymentId.value}/method`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
method: newMethod,
description: newDescription
})
})
console.log('支付方式已同步:', newMethod)
} catch (e) {
console.warn('同步支付方式失败,继续支付:', e)
}
}
// 根据选择的支付方式处理
@@ -332,6 +426,9 @@ const startPaymentPolling = (paymentId) => {
stopPaymentPolling()
ElMessage.success('支付成功!')
emit('pay-success', payment)
// 重置状态,下次购买生成新订单
currentPaymentId.value = null
lastPlanType = ''
// 延迟关闭模态框
setTimeout(() => {
visible.value = false
@@ -412,6 +509,9 @@ const manualCheckPayment = async () => {
stopPaymentPolling()
ElMessage.success('支付成功!')
emit('pay-success', payment)
// 重置状态,下次购买生成新订单
currentPaymentId.value = null
lastPlanType = ''
setTimeout(() => {
visible.value = false
}, 1500)

View File

@@ -507,6 +507,7 @@ export default {
permanent: 'Permanent',
remainingPoints: 'Remaining Points',
plans: 'Plans',
pointsPerYear: 'points/year',
currentPackage: 'Current Plan',
firstPurchaseDiscount: 'First Purchase Discount up to 15% off',
bestValue: 'Best Value',
@@ -518,6 +519,23 @@ export default {
commercialUse: 'Commercial Use',
noWatermark: 'No Watermark',
earlyAccess: 'Early Access to New Features',
// Points and items
points: 'points',
items: 'items',
pointsValidOneYear: 'Points valid for one year',
textToVideo30Points: 'Text to Video: 30 points/item',
imageToVideo30Points: 'Image to Video: 30 points/item',
storyboardImage30Points: 'Storyboard Image: 30 points/time',
storyboardVideo30Points: 'Storyboard Video: 30 points/item',
maxTextToVideo: 'Max Text to Video {count} items',
maxImageToVideo: 'Max Image to Video {count} items',
maxStoryboardImage: 'Max Storyboard Image {count} times',
maxStoryboardVideo: 'Max Storyboard Video {count} items',
textToVideoItems: 'Text to Video {count} items',
imageToVideoItems: 'or Image to Video {count} items',
storyboardImageTimes: 'or Storyboard Image {count} times',
storyboardVideoItems: 'or Storyboard Video {count} items',
// Points history related
pointsUsageHistory: 'Points Usage History',
@@ -591,7 +609,11 @@ export default {
onlineUsers: 'Online Users',
systemUptime: 'System Uptime',
todayVisitors: 'Today Visitors',
loading: 'Loading...'
loading: 'Loading...',
memberManagement: 'Member Management',
orderManagement: 'Order Management',
taskRecord: 'Task Records',
errorStats: 'Error Statistics'
},
admin: {
@@ -668,7 +690,12 @@ export default {
batchDeleteSuccess: 'Batch delete successful',
batchDeleteFailed: 'Batch delete failed',
loadOrdersFailed: 'Failed to load orders',
apiDataFormatError: 'API data format error'
apiDataFormatError: 'API data format error',
productOrder: 'Product Order',
serviceOrder: 'Service Order',
subscriptionOrder: 'Subscription Order',
digitalProduct: 'Digital Product',
physicalProduct: 'Physical Product'
},
tasks: {
@@ -685,10 +712,27 @@ export default {
processing: 'Processing',
failed: 'Failed',
cancelled: 'Cancelled',
pending: 'Pending',
textToVideo: 'Text to Video',
imageToVideo: 'Image to Video',
storyboardVideo: 'Storyboard Video',
taskDetail: 'Task Detail'
taskDetail: 'Task Detail',
unknown: 'Unknown',
pointsUnit: ' points',
basicInfo: 'Basic Info',
timeInfo: 'Time Info',
progressInfo: 'Progress Info',
progress: 'Progress',
result: 'Result',
resultLink: 'Result Link',
viewResult: 'View Result',
errorInfo: 'Error Info',
close: 'Close',
updateTime: 'Update Time',
completeTime: 'Complete Time',
taskType: 'Task Type',
resourcesConsumed: 'Resources Consumed',
defaultPoints: '0 points'
},
members: {
@@ -706,22 +750,73 @@ export default {
usernamePlaceholder: 'Enter username',
levelPlaceholder: 'Select level',
pointsPlaceholder: 'Enter points',
expiryPlaceholder: 'Select expiry date'
expiryPlaceholder: 'Select expiry date',
memberLevel: 'Membership Level',
freeMember: 'Free Member',
standardMember: 'Standard Member',
professionalMember: 'Professional Member',
userStatus: 'User Status',
activeUsers: 'Active Users',
bannedUsers: 'Banned Users',
allUsers: 'All Users',
role: 'Role',
status: 'Status',
setAdmin: 'Set as Admin',
revokeAdmin: 'Revoke Admin',
ban: 'Ban',
unban: 'Unban',
active: 'Active',
banned: 'Banned',
superAdmin: 'Super Admin',
admin: 'Admin',
normalUser: 'User',
userRole: 'User Role',
selectRole: 'Select role',
confirmRoleChange: 'Are you sure to {action} user {username}?',
confirmBanAction: 'Are you sure to {action} user {username}?',
confirmAction: 'Confirm {action}',
actionSuccess: '{action} successful',
actionFailed: '{action} failed'
},
apiManagement: {
title: 'API Management',
apiKey: 'API Key',
apiKeyPlaceholder: 'Enter API key',
apiBaseUrl: 'API Base URL',
apiBaseUrlPlaceholder: 'Enter API base URL, e.g. https://ai.comfly.chat',
apiBaseUrlHint: 'Currently using',
tokenExpiration: 'Token Expiration',
tokenPlaceholder: 'Enter hours (1-720)',
hours: 'hours',
days: 'days',
rangeHint: 'Range: 1-720 hours (1 hour - 30 days)',
atLeastOneRequired: 'Please enter at least one configuration',
saveSuccess: 'Saved successfully',
saveFailed: 'Save failed'
},
errorStats: {
title: 'Error Statistics',
userAvatar: 'User Avatar',
totalErrors: 'Total Errors',
todayErrors: 'Today Errors',
weekErrors: 'Week Errors',
errorTypeDistribution: 'Error Type Distribution',
last7Days: 'Last 7 Days',
last30Days: 'Last 30 Days',
last90Days: 'Last 90 Days',
times: 'times',
noErrorData: 'No error data',
recentErrors: 'Recent Errors',
refresh: 'Refresh',
time: 'Time',
errorType: 'Error Type',
user: 'User',
taskId: 'Task ID',
errorMessage: 'Error Message'
},
systemSettings: {
title: 'System Settings',
membership: 'Membership Pricing',
@@ -801,8 +896,6 @@ export default {
unknown: 'Unknown',
aiModel: 'AI Model Settings',
promptOptimization: 'Prompt Optimization Settings',
promptOptimizationApiUrl: 'API Endpoint',
promptOptimizationApiUrlTip: 'Enter the API endpoint for prompt optimization, e.g., https://api.openai.com',
promptOptimizationModel: 'Model Name',
promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro',
storyboardSystemPrompt: 'Storyboard System Prompt',

View File

@@ -506,6 +506,7 @@ export default {
unlimited: '无限',
limited: '有限',
pointsPerMonth: '积分/年',
pointsPerYear: '积分/年',
videoQuality: '视频质量',
support: '客服支持',
priorityQueue: '优先队列',
@@ -532,6 +533,23 @@ export default {
commercialUse: '支持商用',
noWatermark: '下载去水印',
earlyAccess: '新功能优先体验',
// 积分和条数
points: '积分',
items: '条',
pointsValidOneYear: '积分一年有效',
textToVideo30Points: '文生视频30积分/条',
imageToVideo30Points: '图生视频30积分/条',
storyboardImage30Points: '分镜图生成30积分/次',
storyboardVideo30Points: '分镜视频生成30积分/条',
maxTextToVideo: '最多文生视频 {count}条',
maxImageToVideo: '最多图生视频 {count}条',
maxStoryboardImage: '最多分镜图 {count}次',
maxStoryboardVideo: '最多分镜视频 {count}条',
textToVideoItems: '文生视频 {count}条',
imageToVideoItems: '或图生视频 {count}条',
storyboardImageTimes: '或分镜图 {count}次',
storyboardVideoItems: '或分镜视频 {count}条',
// 积分历史相关
pointsUsageHistory: '积分使用情况',
@@ -604,7 +622,11 @@ export default {
systemSettings: '系统设置',
onlineUsers: '当前在线用户',
systemUptime: '系统运行时间',
todayVisitors: '今日访客'
todayVisitors: '今日访客',
memberManagement: '会员管理',
orderManagement: '订单管理',
taskRecord: '生成任务记录',
errorStats: '错误统计'
},
admin: {
@@ -669,7 +691,12 @@ export default {
orderDetail: '订单详情',
basicInfo: '基本信息',
orderType: '订单类型',
paymentInfo: '支付信息'
paymentInfo: '支付信息',
productOrder: '商品订单',
serviceOrder: '服务订单',
subscriptionOrder: '订阅订单',
digitalProduct: '数字商品',
physicalProduct: '实体商品'
},
tasks: {
@@ -686,10 +713,27 @@ export default {
processing: '处理中',
failed: '失败',
cancelled: '已取消',
pending: '待处理',
textToVideo: '文生视频',
imageToVideo: '图生视频',
storyboardVideo: '分镜视频',
taskDetail: '任务详情'
taskDetail: '任务详情',
unknown: '未知',
pointsUnit: '积分',
basicInfo: '基本信息',
timeInfo: '时间信息',
progressInfo: '进度信息',
progress: '进度',
result: '结果',
resultLink: '结果链接',
viewResult: '查看结果',
errorInfo: '错误信息',
close: '关闭',
updateTime: '更新时间',
completeTime: '完成时间',
taskType: '任务类型',
resourcesConsumed: '消耗资源',
defaultPoints: '0积分'
},
members: {
@@ -707,22 +751,73 @@ export default {
usernamePlaceholder: '请输入用户名',
levelPlaceholder: '请选择会员等级',
pointsPlaceholder: '请输入资源点',
expiryPlaceholder: '请选择到期时间'
expiryPlaceholder: '请选择到期时间',
memberLevel: '会员等级',
freeMember: '免费会员',
standardMember: '标准会员',
professionalMember: '专业会员',
userStatus: '用户状态',
activeUsers: '活跃用户',
bannedUsers: '封禁用户',
allUsers: '全部用户',
role: '角色',
status: '状态',
setAdmin: '设为管理员',
revokeAdmin: '取消管理员',
ban: '封禁',
unban: '解封',
active: '活跃',
banned: '封禁',
superAdmin: '超级管理员',
admin: '管理员',
normalUser: '普通用户',
userRole: '用户角色',
selectRole: '请选择用户角色',
confirmRoleChange: '确定要将用户 {username} {action}吗?',
confirmBanAction: '确定要{action}用户 {username} 吗?',
confirmAction: '确认{action}',
actionSuccess: '{action}成功',
actionFailed: '{action}失败'
},
apiManagement: {
title: 'API管理',
apiKey: 'API密钥',
apiKeyPlaceholder: '请输入API密钥',
apiBaseUrl: 'API基础URL',
apiBaseUrlPlaceholder: '请输入API基础URL如 https://ai.comfly.chat',
apiBaseUrlHint: '当前使用',
tokenExpiration: 'Token过期时间',
tokenPlaceholder: '请输入小时数1-720',
hours: '小时',
days: '天',
rangeHint: '范围1-720小时1小时-30天',
atLeastOneRequired: '请至少输入一个配置项',
saveSuccess: '保存成功',
saveFailed: '保存失败'
},
errorStats: {
title: '错误类型统计',
userAvatar: '用户头像',
totalErrors: '总错误数',
todayErrors: '今日错误',
weekErrors: '本周错误',
errorTypeDistribution: '错误类型分布',
last7Days: '最近7天',
last30Days: '最近30天',
last90Days: '最近90天',
times: '次',
noErrorData: '暂无错误数据',
recentErrors: '最近错误',
refresh: '刷新',
time: '时间',
errorType: '错误类型',
user: '用户',
taskId: '任务ID',
errorMessage: '错误信息'
},
systemSettings: {
title: '系统设置',
membership: '会员收费标准',
@@ -779,8 +874,6 @@ export default {
confirmCleanup: '确认清理',
aiModel: 'AI模型设置',
promptOptimization: '提示词优化设置',
promptOptimizationApiUrl: 'API端点',
promptOptimizationApiUrlTip: '输入用于优化提示词的API端点地址如 https://api.openai.com',
promptOptimizationModel: '模型名称',
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
storyboardSystemPrompt: '分镜图系统引导词',

View File

@@ -49,11 +49,10 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input">
<div class="page-title">
<h2>{{ $t('nav.dashboard') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
@@ -665,36 +664,11 @@ onUnmounted(() => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {

View File

@@ -56,12 +56,11 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input" v-model="searchText" />
</div>
<div class="page-title">
<h2>{{ $t('nav.orderManagement') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
<el-dropdown v-if="isAdminMode" @command="handleUserCommand">
@@ -229,11 +228,11 @@
style="width: 120px;"
@change="handleStatusChange"
>
<el-option label="待支付" value="PENDING" />
<el-option label="已支付" value="PAID" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已取消" value="CANCELLED" />
<el-option label="已退款" value="REFUNDED" />
<el-option :label="$t('orders.pending')" value="PENDING" />
<el-option :label="$t('orders.paid')" value="PAID" />
<el-option :label="$t('orders.completed')" value="COMPLETED" />
<el-option :label="$t('orders.cancelled')" value="CANCELLED" />
<el-option :label="$t('orders.refunded')" value="REFUNDED" />
</el-select>
<span v-else class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
{{ getStatusText(currentOrderDetail.status) }}
@@ -410,15 +409,15 @@ const getStatusClass = (status) => {
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'PENDING': '待支付',
'CONFIRMED': '已确认',
'PAID': '已支付',
'PROCESSING': '处理中',
'SHIPPED': '已发货',
'DELIVERED': '已送达',
'COMPLETED': '已完成',
'CANCELLED': '已取消',
'REFUNDED': '已退款'
'PENDING': t('orders.pending'),
'CONFIRMED': t('orders.confirmed'),
'PAID': t('orders.paid'),
'PROCESSING': t('orders.processing'),
'SHIPPED': t('orders.shipped'),
'DELIVERED': t('orders.delivered'),
'COMPLETED': t('orders.completed'),
'CANCELLED': t('orders.cancelled'),
'REFUNDED': t('orders.refunded')
}
return statusMap[status] || status
}
@@ -426,11 +425,11 @@ const getStatusText = (status) => {
// 获取订单类型文本
const getOrderTypeText = (orderType) => {
const typeMap = {
'PRODUCT': '商品订单',
'SERVICE': '服务订单',
'SUBSCRIPTION': '订阅订单',
'DIGITAL': '数字商品',
'PHYSICAL': '实体商品'
'PRODUCT': t('orders.productOrder'),
'SERVICE': t('orders.serviceOrder'),
'SUBSCRIPTION': t('orders.subscriptionOrder'),
'DIGITAL': t('orders.digitalProduct'),
'PHYSICAL': t('orders.physicalProduct')
}
return typeMap[orderType] || orderType
}
@@ -840,38 +839,11 @@ const fetchSystemStats = async () => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {

View File

@@ -47,11 +47,10 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input" />
<div class="page-title">
<h2>{{ $t('nav.apiManagement') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
@@ -88,10 +87,20 @@
style="width: 100%; max-width: 600px;"
/>
</el-form-item>
<el-form-item :label="$t('apiManagement.apiBaseUrl')">
<el-input
v-model="apiForm.apiBaseUrl"
:placeholder="$t('apiManagement.apiBaseUrlPlaceholder')"
style="width: 100%; max-width: 600px;"
/>
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
{{ $t('apiManagement.apiBaseUrlHint') }}: {{ currentApiBaseUrl || $t('common.notConfigured') || '未配置' }}
</div>
</el-form-item>
<el-form-item :label="$t('apiManagement.tokenExpiration')">
<div style="display: flex; align-items: center; gap: 12px; width: 100%; max-width: 600px;">
<el-input
v-model.number="apiForm.jwtExpirationHours"
v-model.number="apiForm.tokenExpireHours"
type="number"
:placeholder="$t('apiManagement.tokenPlaceholder')"
style="flex: 1;"
@@ -99,9 +108,6 @@
:max="720"
/>
<span style="color: #6b7280; font-size: 14px;">{{ $t('apiManagement.hours') }}</span>
<span style="color: #9ca3af; font-size: 12px;" v-if="apiForm.jwtExpirationHours">
({{ formatJwtExpiration(apiForm.jwtExpirationHours) }})
</span>
</div>
<div style="margin-top: 8px; color: #6b7280; font-size: 12px;">
{{ $t('apiManagement.rangeHint') }}
@@ -147,8 +153,10 @@ const onlineUsers = ref('0/500')
const systemUptime = ref(t('common.loading'))
const apiForm = reactive({
apiKey: '',
jwtExpirationHours: 24 // 默认24小时
apiBaseUrl: '',
tokenExpireHours: null // 从数据库加载
})
const currentApiBaseUrl = ref('')
// 导航功能
const goToDashboard = () => {
@@ -200,20 +208,21 @@ const formatJwtExpiration = (hours) => {
}
}
// 加载当前API密钥和JWT配置仅显示部分
// 加载当前API配置
const loadApiKey = async () => {
loading.value = true
try {
const response = await api.get('/api-key')
if (response.data?.maskedKey) {
// 不显示掩码后的密钥,只用于验证
console.log('当前API密钥已配置')
}
// 加载JWT过期时间转换为小时
if (response.data?.jwtExpiration) {
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpiration / 3600000)
} else if (response.data?.jwtExpirationHours) {
apiForm.jwtExpirationHours = Math.round(response.data.jwtExpirationHours)
// 加载当前API基础URL
if (response.data?.apiBaseUrl) {
currentApiBaseUrl.value = response.data.apiBaseUrl
}
// 加载当前Token过期时间
if (response.data?.tokenExpireHours) {
apiForm.tokenExpireHours = response.data.tokenExpireHours
}
} catch (error) {
console.error('加载配置失败:', error)
@@ -222,21 +231,16 @@ const loadApiKey = async () => {
}
}
// 保存API密钥和JWT配置
// 保存API配置到数据库
const saveApiKey = async () => {
// 检查是否有任何输入
const hasApiKey = apiForm.apiKey && apiForm.apiKey.trim() !== ''
const hasJwtExpiration = apiForm.jwtExpirationHours != null && apiForm.jwtExpirationHours > 0
const hasApiBaseUrl = apiForm.apiBaseUrl && apiForm.apiBaseUrl.trim() !== ''
const hasTokenExpire = apiForm.tokenExpireHours && apiForm.tokenExpireHours >= 1 && apiForm.tokenExpireHours <= 720
// 验证输入:至少需要提供一个配置项
if (!hasApiKey && !hasJwtExpiration) {
ElMessage.warning(t('apiManagement.atLeastOneRequired') || '请至少输入API密钥或设置Token过期时间')
return
}
// 验证JWT过期时间范围
if (hasJwtExpiration && (apiForm.jwtExpirationHours < 1 || apiForm.jwtExpirationHours > 720)) {
ElMessage.warning(t('apiManagement.tokenRangeError') || 'Token过期时间必须在1-720小时之间')
if (!hasApiKey && !hasApiBaseUrl && !hasTokenExpire) {
ElMessage.warning(t('apiManagement.atLeastOneRequired') || '请至少输入一个配置项')
return
}
@@ -249,17 +253,25 @@ const saveApiKey = async () => {
requestData.apiKey = apiForm.apiKey.trim()
}
// 如果提供了JWT过期时间转换为毫秒并添加到请求中
if (hasJwtExpiration) {
requestData.jwtExpiration = apiForm.jwtExpirationHours * 3600000 // 转换为毫秒
// 如果提供了API基础URL添加到请求中
if (hasApiBaseUrl) {
requestData.apiBaseUrl = apiForm.apiBaseUrl.trim()
}
// 如果提供了Token过期时间添加到请求中
if (hasTokenExpire) {
requestData.tokenExpireHours = apiForm.tokenExpireHours
}
const response = await api.put('/api-key', requestData)
if (response.data?.success) {
ElMessage.success(response.data.message || '配置保存成功,请重启应用以使配置生效')
// 清空API密钥输入框保留JWT过期时间
ElMessage.success(response.data.message || '配置保存到数据库,立即生效')
// 清空输入框
apiForm.apiKey = ''
apiForm.apiBaseUrl = ''
// 重新加载当前配置
loadApiKey()
} else {
ElMessage.error(response.data?.error || '保存失败')
}
@@ -274,7 +286,7 @@ const saveApiKey = async () => {
// 重置表单
const resetForm = () => {
apiForm.apiKey = ''
// 重新加载JWT过期时间
apiForm.apiBaseUrl = ''
loadApiKey()
}
@@ -419,38 +431,11 @@ const fetchSystemStats = async () => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {

View File

@@ -2,7 +2,7 @@
<div class="login-page">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 修改密码卡片 -->

View File

@@ -29,7 +29,7 @@
</div>
<div class="nav-item active">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
<span>{{ $t('nav.errorStats') }}</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
@@ -52,13 +52,13 @@
<!-- 顶部搜索栏 -->
<header class="top-header">
<div class="page-title">
<h2>错误类型统计</h2>
<h2>{{ $t('errorStats.title') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
<el-dropdown @command="handleUserCommand">
<div class="user-avatar">
<img src="/images/backgrounds/avatar-default.svg" alt="用户头像" />
<img src="/images/backgrounds/avatar-default.svg" :alt="$t('errorStats.userAvatar')" />
<el-icon class="arrow-down"><ArrowDown /></el-icon>
</div>
<template #dropdown>
@@ -81,7 +81,7 @@
<el-icon><Warning /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">总错误数</div>
<div class="stat-title">{{ $t('errorStats.totalErrors') }}</div>
<div class="stat-number">{{ statistics.totalErrors || 0 }}</div>
</div>
</div>
@@ -91,7 +91,7 @@
<el-icon><Clock /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">今日错误</div>
<div class="stat-title">{{ $t('errorStats.todayErrors') }}</div>
<div class="stat-number">{{ statistics.todayErrors || 0 }}</div>
</div>
</div>
@@ -101,7 +101,7 @@
<el-icon><Calendar /></el-icon>
</div>
<div class="stat-content">
<div class="stat-title">本周错误</div>
<div class="stat-title">{{ $t('errorStats.weekErrors') }}</div>
<div class="stat-number">{{ statistics.weekErrors || 0 }}</div>
</div>
</div>
@@ -112,11 +112,11 @@
<div class="charts-section">
<div class="chart-card full-width">
<div class="chart-header">
<h3>错误类型分布</h3>
<h3>{{ $t('errorStats.errorTypeDistribution') }}</h3>
<el-select v-model="selectedDays" @change="loadStatistics" class="days-select">
<el-option label="最近7天" :value="7"></el-option>
<el-option label="最近30天" :value="30"></el-option>
<el-option label="最近90天" :value="90"></el-option>
<el-option :label="$t('errorStats.last7Days')" :value="7"></el-option>
<el-option :label="$t('errorStats.last30Days')" :value="30"></el-option>
<el-option :label="$t('errorStats.last90Days')" :value="90"></el-option>
</el-select>
</div>
<div class="error-type-list">
@@ -127,7 +127,7 @@
>
<div class="type-info">
<span class="type-name">{{ item.description || item.type }}</span>
<span class="type-count">{{ item.count }} </span>
<span class="type-count">{{ item.count }} {{ $t('errorStats.times') }}</span>
</div>
<div class="type-bar">
<div
@@ -137,7 +137,7 @@
</div>
<div class="type-percentage">{{ getPercentage(item.count) }}%</div>
</div>
<el-empty v-if="errorTypeStats.length === 0" description="暂无错误数据" />
<el-empty v-if="errorTypeStats.length === 0" :description="$t('errorStats.noErrorData')" />
</div>
</div>
</div>
@@ -145,23 +145,23 @@
<!-- 最近错误列表 -->
<div class="recent-errors-section">
<div class="section-header">
<h3>最近错误</h3>
<el-button type="primary" size="small" @click="loadRecentErrors">刷新</el-button>
<h3>{{ $t('errorStats.recentErrors') }}</h3>
<el-button type="primary" size="small" @click="loadRecentErrors">{{ $t('errorStats.refresh') }}</el-button>
</div>
<el-table :data="recentErrors" v-loading="tableLoading" stripe>
<el-table-column prop="createdAt" label="时间" width="180">
<el-table-column prop="createdAt" :label="$t('errorStats.time')" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="errorType" label="错误类型" width="150">
<el-table-column prop="errorType" :label="$t('errorStats.errorType')" width="150">
<template #default="{ row }">
<el-tag :type="getTagType(row.errorType)">{{ row.errorType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="username" label="用户" width="120" />
<el-table-column prop="taskId" label="任务ID" width="200" />
<el-table-column prop="errorMessage" label="错误信息" show-overflow-tooltip />
<el-table-column prop="username" :label="$t('errorStats.user')" width="120" />
<el-table-column prop="taskId" :label="$t('errorStats.taskId')" width="200" />
<el-table-column prop="errorMessage" :label="$t('errorStats.errorMessage')" show-overflow-tooltip />
</el-table>
<!-- 分页 -->

View File

@@ -28,7 +28,7 @@
</div>
<div class="nav-item" @click="goToErrorStats">
<el-icon><Warning /></el-icon>
<span>错误统计</span>
<span>{{ $t('nav.errorStats') }}</span>
</div>
<div class="nav-item" @click="goToSettings">
<el-icon><Setting /></el-icon>
@@ -47,11 +47,10 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input" v-model="searchText" />
<div class="page-title">
<h2>{{ $t('nav.taskRecord') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
@@ -62,7 +61,15 @@
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="exitAdmin">
<el-dropdown-item command="apiManagement">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.apiManagement') }}</span>
</el-dropdown-item>
<el-dropdown-item command="taskRecord">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</el-dropdown-item>
<el-dropdown-item divided command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>
@@ -123,9 +130,9 @@
@change="toggleTaskSelection(task)" />
</td>
<td>{{ task.taskId || task.id }}</td>
<td>{{ task.username || '未知' }}</td>
<td>{{ task.type || '未知' }}</td>
<td>{{ task.resources || '0积分' }}</td>
<td>{{ task.username || $t('tasks.unknown') }}</td>
<td>{{ getTaskTypeText(task.type) }}</td>
<td>{{ task.resources || '0' + $t('tasks.pointsUnit') }}</td>
<td>
<span class="status-tag" :class="getStatusClass(task.status)">
{{ getStatusText(task.status) }}
@@ -177,25 +184,25 @@
>
<div class="task-detail-content" v-if="currentTask">
<div class="detail-section">
<h4>基本信息</h4>
<h4>{{ $t('tasks.basicInfo') }}</h4>
<div class="detail-row">
<span class="detail-label">任务ID</span>
<span class="detail-label">{{ $t('tasks.taskId') }}</span>
<span class="detail-value">{{ currentTask.taskId || currentTask.id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">用户名</span>
<span class="detail-value">{{ currentTask.username || '未知' }}</span>
<span class="detail-label">{{ $t('tasks.username') }}</span>
<span class="detail-value">{{ currentTask.username || $t('tasks.unknown') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">任务类型</span>
<span class="detail-value">{{ currentTask.type || currentTask.taskType || '未知' }}</span>
<span class="detail-label">{{ $t('tasks.taskType') }}</span>
<span class="detail-value">{{ getTaskTypeText(currentTask.type || currentTask.taskType) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">消耗资源</span>
<span class="detail-value">{{ currentTask.resources || '0积分' }}</span>
<span class="detail-label">{{ $t('tasks.resourcesConsumed') }}</span>
<span class="detail-value">{{ currentTask.resources || $t('tasks.defaultPoints') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">状态</span>
<span class="detail-label">{{ $t('tasks.status') }}</span>
<span class="status-tag" :class="getStatusClass(currentTask.status)">
{{ getStatusText(currentTask.status) }}
</span>
@@ -203,34 +210,34 @@
</div>
<div class="detail-section">
<h4>时间信息</h4>
<h4>{{ $t('tasks.timeInfo') }}</h4>
<div class="detail-row">
<span class="detail-label">创建时间</span>
<span class="detail-label">{{ $t('tasks.createTime') }}</span>
<span class="detail-value">{{ formatDate(currentTask.createdAt || currentTask.createTime) }}</span>
</div>
<div class="detail-row" v-if="currentTask.updatedAt">
<span class="detail-label">更新时间</span>
<span class="detail-label">{{ $t('tasks.updateTime') }}</span>
<span class="detail-value">{{ formatDate(currentTask.updatedAt) }}</span>
</div>
<div class="detail-row" v-if="currentTask.completedAt">
<span class="detail-label">完成时间</span>
<span class="detail-label">{{ $t('tasks.completeTime') }}</span>
<span class="detail-value">{{ formatDate(currentTask.completedAt) }}</span>
</div>
</div>
<div class="detail-section" v-if="currentTask.progress !== undefined">
<h4>进度信息</h4>
<h4>{{ $t('tasks.progressInfo') }}</h4>
<div class="detail-row">
<span class="detail-label">进度</span>
<span class="detail-label">{{ $t('tasks.progress') }}</span>
<el-progress :percentage="currentTask.progress || 0" :status="getProgressStatus(currentTask.status)" />
</div>
</div>
<div class="detail-section" v-if="currentTask.resultUrl">
<h4>结果</h4>
<h4>{{ $t('tasks.result') }}</h4>
<div class="detail-row">
<span class="detail-label">结果链接</span>
<a :href="currentTask.resultUrl" target="_blank" class="result-link">查看结果</a>
<span class="detail-label">{{ $t('tasks.resultLink') }}</span>
<a :href="currentTask.resultUrl" target="_blank" class="result-link">{{ $t('tasks.viewResult') }}</a>
</div>
<div class="result-preview" v-if="isVideoUrl(currentTask.resultUrl)">
<video :src="currentTask.resultUrl" controls class="preview-video"></video>
@@ -241,19 +248,19 @@
</div>
<div class="detail-section" v-if="currentTask.errorMessage">
<h4>错误信息</h4>
<h4>{{ $t('tasks.errorInfo') }}</h4>
<div class="error-message">{{ currentTask.errorMessage }}</div>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
<el-button @click="detailDialogVisible = false">{{ $t('tasks.close') }}</el-button>
<el-button
v-if="currentTask?.resultUrl"
type="primary"
@click="openResult(currentTask.resultUrl)"
>
查看结果
{{ $t('tasks.viewResult') }}
</el-button>
</template>
</el-dialog>
@@ -263,6 +270,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Grid,
@@ -281,6 +289,7 @@ import { taskStatusApi } from '@/api/taskStatus'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const { t } = useI18n()
// 响应式数据
const statusFilter = ref('all')
@@ -329,7 +338,11 @@ const goToSettings = () => {
// 处理用户头像下拉菜单
const handleUserCommand = (command) => {
if (command === 'exitAdmin') {
if (command === 'apiManagement') {
router.push('/api-management')
} else if (command === 'taskRecord') {
router.push('/generate-task-record')
} else if (command === 'exitAdmin') {
// 退出后台,返回个人首页
router.push('/profile')
}
@@ -442,13 +455,27 @@ const getStatusClass = (status) => {
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
'COMPLETED': '已完成',
'PROCESSING': '处理中',
'CANCELLED': '已取消',
'FAILED': '失败',
'PENDING': '待处理'
'COMPLETED': t('tasks.completed'),
'PROCESSING': t('tasks.processing'),
'CANCELLED': t('tasks.cancelled'),
'FAILED': t('tasks.failed'),
'PENDING': t('tasks.pending')
}
return statusMap[status] || status || '未知'
return statusMap[status] || status || t('tasks.unknown')
}
// 获取任务类型文本
const getTaskTypeText = (type) => {
if (!type) return t('tasks.unknown')
const typeMap = {
'TEXT_TO_VIDEO': t('tasks.textToVideo'),
'IMAGE_TO_VIDEO': t('tasks.imageToVideo'),
'STORYBOARD': t('tasks.storyboardVideo'),
'文生视频': t('tasks.textToVideo'),
'图生视频': t('tasks.imageToVideo'),
'分镜视频': t('tasks.storyboardVideo')
}
return typeMap[type] || type
}
// 状态筛选
@@ -459,7 +486,7 @@ const handleStatusFilter = () => {
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '未知'
if (!dateString) return t('tasks.unknown')
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) return '未知'
@@ -898,38 +925,11 @@ const fetchSystemStats = async () => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {

View File

@@ -3,7 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToProfile">

View File

@@ -326,6 +326,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click.stop="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -364,7 +372,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
// 表单数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const aspectRatio = ref('9:16')
const duration = ref('15')
const hdMode = ref(false)
const inProgress = ref(false)
@@ -481,6 +489,16 @@ const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToApiManagement = () => {
showUserMenu.value = false
router.push('/api-management')
}
const goToTaskRecord = () => {
showUserMenu.value = false
router.push('/generate-task-record')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -567,6 +585,34 @@ const removeLastFrame = () => {
lastFrameFile.value = null
}
// 处理创建任务错误的辅助函数
const handleCreateError = (errorMsg) => {
const msg = errorMsg || t('video.imageToVideo.createTaskFailed')
// 检测积分不足
if (msg.includes('积分不足') || msg.includes('insufficient') || msg.includes('points')) {
ElMessageBox.confirm(
'您的积分不足,无法创建任务。是否前往充值?',
'积分不足',
{
confirmButtonText: '去充值',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push('/subscription')
}).catch(() => {})
} else {
ElMessage.error(msg)
}
inProgress.value = false
isCreatingTask.value = false
currentTask.value = null
taskStatus.value = ''
taskProgress.value = 0
}
// 开始生成视频
const startGenerate = async () => {
console.log('[StartGenerate] 开始firstFrameFile:', firstFrameFile.value ? `File(${firstFrameFile.value.name})` : 'null',
@@ -603,15 +649,60 @@ const startGenerate = async () => {
return
}
} else {
// 外部 URL如 COS清空图片显示,让用户重新上传
console.warn('[StartGenerate] 图片是外部URL清空显示让用户重新上传')
firstFrameImage.value = ''
ElMessage.error('请重新选择图片文件')
return
// 外部 URL如 COS直接使用 URL 创建任务,无需下载
console.log('[StartGenerate] 图片是外部URL直接使用URL创建任务:', imageUrl.substring(0, 100))
// 验证描述文字
if (!inputText.value.trim()) {
ElMessage.error(t('video.imageToVideo.enterDescriptionRequired'))
return
}
// 标记正在创建任务
isCreatingTask.value = true
// 显示加载状态
const loading = ElLoading.service({
lock: true,
text: t('video.imageToVideo.creatingTask'),
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 直接调用通过URL创建任务的API
const response = await imageToVideoApi.createTaskByUrl({
imageUrl: imageUrl,
prompt: inputText.value.trim(),
aspectRatio: aspectRatio.value,
duration: parseInt(duration.value),
hdMode: hdMode.value
})
loading.close()
if (response.data && response.data.success) {
currentTask.value = response.data.data
inProgress.value = true
taskProgress.value = 0
taskStatus.value = 'PENDING'
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
setTimeout(() => userStore.fetchCurrentUser(), 0)
startPollingTask()
setTimeout(() => { isCreatingTask.value = false }, 2000)
} else {
handleCreateError(response.data?.message)
}
return
} catch (error) {
loading.close()
console.error('[StartGenerate] 通过URL创建任务失败:', error)
handleCreateError(error.response?.data?.message || error.message)
return
}
}
}
// 验证表单
// 验证表单(有 File 对象的情况)
if (!firstFrameFile.value) {
ElMessage.error(t('video.imageToVideo.uploadFirstFrameRequired'))
return
@@ -633,7 +724,7 @@ const startGenerate = async () => {
})
try {
// 准备请求参数
// 准备请求参数(使用 File 对象)
const params = {
firstFrame: firstFrameFile.value,
prompt: inputText.value.trim(),
@@ -1387,6 +1478,9 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
// 处理"做同款"传递的路由参数
if (route.query.prompt || route.query.referenceImage) {
console.log('[做同款] 接收参数:', route.query)
@@ -2362,10 +2456,11 @@ onUnmounted(() => {
.video-player {
position: relative;
width: 100%;
/* height 由 aspect-ratio 动态计算 */
background: #1a1a1a;
border-radius: 12px;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
@@ -2376,7 +2471,6 @@ onUnmounted(() => {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
display: block;
}
@@ -2593,21 +2687,6 @@ onUnmounted(() => {
position: relative;
}
.history-preview.vertical {
aspect-ratio: auto;
width: 80%;
max-width: 1000px;
display: flex;
justify-content: center;
}
.history-preview.vertical .history-video-thumbnail,
.history-preview.vertical .history-placeholder {
aspect-ratio: 9/16;
width: auto;
height: 100%;
max-height: 600px;
}
.history-placeholder {
width: 100%;
@@ -2671,7 +2750,7 @@ onUnmounted(() => {
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
cursor: pointer;
display: block;
}

View File

@@ -2,7 +2,7 @@
<div class="login-page">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 登录卡片 -->

View File

@@ -47,11 +47,10 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><Search /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input" />
<div class="page-title">
<h2>{{ $t('nav.memberManagement') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
@@ -82,16 +81,16 @@
<div class="table-toolbar">
<div class="toolbar-left">
<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 v-model="selectedLevel" :placeholder="$t('members.memberLevel')" size="small" @change="handleFilterChange">
<el-option :label="$t('members.allLevels')" value="all" />
<el-option :label="$t('members.freeMember')" value="free" />
<el-option :label="$t('members.standardMember')" value="standard" />
<el-option :label="$t('members.professionalMember')" 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 v-model="selectedStatus" :placeholder="$t('members.userStatus')" size="small" @change="handleFilterChange" style="margin-left: 10px;">
<el-option :label="$t('members.activeUsers')" value="active" />
<el-option :label="$t('members.bannedUsers')" value="banned" />
<el-option :label="$t('members.allUsers')" value="all" />
</el-select>
</div>
<div class="toolbar-right">
@@ -111,9 +110,9 @@
</th>
<th>{{ $t('members.userId') }}</th>
<th>{{ $t('members.username') }}</th>
<th>角色</th>
<th>{{ $t('members.role') }}</th>
<th>{{ $t('members.level') }}</th>
<th>状态</th>
<th>{{ $t('members.status') }}</th>
<th>{{ $t('members.points') }}</th>
<th>{{ $t('members.expiryDate') }}</th>
<th>{{ $t('members.operation') }}</th>
@@ -135,13 +134,13 @@
</span>
</td>
<td>
<span class="level-tag" :class="member.level === '专业会员' ? 'professional' : 'standard'">
<span class="level-tag" :class="member.level === $t('members.professionalMember') ? 'professional' : 'standard'">
{{ member.level }}
</span>
</td>
<td>
<span class="status-tag" :class="member.isActive ? 'active' : 'banned'">
{{ member.isActive ? '活跃' : '封禁' }}
{{ member.isActive ? $t('members.active') : $t('members.banned') }}
</span>
</td>
<td>{{ member.points.toLocaleString() }}</td>
@@ -153,13 +152,13 @@
:type="member.role === 'ROLE_ADMIN' ? 'info' : 'primary'"
class="action-link"
@click="toggleRole(member)">
{{ member.role === 'ROLE_ADMIN' ? '取消管理员' : '设为管理员' }}
{{ member.role === 'ROLE_ADMIN' ? $t('members.revokeAdmin') : $t('members.setAdmin') }}
</el-link>
<el-link
:type="member.isActive ? 'warning' : 'success'"
class="action-link"
@click="toggleBan(member)">
{{ member.isActive ? '封禁' : '解封' }}
{{ member.isActive ? $t('members.ban') : $t('members.unban') }}
</el-link>
<el-link type="danger" class="action-link" @click="deleteMember(member)">{{ $t('common.delete') }}</el-link>
</td>
@@ -209,17 +208,17 @@
<el-form-item label="用户ID" prop="id">
<el-input v-model="editForm.id" disabled />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="editForm.username" placeholder="请输入用户名" />
<el-form-item :label="$t('members.username')" prop="username">
<el-input v-model="editForm.username" :placeholder="$t('members.usernamePlaceholder')" />
</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-form-item v-if="isSuperAdmin && editForm.role !== 'ROLE_SUPER_ADMIN'" :label="$t('members.userRole')" prop="role">
<el-select v-model="editForm.role" :placeholder="$t('members.selectRole')">
<el-option :label="$t('members.normalUser')" value="ROLE_USER" />
<el-option :label="$t('members.admin')" value="ROLE_ADMIN" />
</el-select>
</el-form-item>
<el-form-item label="会员等级" prop="level">
<el-select v-model="editForm.level" placeholder="请选择会员等级">
<el-form-item :label="$t('members.level')" prop="level">
<el-select v-model="editForm.level" :placeholder="$t('members.levelPlaceholder')">
<el-option
v-for="level in membershipLevels"
:key="level.id"
@@ -228,7 +227,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="剩余资源点" prop="points">
<el-form-item :label="$t('members.points')" prop="points">
<el-input-number
v-model="editForm.points"
:min="0"
@@ -257,6 +256,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Grid,
@@ -275,6 +275,7 @@ import * as memberAPI from '@/api/members'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const router = useRouter()
const { t } = useI18n()
// 数据状态
const selectedMembers = ref([])
@@ -581,14 +582,14 @@ const handleFilterChange = () => {
// 封禁/解封会员
const toggleBan = async (member) => {
const action = member.isActive ? '封禁' : '解封'
const action = member.isActive ? t('members.ban') : t('members.unban')
try {
await ElMessageBox.confirm(
`确定要${action}用户 ${member.username} 吗?`,
`确认${action}`,
t('members.confirmBanAction', { action: action, username: member.username }),
t('members.confirmAction', { action: action }),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
@@ -596,15 +597,15 @@ const toggleBan = async (member) => {
const response = await memberAPI.toggleBanMember(member.id, !member.isActive)
if (response.data && response.data.success) {
ElMessage.success(response.data.message || `${action}成功`)
ElMessage.success(response.data.message || t('members.actionSuccess', { action: action }))
await loadMembers()
} else {
ElMessage.error(response.data?.message || `${action}失败`)
ElMessage.error(response.data?.message || t('members.actionFailed', { action: action }))
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || `${action}失败`
const errorMsg = error.response?.data?.message || t('members.actionFailed', { action: action })
ElMessage.error(errorMsg)
}
}
@@ -648,10 +649,38 @@ const loadMembers = async () => {
}
}
// 辅助函数:获取会员等级显示名称
// 辅助函数:获取会员等级显示名称(支持国际化)
const getMembershipLevel = (membership) => {
if (!membership) return '标准会员'
return membership.display_name || '标准会员'
if (!membership) return t('members.standardMember')
// 根据会员等级name进行翻译
const levelName = membership.name || membership.level_name
if (levelName) {
const levelMap = {
'free': t('members.freeMember'),
'standard': t('members.standardMember'),
'professional': t('members.professionalMember')
}
if (levelMap[levelName]) {
return levelMap[levelName]
}
}
// 如果display_name包含中文尝试翻译
const displayName = membership.display_name
if (displayName) {
if (displayName.includes('免费') || displayName.toLowerCase().includes('free')) {
return t('members.freeMember')
}
if (displayName.includes('标准') || displayName.toLowerCase().includes('standard')) {
return t('members.standardMember')
}
if (displayName.includes('专业') || displayName.toLowerCase().includes('professional')) {
return t('members.professionalMember')
}
}
return membership.display_name || t('members.standardMember')
}
// 辅助函数:获取会员到期时间
@@ -663,11 +692,11 @@ const getMembershipExpiry = (membership) => {
// 辅助函数:获取角色显示名称
const getRoleLabel = (role) => {
const roleMap = {
'ROLE_SUPER_ADMIN': '超级管理员',
'ROLE_ADMIN': '管理员',
'ROLE_USER': '普通用户'
'ROLE_SUPER_ADMIN': t('members.superAdmin'),
'ROLE_ADMIN': t('members.admin'),
'ROLE_USER': t('members.normalUser')
}
return roleMap[role] || '普通用户'
return roleMap[role] || t('members.normalUser')
}
// 辅助函数:获取角色样式类
@@ -683,15 +712,15 @@ const getRoleClass = (role) => {
// 设置/取消管理员
const toggleRole = async (member) => {
const newRole = member.role === 'ROLE_ADMIN' ? 'ROLE_USER' : 'ROLE_ADMIN'
const action = newRole === 'ROLE_ADMIN' ? '设为管理员' : '取消管理员权限'
const action = newRole === 'ROLE_ADMIN' ? t('members.setAdmin') : t('members.revokeAdmin')
try {
await ElMessageBox.confirm(
`确定要将用户 ${member.username} ${action}吗?`,
`确认${action}`,
t('members.confirmRoleChange', { username: member.username, action: action }),
t('members.confirmAction', { action: action }),
{
confirmButtonText: '确定',
cancelButtonText: '取消',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
@@ -699,15 +728,15 @@ const toggleRole = async (member) => {
const response = await memberAPI.setUserRole(member.id, newRole)
if (response.data && response.data.success) {
ElMessage.success(response.data.message || `${action}成功`)
ElMessage.success(response.data.message || t('members.actionSuccess', { action: action }))
await loadMembers()
} else {
ElMessage.error(response.data?.message || `${action}失败`)
ElMessage.error(response.data?.message || t('members.actionFailed', { action: action }))
}
} catch (error) {
if (error !== 'cancel') {
console.error(`${action}失败:`, error)
const errorMsg = error.response?.data?.message || `${action}失败`
const errorMsg = error.response?.data?.message || t('members.actionFailed', { action: action })
ElMessage.error(errorMsg)
}
}
@@ -886,38 +915,11 @@ const fetchSystemStats = async () => {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.search-bar {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
color: #9ca3af;
font-size: 16px;
z-index: 1;
}
.search-input {
width: 300px;
padding: 10px 12px 10px 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {

View File

@@ -4,7 +4,7 @@
<aside class="sidebar">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 导航菜单 -->
@@ -412,6 +412,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -608,6 +616,24 @@ const goToSettings = () => {
}
}
const goToApiManagement = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/api-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToTaskRecord = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/generate-task-record')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -1524,13 +1550,11 @@ const loadUserInfo = async () => {
}
}
onMounted(() => {
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
loadUserInfo()
loadList()
})
// 注册/注销全局点击监听用于关闭菜单
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})

View File

@@ -4,7 +4,7 @@
<aside class="sidebar">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 导航菜单 -->
@@ -258,6 +258,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click.stop="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer; pointer-events: auto;">
@@ -420,6 +428,28 @@ const goToSettings = () => {
}
}
// 跳转到API管理
const goToApiManagement = () => {
showUserMenu.value = false
// 检查用户权限只有管理员才能访问API管理
if (userStore.isAdmin) {
router.push('/api-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
// 跳转到生成任务管理
const goToTaskRecord = () => {
showUserMenu.value = false
// 检查用户权限,只有管理员才能访问生成任务管理
if (userStore.isAdmin) {
router.push('/generate-task-record')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
// 退出登录
const logout = async () => {
try {
@@ -703,8 +733,10 @@ const onImageError = (event) => {
event.target.style.display = 'none'
}
onMounted(() => {
onMounted(async () => {
document.addEventListener('click', handleClickOutside)
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
loadUserInfo()
loadVideos()
})

View File

@@ -2,7 +2,7 @@
<div class="login-page">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 设置密码卡片 -->

View File

@@ -3,7 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToProfile">

View File

@@ -21,6 +21,8 @@
</div>
</header>
<!-- 分镜视频创作逻辑改了3版有很多逻辑用不上但是我保留了谁知道什么时候就又用上了 -->
<!-- 用户菜单下拉 -->
<Teleport to="body">
<div v-if="showUserMenu" class="user-menu-teleport" :style="menuStyle">
@@ -46,6 +48,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click.stop="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -567,7 +577,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
// 表单数据
const inputText = ref('')
const videoPrompt = ref('') // 视频生成提示词
const aspectRatio = ref('16:9')
const aspectRatio = ref('9:16')
const duration = ref('15')
const hdMode = ref(false)
const imageModel = ref('nano-banana2')
@@ -704,6 +714,16 @@ const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToApiManagement = () => {
showUserMenu.value = false
router.push('/api-management')
}
const goToTaskRecord = () => {
showUserMenu.value = false
router.push('/generate-task-record')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -2733,6 +2753,9 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
// 处理"做同款"传递的路由参数
if (route.query.prompt || route.query.referenceImage) {
console.log('[做同款] 接收参数:', route.query)
@@ -4439,12 +4462,11 @@ onBeforeUnmount(() => {
.video-player {
position: relative;
width: 100%;
max-width: 100%;
height: auto;
max-height: 100%;
background: #1a1a1a;
border-radius: 12px;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
@@ -4453,11 +4475,8 @@ onBeforeUnmount(() => {
.result-video {
width: 100%;
height: auto;
max-height: 100%;
max-width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
display: block;
}
@@ -4742,6 +4761,7 @@ onBeforeUnmount(() => {
position: relative;
}
.history-placeholder {
width: 100%;
height: 100%;
@@ -4804,7 +4824,7 @@ onBeforeUnmount(() => {
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
cursor: pointer;
display: block;
}

View File

@@ -4,7 +4,7 @@
<aside class="sidebar">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 导航菜单 -->
@@ -127,7 +127,7 @@
<div class="package-header">
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
</div>
<div class="package-price">¥{{ membershipPrices.free }}/</div>
<div class="package-price">¥{{ membershipPrices.free }}/{{ $t('subscription.perMonth') }}</div>
<div class="points-box points-box-placeholder">&nbsp;</div>
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
<div class="package-features">
@@ -137,19 +137,19 @@
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>文生视频30积分/</span>
<span>{{ $t('subscription.textToVideo30Points') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>图生视频30积分/</span>
<span>{{ $t('subscription.imageToVideo30Points') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>分镜图生成30积分/</span>
<span>{{ $t('subscription.storyboardImage30Points') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>分镜视频生成30积分/</span>
<span>{{ $t('subscription.storyboardVideo30Points') }}</span>
</div>
</div>
</div>
@@ -160,8 +160,9 @@
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
</div>
<div class="package-price">¥{{ membershipPrices.standard }}/</div>
<div class="points-box">{{ $t('subscription.standardPoints') }}</div>
<div class="package-price">¥{{ membershipPrices.standard }}/{{ Math.floor((membershipPoints.standard || 0) / 30) }}{{ $t('subscription.items') }}</div>
<div class="points-box" v-if="membershipPoints.standard !== null">{{ membershipPoints.standard }}{{ $t('subscription.points') }}</div>
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
@@ -172,6 +173,26 @@
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.commercialUse') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.pointsValidOneYear') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.maxTextToVideo', { count: Math.floor((membershipPoints.standard || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.maxImageToVideo', { count: Math.floor((membershipPoints.standard || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.maxStoryboardImage', { count: Math.floor((membershipPoints.standard || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.maxStoryboardVideo', { count: Math.floor((membershipPoints.standard || 0) / 30) }) }}</span>
</div>
</div>
</div>
@@ -181,8 +202,9 @@
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
</div>
<div class="package-price">¥{{ membershipPrices.premium }}/</div>
<div class="points-box">{{ $t('subscription.premiumPoints') }}</div>
<div class="package-price">¥{{ membershipPrices.premium }}/{{ Math.floor((membershipPoints.premium || 0) / 30) }}{{ $t('subscription.items') }}</div>
<div class="points-box" v-if="membershipPoints.premium !== null">{{ membershipPoints.premium }}{{ $t('subscription.points') }}</div>
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
<div class="package-features">
<div class="feature-item">
@@ -197,6 +219,26 @@
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.earlyAccess') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.pointsValidOneYear') }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.textToVideoItems', { count: Math.floor((membershipPoints.premium || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.imageToVideoItems', { count: Math.floor((membershipPoints.premium || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.storyboardImageTimes', { count: Math.floor((membershipPoints.premium || 0) / 30) }) }}</span>
</div>
<div class="feature-item">
<el-icon class="check-icon"><Check /></el-icon>
<span>{{ $t('subscription.storyboardVideoItems', { count: Math.floor((membershipPoints.premium || 0) / 30) }) }}</span>
</div>
</div>
</div>
</div>
@@ -228,6 +270,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -422,6 +472,9 @@ const loadUserSubscriptionInfo = async () => {
await userStore.init()
}
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
// 检查用户是否已认证
if (!userStore.isAuthenticated) {
console.warn('用户未认证,跳转到登录页')
@@ -766,6 +819,24 @@ const goToSettings = () => {
}
}
const goToApiManagement = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/api-management')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToTaskRecord = () => {
showUserMenu.value = false
if (userStore.isAdmin) {
router.push('/generate-task-record')
} else {
ElMessage.warning(t('profile.insufficientPermission'))
}
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -793,6 +864,9 @@ const selectPlan = (plan) => {
}
// 处理订阅
// 记录当前选择的套餐类型,用于判断是否需要生成新订单
let lastSelectedPlanType = ''
const handleSubscribe = async (planType) => {
console.log('handleSubscribe 被调用planType:', planType)
try {
@@ -800,13 +874,23 @@ const handleSubscribe = async (planType) => {
const planInfo = getPlanInfo(planType)
console.log('获取到的套餐信息:', planInfo)
// 设置支付数据
currentPaymentData.value = {
title: `${planInfo.name}会员`,
amount: planInfo.price,
orderId: `SUB_${planType}_${Date.now()}`,
planType: planType,
planInfo: planInfo
// 只有套餐类型变化时才生成新的orderId
if (planType !== lastSelectedPlanType || !currentPaymentData.value.orderId) {
currentPaymentData.value = {
title: `${planInfo.name}会员`,
amount: planInfo.price,
orderId: `SUB_${planType}_${Date.now()}`,
planType: planType,
planInfo: planInfo
}
lastSelectedPlanType = planType
console.log('套餐变化生成新的orderId:', currentPaymentData.value.orderId)
} else {
// 同一套餐只更新必要信息保留orderId
currentPaymentData.value.title = `${planInfo.name}会员`
currentPaymentData.value.amount = planInfo.price
currentPaymentData.value.planInfo = planInfo
console.log('同一套餐复用orderId:', currentPaymentData.value.orderId)
}
console.log('支付数据设置完成:', currentPaymentData.value)
@@ -930,6 +1014,10 @@ const handlePaymentSuccess = async (paymentData) => {
// 关闭支付模态框
paymentModalVisible.value = false
// 重置支付数据,下次购买生成新订单
lastSelectedPlanType = ''
currentPaymentData.value = {}
// 重新加载用户订阅信息
await loadUserSubscriptionInfo()
@@ -1334,6 +1422,12 @@ const createSubscriptionOrder = async (planType, planInfo) => {
text-align: center;
}
.subscription-packages .points-box .points-validity {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.subscription-packages .points-box-placeholder {
background: transparent;
color: transparent;

View File

@@ -47,11 +47,10 @@
<!-- 主内容区域 -->
<main class="main-content">
<!-- 顶部搜索 -->
<!-- 顶部操作 -->
<header class="top-header">
<div class="search-bar">
<el-icon class="search-icon"><User /></el-icon>
<input type="text" :placeholder="$t('common.searchPlaceholder')" class="search-input">
<div class="page-title">
<h2>{{ $t('nav.systemSettings') }}</h2>
</div>
<div class="header-actions">
<LanguageSwitcher />
@@ -111,8 +110,8 @@
<h3>{{ level.name }}</h3>
</div>
<div class="card-body">
<p class="price">¥{{ level.price || 0 }}/</p>
<p class="description">包含{{ level.resourcePoints || level.pointsBonus || 0 }}积分/</p>
<p class="price">¥{{ level.price || 0 }}/{{ Math.floor((level.resourcePoints || level.pointsBonus || 0) / 30) }}{{ $t('subscription.items') }}</p>
<p class="description">{{ level.resourcePoints || level.pointsBonus || 0 }}{{ $t('subscription.points') }}</p>
</div>
<div class="card-footer">
<el-button type="primary" @click="editLevel(level)">{{ $t('common.edit') }}</el-button>
@@ -254,10 +253,6 @@
</template>
<div class="ai-model-content">
<el-form label-width="180px">
<el-form-item :label="$t('systemSettings.promptOptimizationApiUrl')">
<el-input v-model="promptOptimizationApiUrl" style="width: 400px;" placeholder="https://ai.comfly.chat"></el-input>
<div class="model-tip">{{ $t('systemSettings.promptOptimizationApiUrlTip') }}</div>
</el-form-item>
<el-form-item :label="$t('systemSettings.promptOptimizationModel')">
<el-input v-model="promptOptimizationModel" style="width: 400px;" placeholder="gpt-5.1-thinking"></el-input>
<div class="model-tip">{{ $t('systemSettings.promptOptimizationModelTip') }}</div>
@@ -431,8 +426,8 @@ import {
ShoppingCart,
Document,
Setting,
User as Search,
User as ArrowDown,
Search,
ArrowDown,
Delete,
Refresh,
Check,
@@ -503,7 +498,6 @@ const cleanupConfig = reactive({
// AI模型设置相关
const promptOptimizationModel = ref('gpt-5.1-thinking')
const promptOptimizationApiUrl = ref('https://ai.comfly.chat')
const storyboardSystemPrompt = ref('')
const savingAiModel = ref(false)
@@ -576,8 +570,14 @@ const saveEdit = async () => {
return
}
const pointsInt = parseInt(editForm.resourcePoints)
if (Number.isNaN(pointsInt) || pointsInt < 0) {
ElMessage.error(t('systemSettings.enterValidNumber'))
return
}
// 直接更新membership_levels表
const updateData = { price: priceInt }
const updateData = { price: priceInt, pointsBonus: pointsInt }
console.log('准备更新会员等级:', editForm.id, updateData)
const response = await api.put(`/members/levels/${editForm.id}`, updateData)
console.log('会员等级更新响应:', response.data)
@@ -761,9 +761,6 @@ const loadAiModelSettings = async () => {
if (data.promptOptimizationModel) {
promptOptimizationModel.value = data.promptOptimizationModel
}
if (data.promptOptimizationApiUrl) {
promptOptimizationApiUrl.value = data.promptOptimizationApiUrl
}
if (data.storyboardSystemPrompt !== undefined) {
storyboardSystemPrompt.value = data.storyboardSystemPrompt
}
@@ -785,7 +782,6 @@ const saveAiModelSettings = async () => {
},
body: JSON.stringify({
promptOptimizationModel: promptOptimizationModel.value,
promptOptimizationApiUrl: promptOptimizationApiUrl.value,
storyboardSystemPrompt: storyboardSystemPrompt.value
})
})
@@ -943,27 +939,11 @@ const fetchSystemStats = async () => {
z-index: 100;
}
.search-bar {
display: flex;
align-items: center;
background-color: #f0f2f5;
border-radius: 20px;
padding: 8px 15px;
width: 300px;
}
.search-icon {
color: #909399;
margin-right: 8px;
}
.search-input {
border: none;
background: transparent;
outline: none;
flex-grow: 1;
font-size: 14px;
color: #333;
.page-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
.header-actions {
@@ -1511,11 +1491,6 @@ const fetchSystemStats = async () => {
padding: 10px 20px;
}
.search-bar {
width: 200px;
padding: 6px 10px;
}
.content-section {
padding: 20px;
}

View File

@@ -3,7 +3,7 @@
<!-- 左侧导航栏 -->
<aside class="sidebar">
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<nav class="nav-menu">
<div class="nav-item" @click="goToProfile">

View File

@@ -300,6 +300,14 @@
<el-icon><Warning /></el-icon>
<span>错误统计</span>
</div>
<div class="menu-item" @click.stop="goToApiManagement">
<el-icon><Document /></el-icon>
<span>{{ t('nav.apiManagement') }}</span>
</div>
<div class="menu-item" @click.stop="goToTaskRecord">
<el-icon><Document /></el-icon>
<span>{{ t('nav.tasks') }}</span>
</div>
</template>
<!-- 修改密码所有登录用户可见 -->
<div class="menu-item" @click.stop="goToChangePassword" style="cursor: pointer;">
@@ -338,7 +346,7 @@ const isAuthenticated = computed(() => userStore.isAuthenticated)
// 响应式数据
const inputText = ref('')
const aspectRatio = ref('16:9')
const aspectRatio = ref('9:16')
const duration = ref(15)
const hdMode = ref(false)
const inProgress = ref(false)
@@ -447,6 +455,16 @@ const goToErrorStats = () => {
router.push('/admin/error-statistics')
}
const goToApiManagement = () => {
showUserMenu.value = false
router.push('/api-management')
}
const goToTaskRecord = () => {
showUserMenu.value = false
router.push('/generate-task-record')
}
const goToChangePassword = () => {
showUserMenu.value = false
router.push('/change-password')
@@ -1152,6 +1170,9 @@ const checkLastTaskStatus = async () => {
}
onMounted(async () => {
// 强制刷新用户信息,确保获取管理员修改后的最新数据
await userStore.fetchCurrentUser()
// 处理"做同款"传递的路由参数
if (route.query.prompt) {
inputText.value = route.query.prompt
@@ -2033,12 +2054,11 @@ onUnmounted(() => {
.video-player {
position: relative;
width: 100%;
max-width: 100%;
height: auto;
max-height: 100%;
background: #1a1a1a;
border-radius: 12px;
width: 80%;
max-width: 1000px;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
@@ -2047,11 +2067,8 @@ onUnmounted(() => {
.result-video {
width: 100%;
height: auto;
max-height: 100%;
max-width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
display: block;
}
@@ -2268,21 +2285,6 @@ onUnmounted(() => {
position: relative;
}
.history-preview.vertical {
aspect-ratio: auto;
width: 80%;
max-width: 1000px;
display: flex;
justify-content: center;
}
.history-preview.vertical .history-video-thumbnail,
.history-preview.vertical .history-placeholder {
aspect-ratio: 9/16;
width: auto;
height: 100%;
max-height: 600px;
}
.history-placeholder {
width: 100%;
@@ -2346,7 +2348,7 @@ onUnmounted(() => {
.history-video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
cursor: pointer;
display: block;
}

View File

@@ -4,7 +4,7 @@
<aside class="sidebar">
<!-- Logo -->
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<!-- 导航菜单 -->

View File

@@ -4,7 +4,7 @@
<header class="navbar">
<div class="navbar-content">
<div class="logo">
<img src="/images/backgrounds/logo.svg" alt="Logo" />
<img src="/images/backgrounds/logo.svg?v=2" alt="Logo" />
</div>
<nav class="nav-links">
<a href="#" class="nav-link" @click.prevent="goToTextToVideo">{{ $t('welcome.textToVideo') }}</a>

View File

@@ -58,6 +58,24 @@ export default defineConfig({
// 代码分割优化
rollupOptions: {
output: {
// 为所有资源文件包括SVG生成内容哈希
assetFileNames: (assetInfo) => {
// 获取文件扩展名
const extType = assetInfo.name.split('.').pop();
// 图片类型包括SVG
if (/png|jpe?g|gif|svg|webp|ico/i.test(extType)) {
return `static/images/[name]-[hash][extname]`;
}
// 字体类型
if (/woff2?|eot|ttf|otf/i.test(extType)) {
return `static/fonts/[name]-[hash][extname]`;
}
// 其他资源
return `static/[name]-[hash][extname]`;
},
// JS文件哈希
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'element-plus': ['element-plus', '@element-plus/icons-vue'],

View File

@@ -115,8 +115,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
description TEXT COMMENT '描述',
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格(元)',
duration_days INT NOT NULL DEFAULT 30 COMMENT '时长(天)',
points_bonus INT NOT NULL DEFAULT 0 COMMENT '积分奖励',
resource_points INT NOT NULL DEFAULT 0 COMMENT '资源点数量',
points_bonus INT NOT NULL DEFAULT 0 COMMENT '积分奖励(购买会员后获得的资源点数量)',
features JSON COMMENT '功能特性JSON格式',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -144,47 +143,10 @@ CREATE TABLE IF NOT EXISTS user_memberships (
INDEX idx_end_date (end_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会员信息表';
-- 视频生成任务表
CREATE TABLE IF NOT EXISTS video_tasks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(100) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
task_type VARCHAR(50) NOT NULL COMMENT 'TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO',
title VARCHAR(200) NOT NULL,
description TEXT,
input_text TEXT,
input_image_url VARCHAR(500),
output_video_url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING, PROCESSING, COMPLETED, FAILED',
progress INT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_task_id (task_id),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频生成任务表';
-- 系统配置表
CREATE TABLE IF NOT EXISTS system_configs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value TEXT,
description VARCHAR(500),
config_type VARCHAR(50) NOT NULL DEFAULT 'STRING' COMMENT 'STRING, NUMBER, BOOLEAN, JSON',
is_public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
-- 系统设置表(用于存储系统级别的设置)
-- 注意:套餐价格已移至 membership_levels 表管理
CREATE TABLE IF NOT EXISTS system_settings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
standard_price_cny INT NOT NULL DEFAULT 0 COMMENT '标准版价格(元)',
pro_price_cny INT NOT NULL DEFAULT 0 COMMENT '专业版价格(元)',
points_per_generation INT NOT NULL DEFAULT 1 COMMENT '每次生成消耗的资源点',
site_name VARCHAR(100) NOT NULL DEFAULT 'AIGC Demo' COMMENT '站点名称',
site_subtitle VARCHAR(150) NOT NULL DEFAULT '现代化的Spring Boot应用演示' COMMENT '站点副标题',
@@ -192,7 +154,10 @@ CREATE TABLE IF NOT EXISTS system_settings (
maintenance_mode BOOLEAN NOT NULL DEFAULT FALSE COMMENT '维护模式',
enable_alipay BOOLEAN NOT NULL DEFAULT TRUE COMMENT '启用支付宝',
enable_paypal BOOLEAN NOT NULL DEFAULT TRUE COMMENT '启用PayPal',
contact_email VARCHAR(120) DEFAULT 'support@example.com' COMMENT '联系邮箱'
contact_email VARCHAR(120) DEFAULT 'support@example.com' COMMENT '联系邮箱',
prompt_optimization_model VARCHAR(50) DEFAULT 'gpt-5.1-thinking' COMMENT '优化提示词使用的模型',
storyboard_system_prompt VARCHAR(2000) DEFAULT '' COMMENT '分镜图生成系统引导词',
token_expire_hours INT NOT NULL DEFAULT 24 COMMENT 'Token过期时间小时'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统设置表';
-- 用户活跃度统计表

Binary file not shown.

View File

@@ -2,64 +2,89 @@ package com.example.demo.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import com.example.demo.model.SystemSettings;
import com.example.demo.repository.SystemSettingsRepository;
import jakarta.annotation.PostConstruct;
/**
* 动态API配置管理器
* 允许在运行时更新API密钥,无需重启应用
* 优先从数据库加载配置,支持运行时更新,无需重启应用
* 注意:视频生成和图片生成使用同一个 API Key
*/
@Component
public class DynamicApiConfig {
private static final Logger logger = LoggerFactory.getLogger(DynamicApiConfig.class);
// 从配置文件读取初始值
@Autowired
private SystemSettingsRepository systemSettingsRepository;
// 从配置文件读取的默认值(作为兜底)
@Value("${ai.api.key:}")
private String initialApiKey;
private String defaultApiKey;
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
private String initialApiBaseUrl;
@Value("${ai.api.base-url:https://ai.comfly.chat}")
private String defaultApiBaseUrl;
@Value("${ai.image.api.key:}")
private String initialImageApiKey;
@Value("${ai.image.api.base-url:https://ai.comfly.chat}")
private String initialImageApiBaseUrl;
// 运行时可更新的值如果为null则使用初始值
// 运行时配置(优先级最高,从数据库加载或动态更新)
private volatile String runtimeApiKey = null;
private volatile String runtimeApiBaseUrl = null;
private volatile String runtimeImageApiKey = null;
private volatile String runtimeImageApiBaseUrl = null;
/**
* 获取当前有效的AI API密钥
* 优先使用运行时设置的值,否则使用配置文件的值
* 应用启动时从数据库加载配置
*/
@PostConstruct
public void loadFromDatabase() {
try {
SystemSettings settings = systemSettingsRepository.findById(1L).orElse(null);
if (settings != null) {
if (settings.getAiApiKey() != null && !settings.getAiApiKey().isEmpty()) {
this.runtimeApiKey = settings.getAiApiKey();
logger.info("✅ 从数据库加载 AI API Key: {}****", settings.getAiApiKey().substring(0, Math.min(4, settings.getAiApiKey().length())));
}
if (settings.getAiApiBaseUrl() != null && !settings.getAiApiBaseUrl().isEmpty()) {
this.runtimeApiBaseUrl = settings.getAiApiBaseUrl();
logger.info("✅ 从数据库加载 AI API Base URL: {}", settings.getAiApiBaseUrl());
}
}
logger.info("API配置加载完成: apiKey={}, apiBaseUrl={}", maskApiKey(getApiKey()), getApiBaseUrl());
} catch (Exception e) {
logger.warn("从数据库加载API配置失败使用配置文件默认值: {}", e.getMessage());
}
}
/**
* 获取当前有效的AI API密钥视频和图片生成共用
* 优先级:运行时配置 > 配置文件默认值
*/
public String getApiKey() {
return runtimeApiKey != null ? runtimeApiKey : initialApiKey;
return runtimeApiKey != null ? runtimeApiKey : defaultApiKey;
}
/**
* 获取当前有效的AI API基础URL
*/
public String getApiBaseUrl() {
return runtimeApiBaseUrl != null ? runtimeApiBaseUrl : initialApiBaseUrl;
return runtimeApiBaseUrl != null ? runtimeApiBaseUrl : defaultApiBaseUrl;
}
/**
* 获取当前有效的图片API密钥
* 获取图片API密钥与视频API共用同一个Key
*/
public String getImageApiKey() {
return runtimeImageApiKey != null ? runtimeImageApiKey : initialImageApiKey;
return getApiKey();
}
/**
* 获取当前有效的图片API基础URL
* 获取图片API基础URL与视频API共用同一个URL
*/
public String getImageApiBaseUrl() {
return runtimeImageApiBaseUrl != null ? runtimeImageApiBaseUrl : initialImageApiBaseUrl;
return getApiBaseUrl();
}
/**
@@ -83,23 +108,17 @@ public class DynamicApiConfig {
}
/**
* 动态更新图片API密钥立即生效,无需重启
* 动态更新图片API密钥与视频API共用调用 updateApiKey
*/
public synchronized void updateImageApiKey(String newApiKey) {
if (newApiKey != null && !newApiKey.trim().isEmpty()) {
this.runtimeImageApiKey = newApiKey.trim();
logger.info("✅ 图片API密钥已动态更新立即生效无需重启");
}
updateApiKey(newApiKey);
}
/**
* 动态更新图片API基础URL立即生效,无需重启
* 动态更新图片API基础URL与视频API共用调用 updateApiBaseUrl
*/
public synchronized void updateImageApiBaseUrl(String newBaseUrl) {
if (newBaseUrl != null && !newBaseUrl.trim().isEmpty()) {
this.runtimeImageApiBaseUrl = newBaseUrl.trim();
logger.info("✅ 图片API基础URL已动态更新立即生效无需重启");
}
updateApiBaseUrl(newBaseUrl);
}
/**
@@ -108,8 +127,6 @@ public class DynamicApiConfig {
public synchronized void reset() {
this.runtimeApiKey = null;
this.runtimeApiBaseUrl = null;
this.runtimeImageApiKey = null;
this.runtimeImageApiBaseUrl = null;
logger.info("⚠️ API配置已重置为配置文件的初始值");
}
@@ -120,10 +137,7 @@ public class DynamicApiConfig {
java.util.Map<String, Object> status = new java.util.HashMap<>();
status.put("apiKey", maskApiKey(getApiKey()));
status.put("apiBaseUrl", getApiBaseUrl());
status.put("imageApiKey", maskApiKey(getImageApiKey()));
status.put("imageApiBaseUrl", getImageApiBaseUrl());
status.put("usingRuntimeConfig", runtimeApiKey != null || runtimeApiBaseUrl != null ||
runtimeImageApiKey != null || runtimeImageApiBaseUrl != null);
status.put("usingRuntimeConfig", runtimeApiKey != null || runtimeApiBaseUrl != null);
return status;
}

View File

@@ -408,7 +408,6 @@ public class AdminController {
SystemSettings settings = systemSettingsService.getOrCreate();
response.put("promptOptimizationModel", settings.getPromptOptimizationModel());
response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt());
response.put("siteName", settings.getSiteName());
response.put("siteSubtitle", settings.getSiteSubtitle());
@@ -416,9 +415,11 @@ public class AdminController {
response.put("maintenanceMode", settings.getMaintenanceMode());
response.put("contactEmail", settings.getContactEmail());
response.put("tokenExpireHours", settings.getTokenExpireHours());
// 套餐价格配置
response.put("standardPriceCny", settings.getStandardPriceCny());
response.put("proPriceCny", settings.getProPriceCny());
// 套餐价格配置从membership_levels表读取
membershipLevelRepository.findByName("standard").ifPresent(level ->
response.put("standardPriceCny", level.getPrice().intValue()));
membershipLevelRepository.findByName("professional").ifPresent(level ->
response.put("proPriceCny", level.getPrice().intValue()));
response.put("pointsPerGeneration", settings.getPointsPerGeneration());
// 支付渠道开关
response.put("enableAlipay", settings.getEnableAlipay());
@@ -452,13 +453,6 @@ public class AdminController {
logger.info("更新优化提示词模型为: {}", model);
}
// 更新优化提示词API端点
if (settingsData.containsKey("promptOptimizationApiUrl")) {
String apiUrl = (String) settingsData.get("promptOptimizationApiUrl");
settings.setPromptOptimizationApiUrl(apiUrl);
logger.info("更新优化提示词API端点为: {}", apiUrl);
}
// 更新分镜图系统引导词
if (settingsData.containsKey("storyboardSystemPrompt")) {
String prompt = (String) settingsData.get("storyboardSystemPrompt");
@@ -487,30 +481,26 @@ public class AdminController {
}
}
// 更新套餐价格(同时更新system_settings和membership_levels表
// 更新套餐价格(只更新membership_levels表
if (settingsData.containsKey("standardPriceCny")) {
Object value = settingsData.get("standardPriceCny");
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
settings.setStandardPriceCny(price);
// 同步更新membership_levels表
membershipLevelRepository.findByName("standard").ifPresent(level -> {
level.setPrice(price.doubleValue());
level.setUpdatedAt(java.time.LocalDateTime.now());
membershipLevelRepository.save(level);
logger.info("同步更新membership_levels表: standard价格={}", price);
logger.info("更新membership_levels表: standard价格={}", price);
});
logger.info("更新标准版价格为: {} 元", price);
}
if (settingsData.containsKey("proPriceCny")) {
Object value = settingsData.get("proPriceCny");
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
settings.setProPriceCny(price);
// 同步更新membership_levels表
membershipLevelRepository.findByName("professional").ifPresent(level -> {
level.setPrice(price.doubleValue());
level.setUpdatedAt(java.time.LocalDateTime.now());
membershipLevelRepository.save(level);
logger.info("同步更新membership_levels表: professional价格={}", price);
logger.info("更新membership_levels表: professional价格={}", price);
});
logger.info("更新专业版价格为: {} 元", price);
}

View File

@@ -1,21 +1,11 @@
package com.example.demo.controller;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,33 +15,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.config.DynamicApiConfig;
import com.example.demo.model.SystemSettings;
import com.example.demo.service.SystemSettingsService;
/**
* API密钥管理控制器
* 配置保存到数据库,支持运行时更新,重启后自动加载
*/
@RestController
@RequestMapping("/api/api-key")
@CrossOrigin(origins = "*")
public class ApiKeyController {
private static final Logger logger = LoggerFactory.getLogger(ApiKeyController.class);
@Value("${spring.profiles.active:dev}")
private String activeProfile;
@Value("${ai.api.key:}")
private String currentApiKey;
@Value("${jwt.expiration:86400000}")
private Long currentJwtExpiration;
@Autowired
private DynamicApiConfig dynamicApiConfig;
@Autowired
private SystemSettingsService systemSettingsService;
/**
* 获取当前API密钥和JWT配置(仅显示部分,用于验证)
* 获取当前API密钥配置仅显示部分用于验证
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getApiKey() {
try {
Map<String, Object> response = new HashMap<>();
// 从动态配置获取当前使用的 API Key
String currentApiKey = dynamicApiConfig.getApiKey();
String currentApiBaseUrl = dynamicApiConfig.getApiBaseUrl();
// 只返回密钥的前4位和后4位中间用*代替
if (currentApiKey != null && currentApiKey.length() > 8) {
String masked = currentApiKey.substring(0, 4) + "****" + currentApiKey.substring(currentApiKey.length() - 4);
@@ -59,10 +54,13 @@ public class ApiKeyController {
} else {
response.put("maskedKey", "****");
}
// 返回JWT过期时间毫秒
response.put("jwtExpiration", currentJwtExpiration);
// 转换为小时显示
response.put("jwtExpirationHours", currentJwtExpiration / 3600000.0);
response.put("apiBaseUrl", currentApiBaseUrl);
// 从数据库获取 Token 过期时间
SystemSettings settings = systemSettingsService.getOrCreate();
response.put("tokenExpireHours", settings.getTokenExpireHours());
response.put("success", true);
return ResponseEntity.ok(response);
} catch (Exception e) {
@@ -75,99 +73,94 @@ public class ApiKeyController {
}
/**
* 更新API密钥和JWT配置到配置文件
* 更新API密钥配置到数据库(立即生效,重启后自动加载)
*/
@PutMapping
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestBody Map<String, Object> request) {
try {
String newApiKey = (String) request.get("apiKey");
Object jwtExpirationObj = request.get("jwtExpiration");
String newApiBaseUrl = (String) request.get("apiBaseUrl");
Object tokenExpireObj = request.get("tokenExpireHours");
// 验证API密钥
if (newApiKey != null && newApiKey.trim().isEmpty()) {
newApiKey = null; // 如果为空字符串,则不更新
newApiKey = null;
}
// 验证JWT过期时间
Long newJwtExpiration = null;
if (jwtExpirationObj != null) {
if (jwtExpirationObj instanceof Number) {
newJwtExpiration = ((Number) jwtExpirationObj).longValue();
} else if (jwtExpirationObj instanceof String) {
// 验证API基础URL
if (newApiBaseUrl != null && newApiBaseUrl.trim().isEmpty()) {
newApiBaseUrl = null;
}
// 验证Token过期时间
Integer tokenExpireHours = null;
if (tokenExpireObj != null) {
if (tokenExpireObj instanceof Number) {
tokenExpireHours = ((Number) tokenExpireObj).intValue();
} else if (tokenExpireObj instanceof String) {
try {
newJwtExpiration = Long.parseLong((String) jwtExpirationObj);
tokenExpireHours = Integer.parseInt((String) tokenExpireObj);
} catch (NumberFormatException e) {
Map<String, Object> error = new HashMap<>();
error.put("error", "JWT过期时间格式错误");
error.put("message", "JWT过期时间必须是数字毫秒");
return ResponseEntity.badRequest().body(error);
// 忽略无效值
}
}
// 验证过期时间范围至少1小时最多30天
if (newJwtExpiration != null && (newJwtExpiration < 3600000 || newJwtExpiration > 2592000000L)) {
Map<String, Object> error = new HashMap<>();
error.put("error", "JWT过期时间超出范围");
error.put("message", "JWT过期时间必须在1小时3600000毫秒到30天2592000000毫秒之间");
return ResponseEntity.badRequest().body(error);
// 验证范围
if (tokenExpireHours != null && (tokenExpireHours < 1 || tokenExpireHours > 720)) {
tokenExpireHours = null;
}
}
// 如果都没有提供,返回错误
if (newApiKey == null && newJwtExpiration == null) {
if (newApiKey == null && newApiBaseUrl == null && tokenExpireHours == null) {
Map<String, Object> error = new HashMap<>();
error.put("error", "至少需要提供一个配置项");
error.put("message", "请提供API密钥或JWT过期时间");
error.put("message", "请提供API密钥、API基础URL或Token过期时间");
return ResponseEntity.badRequest().body(error);
}
// 确定配置文件路径
String configFileName = "application-" + activeProfile + ".properties";
Path configPath = getConfigFilePath(configFileName);
// 读取现有配置
Properties props = new Properties();
if (Files.exists(configPath)) {
try (FileInputStream fis = new FileInputStream(configPath.toFile())) {
props.load(fis);
}
}
// 获取系统设置
SystemSettings settings = systemSettingsService.getOrCreate();
StringBuilder message = new StringBuilder();
// 更新API密钥
if (newApiKey != null) {
props.setProperty("ai.api.key", newApiKey);
props.setProperty("ai.image.api.key", newApiKey); // 同时更新图片API密钥
logger.info("API密钥已更新到配置文件");
newApiKey = newApiKey.trim();
settings.setAiApiKey(newApiKey);
// 动态更新运行时配置,立即生效
// 动态更新运行时配置,立即生效
dynamicApiConfig.updateApiKey(newApiKey);
dynamicApiConfig.updateImageApiKey(newApiKey);
logger.info("✅ API密钥已立即生效(无需重启应用)");
logger.info("✅ API密钥已保存到数据库并立即生效");
message.append("API密钥已更新。");
}
// 更新JWT过期时间
if (newJwtExpiration != null) {
props.setProperty("jwt.expiration", String.valueOf(newJwtExpiration));
logger.info("JWT过期时间已更新: {} 毫秒 ({} 小时)", newJwtExpiration, newJwtExpiration / 3600000.0);
// 更新API基础URL
if (newApiBaseUrl != null) {
newApiBaseUrl = newApiBaseUrl.trim();
settings.setAiApiBaseUrl(newApiBaseUrl);
// 动态更新运行时配置,立即生效
dynamicApiConfig.updateApiBaseUrl(newApiBaseUrl);
logger.info("✅ API基础URL已保存到数据库并立即生效: {}", newApiBaseUrl);
message.append("API基础URL已更新。");
}
// 保存配置文件
try (FileOutputStream fos = new FileOutputStream(configPath.toFile())) {
props.store(fos, "Updated by API Key Management");
// 更新Token过期时间
if (tokenExpireHours != null) {
settings.setTokenExpireHours(tokenExpireHours);
logger.info("✅ Token过期时间已保存到数据库: {} 小时", tokenExpireHours);
message.append("Token过期时间已更新为" + tokenExpireHours + "小时。");
}
logger.info("配置已更新到配置文件: {}", configPath);
// 保存到数据库
systemSettingsService.update(settings);
message.append("配置已保存到数据库,立即生效且重启后自动加载。");
Map<String, Object> response = new HashMap<>();
response.put("success", true);
StringBuilder message = new StringBuilder();
if (newApiKey != null) {
message.append("API密钥已更新。");
}
if (newJwtExpiration != null) {
message.append("JWT过期时间已更新。");
}
message.append("配置已立即生效(无需重启)。如需永久保存,请重启应用加载配置文件。");
response.put("message", message.toString());
return ResponseEntity.ok(response);
@@ -180,45 +173,5 @@ message.append("配置已立即生效(无需重启)。如需永久保存,
return ResponseEntity.status(500).body(error);
}
}
/**
* 获取配置文件路径
* 优先使用外部配置文件如果不存在则使用classpath中的配置文件
*/
private Path getConfigFilePath(String fileName) throws IOException {
// 尝试从外部配置目录查找
String externalConfigDir = System.getProperty("user.dir");
Path externalPath = Paths.get(externalConfigDir, "config", fileName);
if (Files.exists(externalPath)) {
return externalPath;
}
// 尝试从项目根目录查找
Path rootPath = Paths.get(externalConfigDir, "src", "main", "resources", fileName);
if (Files.exists(rootPath)) {
return rootPath;
}
// 尝试从classpath复制到外部目录
ClassPathResource resource = new ClassPathResource(fileName);
if (resource.exists()) {
// 创建config目录
Path configDir = Paths.get(externalConfigDir, "config");
Files.createDirectories(configDir);
// 复制文件到外部目录
Path targetPath = configDir.resolve(fileName);
try (InputStream is = resource.getInputStream();
FileOutputStream fos = new FileOutputStream(targetPath.toFile())) {
is.transferTo(fos);
}
return targetPath;
}
// 如果都不存在,创建新的配置文件
Path configDir = Paths.get(externalConfigDir, "config");
Files.createDirectories(configDir);
return configDir.resolve(fileName);
}
}

View File

@@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -128,6 +129,82 @@ public class ImageToVideoApiController {
}
}
/**
* 通过图片URL创建图生视频任务用于"做同款"功能)
*/
@PostMapping("/create-by-url")
public ResponseEntity<Map<String, Object>> createTaskByUrl(
@RequestBody Map<String, Object> request,
@RequestHeader("Authorization") String token) {
Map<String, Object> response = new HashMap<>();
try {
// 验证用户身份
String username = extractUsernameFromToken(token);
if (username == null) {
response.put("success", false);
response.put("message", "用户未登录或token无效");
return ResponseEntity.status(401).body(response);
}
// 提取参数
String imageUrl = (String) request.get("imageUrl");
String prompt = (String) request.get("prompt");
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
int duration = request.get("duration") instanceof Number
? ((Number) request.get("duration")).intValue()
: Integer.parseInt(request.getOrDefault("duration", "5").toString());
boolean hdMode = Boolean.parseBoolean(request.getOrDefault("hdMode", "false").toString());
logger.info("通过URL创建图生视频任务: username={}, imageUrl={}, prompt={}",
username, imageUrl != null ? imageUrl.substring(0, Math.min(50, imageUrl.length())) : "null", prompt);
// 验证参数
if (imageUrl == null || imageUrl.trim().isEmpty()) {
response.put("success", false);
response.put("message", "图片URL不能为空");
return ResponseEntity.badRequest().body(response);
}
if (prompt == null || prompt.trim().isEmpty()) {
response.put("success", false);
response.put("message", "描述文字不能为空");
return ResponseEntity.badRequest().body(response);
}
if (duration < 1 || duration > 60) {
response.put("success", false);
response.put("message", "视频时长必须在1-60秒之间");
return ResponseEntity.badRequest().body(response);
}
if (!isValidAspectRatio(aspectRatio)) {
response.put("success", false);
response.put("message", "不支持的视频比例");
return ResponseEntity.badRequest().body(response);
}
// 创建任务
ImageToVideoTask task = imageToVideoService.createTaskByUrl(
username, imageUrl.trim(), prompt.trim(), aspectRatio, duration, hdMode
);
response.put("success", true);
response.put("message", "任务创建成功");
response.put("data", task);
logger.info("用户 {} 通过URL创建图生视频任务成功: {}", username, task.getId());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("通过URL创建图生视频任务失败", e);
response.put("success", false);
response.put("message", "创建任务失败:" + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 获取用户的任务列表
*/

View File

@@ -94,9 +94,9 @@ public class MemberApiController {
member.put("createdAt", user.getCreatedAt());
member.put("lastLoginAt", user.getLastLoginAt());
// 获取会员信息
// 获取会员信息(按到期时间降序,返回最新的)
Optional<UserMembership> membership = userMembershipRepository
.findByUserIdAndStatus(user.getId(), "ACTIVE");
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
if (membership.isPresent()) {
UserMembership userMembership = membership.get();
@@ -155,9 +155,9 @@ public class MemberApiController {
member.put("createdAt", user.getCreatedAt());
member.put("lastLoginAt", user.getLastLoginAt());
// 获取会员信息
// 获取会员信息(按到期时间降序,返回最新的)
Optional<UserMembership> membership = userMembershipRepository
.findByUserIdAndStatus(user.getId(), "ACTIVE");
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
if (membership.isPresent()) {
UserMembership userMembership = membership.get();
@@ -256,9 +256,9 @@ public class MemberApiController {
if (levelOpt.isPresent()) {
MembershipLevel level = levelOpt.get();
// 查找或创建会员信息
// 查找或创建会员信息(按到期时间降序,返回最新的)
Optional<UserMembership> membershipOpt = userMembershipRepository
.findByUserIdAndStatus(user.getId(), "ACTIVE");
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
UserMembership membership;
if (membershipOpt.isPresent()) {

View File

@@ -77,8 +77,9 @@ public class PayPalController {
String orderId = (String) request.get("orderId");
String amount = request.get("amount") != null ? request.get("amount").toString() : null;
String method = (String) request.get("method");
String description = (String) request.get("description");
payment = paymentService.createPayment(username, orderId, amount, method);
payment = paymentService.createPayment(username, orderId, amount, method, description);
}
// 调用 PayPal API 创建支付

View File

@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
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.UserMembership;
@@ -33,6 +34,7 @@ import com.example.demo.repository.PaymentRepository;
import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.MembershipLevelRepository;
import com.example.demo.service.AlipayService;
import com.example.demo.service.OrderService;
import com.example.demo.service.PaymentService;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtils;
@@ -52,6 +54,9 @@ public class PaymentApiController {
@Autowired
private UserService userService;
@Autowired
private OrderService orderService;
@Autowired
private PaymentRepository paymentRepository;
@@ -162,13 +167,14 @@ public class PaymentApiController {
String orderId = (String) paymentData.get("orderId");
String amountStr = paymentData.get("amount") != null ? paymentData.get("amount").toString() : null;
String method = (String) paymentData.get("method");
String description = (String) paymentData.get("description");
if (orderId == null || amountStr == null || method == null) {
return ResponseEntity.badRequest()
.body(createErrorResponse("订单号、金额和支付方式不能为空"));
}
Payment payment = paymentService.createPayment(username, orderId, amountStr, method);
Payment payment = paymentService.createPayment(username, orderId, amountStr, method, description);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
@@ -210,6 +216,7 @@ public class PaymentApiController {
payment.setStatus(PaymentStatus.valueOf(status));
paymentService.save(payment);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "状态更新成功");
@@ -223,6 +230,41 @@ public class PaymentApiController {
}
}
/**
* 更新支付方式和描述(用于切换支付方式时)
*/
@PutMapping("/{id}/method")
public ResponseEntity<Map<String, Object>> updatePaymentMethod(
@PathVariable Long id,
@RequestBody Map<String, String> methodData,
Authentication authentication) {
try {
String method = methodData.get("method");
String description = methodData.get("description");
if (method == null) {
return ResponseEntity.badRequest()
.body(createErrorResponse("支付方式不能为空"));
}
// 调用Service层方法带事务
Payment payment = paymentService.updatePaymentMethod(id, method, description, authentication.getName());
logger.info("支付方式更新成功: paymentId={}, method={}, description={}", id, method, description);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "支付方式更新成功");
response.put("data", payment);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("更新支付方式失败", e);
return ResponseEntity.badRequest()
.body(createErrorResponse("更新支付方式失败: " + e.getMessage()));
}
}
/**
* 确认支付成功
*/
@@ -340,7 +382,7 @@ public class PaymentApiController {
// 生成测试订单号
String testOrderId = "TEST_" + System.currentTimeMillis();
Payment payment = paymentService.createPayment(username, testOrderId, amountStr, method);
Payment payment = paymentService.createPayment(username, testOrderId, amountStr, method, "测试支付");
Map<String, Object> response = new HashMap<>();
response.put("success", true);
@@ -467,9 +509,9 @@ public class PaymentApiController {
String expiryTime = "永久";
LocalDateTime paidAt = null;
// 优先从UserMembership表获取用户的实际会员等级管理员可能手动修改
// 优先从UserMembership表获取用户的实际会员等级按到期时间降序,返回最新的
try {
java.util.Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
java.util.Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
if (membershipOpt.isPresent()) {
UserMembership membership = membershipOpt.get();
LocalDateTime endDate = membership.getEndDate();

View File

@@ -83,6 +83,18 @@ public class FailedTaskCleanupLog {
);
}
// 从StoryboardVideoTask创建
public static FailedTaskCleanupLog fromStoryboardVideoTask(StoryboardVideoTask task) {
return new FailedTaskCleanupLog(
task.getTaskId(),
task.getUsername(),
"STORYBOARD_VIDEO",
task.getErrorMessage(),
task.getCreatedAt(),
task.getUpdatedAt()
);
}
// Getters and Setters
public Long getId() {
return id;

View File

@@ -17,18 +17,6 @@ public class SystemSettings {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 标准版价格(单位:元) */
@NotNull
@Min(0)
@Column(nullable = false)
private Integer standardPriceCny = 298;
/** 专业版价格(单位:元) */
@NotNull
@Min(0)
@Column(nullable = false)
private Integer proPriceCny = 398;
/** 每次生成消耗的资源点数量 */
@NotNull
@Min(0)
@@ -70,10 +58,6 @@ public class SystemSettings {
@Column(length = 50)
private String promptOptimizationModel = "gpt-5.1-thinking";
/** 优化提示词API端点 */
@Column(length = 200)
private String promptOptimizationApiUrl = "https://ai.comfly.chat";
/** 分镜图生成系统引导词 */
@Column(length = 2000)
private String storyboardSystemPrompt = "";
@@ -84,6 +68,14 @@ public class SystemSettings {
@Column(nullable = false)
private Integer tokenExpireHours = 24;
/** AI API密钥视频和图片生成共用 */
@Column(length = 200)
private String aiApiKey;
/** AI API基础URL */
@Column(length = 200)
private String aiApiBaseUrl;
public Long getId() {
return id;
}
@@ -92,22 +84,6 @@ public class SystemSettings {
this.id = id;
}
public Integer getStandardPriceCny() {
return standardPriceCny;
}
public void setStandardPriceCny(Integer standardPriceCny) {
this.standardPriceCny = standardPriceCny;
}
public Integer getProPriceCny() {
return proPriceCny;
}
public void setProPriceCny(Integer proPriceCny) {
this.proPriceCny = proPriceCny;
}
public Integer getPointsPerGeneration() {
return pointsPerGeneration;
}
@@ -180,14 +156,6 @@ public class SystemSettings {
this.promptOptimizationModel = promptOptimizationModel;
}
public String getPromptOptimizationApiUrl() {
return promptOptimizationApiUrl;
}
public void setPromptOptimizationApiUrl(String promptOptimizationApiUrl) {
this.promptOptimizationApiUrl = promptOptimizationApiUrl;
}
public String getStoryboardSystemPrompt() {
return storyboardSystemPrompt;
}
@@ -206,6 +174,22 @@ public class SystemSettings {
this.tokenExpireHours = tokenExpireHours;
}
}
public String getAiApiKey() {
return aiApiKey;
}
public void setAiApiKey(String aiApiKey) {
this.aiApiKey = aiApiKey;
}
public String getAiApiBaseUrl() {
return aiApiBaseUrl;
}
public void setAiApiBaseUrl(String aiApiBaseUrl) {
this.aiApiBaseUrl = aiApiBaseUrl;
}
}

View File

@@ -22,6 +22,12 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
@Query("SELECT p FROM Payment p LEFT JOIN FETCH p.user WHERE p.id = :id")
Optional<Payment> findByIdWithUser(@Param("id") Long id);
/**
* 根据ID查询Payment并立即加载User和Order避免LazyInitializationException
*/
@Query("SELECT p FROM Payment p LEFT JOIN FETCH p.user LEFT JOIN FETCH p.order WHERE p.id = :id")
Optional<Payment> findByIdWithUserAndOrder(@Param("id") Long id);
Optional<Payment> findByExternalTransactionId(String externalTransactionId);
List<Payment> findByUserId(Long userId);

View File

@@ -2,19 +2,9 @@ package com.example.demo.repository;
import com.example.demo.model.SystemSettings;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface SystemSettingsRepository extends JpaRepository<SystemSettings, Long> {
@Modifying
@Query("UPDATE SystemSettings s SET s.standardPriceCny = :price WHERE s.id = :id")
int updateStandardPrice(@Param("id") Long id, @Param("price") Integer price);
@Modifying
@Query("UPDATE SystemSettings s SET s.proPriceCny = :price WHERE s.id = :id")
int updateProPrice(@Param("id") Long id, @Param("price") Integer price);
// 套餐价格已移至 membership_levels 表管理
}

View File

@@ -12,6 +12,11 @@ import com.example.demo.model.UserMembership;
public interface UserMembershipRepository extends JpaRepository<UserMembership, Long> {
Optional<UserMembership> findByUserIdAndStatus(Long userId, String status);
/**
* 按到期时间降序查找用户的有效会员记录(返回到期时间最晚的)
*/
Optional<UserMembership> findFirstByUserIdAndStatusOrderByEndDateDesc(Long userId, String status);
long countByStatus(String status);
long countByStartDateBetween(LocalDateTime startDate, LocalDateTime endDate);

View File

@@ -83,13 +83,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String token = jwtUtils.extractTokenFromHeader(authHeader);
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
logger.info("JWT过滤器: 收到token, 长度={}, 前20字符={}", token.length(), token.substring(0, Math.min(20, token.length())));
String username = jwtUtils.getUsernameFromToken(token);
logger.info("JWT过滤器: 从token提取用户名={}", username);
if (username != null && jwtUtils.validateToken(token, username)) {
logger.info("JWT过滤器: token验证通过, username={}", username);
// Redis 验证已降级isTokenValid 总是返回 true
// 主要依赖 JWT 本身的有效性验证
User user = userService.findByUsernameOrNull(username);

View File

@@ -227,18 +227,30 @@ public class AlipayService {
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;
logger.warn("支付宝返回交易已成功,订单号:{}支付ID{},当前状态:{}",
payment.getOrderId(), payment.getId(), payment.getStatus());
// 检查当前支付记录的状态
// 如果当前支付记录已经是成功状态,说明是重复通知,可以返回成功
// 如果当前支付记录是PENDING状态说明可能是订单号冲突需要生成新的订单号
if (payment.getStatus() == PaymentStatus.SUCCESS) {
logger.info("支付记录已经是成功状态,返回已支付信息");
Map<String, Object> result = new HashMap<>();
result.put("alreadyPaid", true);
result.put("message", "该订单已支付成功");
return result;
} else {
// 当前支付是PENDING状态但支付宝说已成功
// 这可能是订单号冲突(之前的支付使用了相同的订单号)
// 不应该直接将新支付标记为成功,而是抛出错误让前端重新创建
logger.error("⚠️ 订单号冲突支付宝返回交易已成功但当前支付记录状态为PENDING。订单号{}支付ID{}",
payment.getOrderId(), payment.getId());
logger.error("这可能是订单号冲突导致的,建议生成新的订单号重新创建支付");
throw new RuntimeException("订单号冲突:该订单号已被使用,请重新创建支付订单");
}
}
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");

View File

@@ -116,6 +116,51 @@ public class ImageToVideoService {
}
}
/**
* 通过图片URL创建图生视频任务用于"做同款"功能)
* 直接使用已有的图片URL无需重新上传
*/
@Transactional
public ImageToVideoTask createTaskByUrl(String username, String imageUrl, String prompt,
String aspectRatio, int duration, boolean hdMode) {
try {
// 检查用户所有类型任务的总数(统一检查)
userWorkService.checkMaxConcurrentTasks(username);
// 生成任务ID
String taskId = generateTaskId();
// 直接使用传入的图片URL作为首帧图片URL
String firstFrameUrl = imageUrl;
// 创建任务记录
ImageToVideoTask task = new ImageToVideoTask(
taskId, username, firstFrameUrl, prompt, aspectRatio, duration, hdMode
);
// 保存到数据库
task = taskRepository.save(task);
// 添加任务到队列
taskQueueService.addImageToVideoTask(username, taskId);
// 创建PROCESSING状态的UserWork以便用户刷新页面后能恢复任务
try {
userWorkService.createProcessingImageToVideoWork(task);
} catch (Exception e) {
logger.warn("创建PROCESSING状态作品失败不影响任务执行: {}", taskId, e);
}
logger.info("通过URL创建图生视频任务成功: taskId={}, username={}, imageUrl={}",
taskId, username, imageUrl.substring(0, Math.min(50, imageUrl.length())));
return task;
} catch (Exception e) {
logger.error("通过URL创建图生视频任务失败", e);
throw new RuntimeException("创建任务失败: " + e.getMessage());
}
}
/**
* 获取用户任务列表
*/

View File

@@ -280,29 +280,76 @@ public class OrderService {
}
/**
* 根据订单描述获取对应会员等级的积分(完全动态,从数据库获取所有会员等级进行匹配)
* 根据订单描述或金额获取对应会员等级的积分(完全动态,从数据库获取所有会员等级进行匹配)
*/
private int getPointsFromOrderMembershipLevel(Order order) {
String description = order.getDescription();
if (description == null || description.isEmpty()) {
return 0;
}
// 从数据库获取所有会员等级,动态匹配
// 从数据库获取所有会员等级
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
for (MembershipLevel level : allLevels) {
// 检查订单描述是否包含会员等级的name或displayName不区分大小写
String name = level.getName();
String displayName = level.getDisplayName();
// 1. 首先尝试从描述匹配
if (description != null && !description.isEmpty()) {
String descLower = description.toLowerCase();
if ((name != null && descLower.contains(name.toLowerCase())) ||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
logger.info("从订单描述匹配到会员等级: level={}, points={}", name, level.getPointsBonus());
return level.getPointsBonus();
for (MembershipLevel level : allLevels) {
String name = level.getName();
String displayName = level.getDisplayName();
// 检查订单描述是否包含会员等级的name或displayName不区分大小写
if ((name != null && descLower.contains(name.toLowerCase())) ||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
logger.info("从订单描述匹配到会员等级: level={}, points={}", name, level.getPointsBonus());
return level.getPointsBonus();
}
// 额外匹配中文关键词
if (name != null) {
if (name.equals("standard") && (description.contains("标准") || description.contains("Standard"))) {
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
return level.getPointsBonus();
}
if (name.equals("professional") && (description.contains("专业") || description.contains("Professional") || description.contains("Pro"))) {
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
return level.getPointsBonus();
}
if (name.equals("free") && (description.contains("免费") || description.contains("Free"))) {
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
return level.getPointsBonus();
}
}
}
}
// 2. 描述为空或无法匹配时,使用金额判断
BigDecimal amount = order.getTotalAmount();
if (amount != null) {
int amountInt = amount.intValue();
logger.info("订单描述为空或无法匹配,尝试使用金额判断: amount={}", amountInt);
// 按价格从高到低排序,优先匹配高价套餐
allLevels.sort((a, b) -> {
Double priceA = a.getPrice() != null ? a.getPrice() : 0.0;
Double priceB = b.getPrice() != null ? b.getPrice() : 0.0;
return priceB.compareTo(priceA);
});
for (MembershipLevel level : allLevels) {
if (level.getPrice() == null || level.getPrice() <= 0) continue;
int levelPrice = level.getPrice().intValue();
// 允许10%的价格浮动
if (amountInt >= levelPrice * 0.9 && amountInt <= levelPrice * 1.1) {
logger.info("从订单金额匹配到会员等级: level={}, price={}, amount={}, points={}",
level.getName(), levelPrice, amountInt, level.getPointsBonus());
return level.getPointsBonus();
}
}
logger.warn("无法从订单金额匹配会员等级: amount={}", amountInt);
}
logger.warn("无法从订单识别会员等级: description={}, amount={}", description, amount);
return 0;
}
@@ -372,8 +419,8 @@ public class OrderService {
}
int durationDays = level.getDurationDays();
// 查找或创建用户会员信息
Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
// 查找或创建用户会员信息(按到期时间降序,返回最新的)
Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
UserMembership membership;
LocalDateTime now = LocalDateTime.now();

View File

@@ -40,6 +40,38 @@ public class PaymentService {
@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); }
/**
* 更新支付方式和描述(带事务,解决懒加载问题)
*/
@Transactional
public Payment updatePaymentMethod(Long paymentId, String method, String description, String username) {
// 使用findByIdWithUserAndOrder一次性加载User和Order避免懒加载异常
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在"));
// 检查权限
if (!payment.getUser().getUsername().equals(username)) {
throw new RuntimeException("无权限修改此支付记录");
}
// 只有PENDING状态的支付才能修改支付方式
if (payment.getStatus() != PaymentStatus.PENDING) {
throw new RuntimeException("只有待支付状态的订单才能修改支付方式");
}
payment.setPaymentMethod(PaymentMethod.valueOf(method));
if (description != null && !description.isEmpty()) {
payment.setDescription(description);
// 同时更新关联订单的描述
if (payment.getOrder() != null) {
payment.getOrder().setDescription(description);
// 由于在同一事务中Order会自动保存
}
}
return paymentRepository.save(payment);
}
public Payment updatePaymentStatus(Long paymentId, PaymentStatus newStatus) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
payment.setStatus(newStatus);
@@ -48,7 +80,9 @@ public class PaymentService {
}
public Payment confirmPaymentSuccess(Long paymentId, String externalTransactionId) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
// 使用findByIdWithUserAndOrder确保加载User和Order避免懒加载问题
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
.orElseThrow(() -> new RuntimeException("Not found"));
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
if (payment.getStatus() == PaymentStatus.SUCCESS) {
@@ -62,19 +96,14 @@ public class PaymentService {
Payment savedPayment = paymentRepository.save(payment);
// 支付成功后更新订单状态为已支付
// 注意:积分添加逻辑已移至 OrderService.handlePointsForStatusChange
// 当订单状态变为 PAID 时会自动添加积分,避免重复添加
try {
updateOrderStatusForPayment(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;
}
@@ -95,9 +124,8 @@ public class PaymentService {
}
try {
// 更新订单状态为已支付
order.setStatus(OrderStatus.PAID);
order.setPaidAt(LocalDateTime.now());
// 直接调用orderService.updateOrderStatus不要在这里修改order状态
// orderService.updateOrderStatus会正确处理状态变更和积分添加
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
logger.info("✅ 订单状态更新为已支付: orderId={}, orderNumber={}, paymentId={}",
@@ -207,19 +235,33 @@ public class PaymentService {
}
@Transactional
public Payment createPayment(String username, String orderId, String amountStr, String method) {
public Payment createPayment(String username, String orderId, String amountStr, String method, String description) {
// 检查是否已存在相同 orderId 的支付记录
Optional<Payment> existing = paymentRepository.findByOrderId(orderId);
if (existing.isPresent()) {
Payment existingPayment = existing.get();
// 如果已存在且状态是 PENDING直接返回
// 如果已存在且状态是 PENDING检查是否是同一用户且金额相同
if (existingPayment.getStatus() == PaymentStatus.PENDING) {
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
return existingPayment;
// 验证用户和金额是否匹配
if (existingPayment.getUser().getUsername().equals(username) &&
existingPayment.getAmount().compareTo(new BigDecimal(amountStr)) == 0) {
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
return existingPayment;
} else {
// 用户或金额不匹配,生成新的 orderId
logger.warn("已存在相同orderId的PENDING支付但用户或金额不匹配生成新orderId: {}", orderId);
orderId = orderId + "_" + System.currentTimeMillis();
}
} else if (existingPayment.getStatus() == PaymentStatus.SUCCESS) {
// 如果已存在成功状态的支付,绝对不能复用,必须生成新的 orderId
logger.warn("⚠️ 已存在相同orderId的成功支付记录生成新orderId避免冲突: orderId={}, existingPaymentId={}, status={}",
orderId, existingPayment.getId(), existingPayment.getStatus());
orderId = orderId + "_" + System.currentTimeMillis();
} else {
// 如果是其他状态FAILED、CANCELLED等生成新的 orderId
orderId = orderId + "_" + System.currentTimeMillis();
logger.info("已存在相同orderId但状态为{}生成新orderId: {}", existingPayment.getStatus(), orderId);
}
// 如果是其他状态,生成新的 orderId
orderId = orderId + "_" + System.currentTimeMillis();
logger.info("已存在相同orderId但状态为{}生成新orderId: {}", existingPayment.getStatus(), orderId);
}
User user = null;
@@ -237,11 +279,24 @@ public class PaymentService {
order.setStatus(OrderStatus.PENDING); // 待支付状态
order.setOrderType(OrderType.SUBSCRIPTION);
// 根据金额设置订单描述
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
order.setDescription("专业版会员订阅 - " + amount + "");
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
order.setDescription("标准版会员订阅 - " + amount + "");
// 使用前端传递的描述如果没有则根据orderId中的套餐类型设置
if (description != null && !description.isEmpty()) {
order.setDescription(description);
} else if (orderId != null && orderId.contains("_")) {
// 从orderId中提取套餐类型如 SUB_standard_xxx -> standard
String[] parts = orderId.split("_");
if (parts.length >= 2) {
String planType = parts[1];
if ("standard".equalsIgnoreCase(planType)) {
order.setDescription("标准版会员订阅 - " + amount + "");
} else if ("premium".equalsIgnoreCase(planType)) {
order.setDescription("专业版会员订阅 - " + amount + "");
} else {
order.setDescription("会员订阅 - " + amount + "");
}
} else {
order.setDescription("会员订阅 - " + amount + "");
}
} else {
order.setDescription("会员订阅 - " + amount + "");
}

View File

@@ -145,13 +145,20 @@ public class RealAIService {
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
// Sora2 API 使用 task_id 字段表示成功
// 支持两种响应格式:{"task_id": "xxx"} 或 {"id": "xxx"}
String taskId = null;
if (responseBody.containsKey("task_id")) {
logger.info("分镜视频任务提交成功task_id: {}", responseBody.get("task_id"));
taskId = String.valueOf(responseBody.get("task_id"));
} else if (responseBody.containsKey("id")) {
taskId = String.valueOf(responseBody.get("id"));
}
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
logger.info("分镜视频任务提交成功task_id: {}", taskId);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", responseBody);
result.put("task_id", responseBody.get("task_id"));
result.put("task_id", taskId);
return result;
} else {
// 处理错误响应
@@ -291,24 +298,30 @@ public class RealAIService {
try {
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
// Sora2 API 使用 task_id 字段表示成功(与文生视频相同格式)
if (responseBody.containsKey("task_id")) {
logger.info("图生视频任务提交成功task_id: {}", responseBody.get("task_id"));
// 转换为统一的响应格式(与文生视频保持一致)
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", responseBody);
result.put("task_id", responseBody.get("task_id"));
return result;
// 支持两种响应格式:{"task_id": "xxx"} 或 {"id": "xxx"}
String taskId = null;
if (responseBody.containsKey("task_id")) {
taskId = String.valueOf(responseBody.get("task_id"));
} else if (responseBody.containsKey("id")) {
taskId = String.valueOf(responseBody.get("id"));
}
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
logger.info("图生视频任务提交成功task_id: {}", taskId);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", responseBody);
result.put("task_id", taskId);
return result;
} else {
// 处理错误响应
logger.error("图生视频任务提交失败响应中缺少task_id: {}", responseBody);
String errorMsg = "未知错误";
if (responseBody.get("message") != null) {
errorMsg = responseBody.get("message").toString();
}
throw new RuntimeException("任务提交失败: " + errorMsg);
// 处理错误响应
logger.error("图生视频任务提交失败响应中缺少task_id或id: {}", responseBody);
String errorMsg = "未知错误";
if (responseBody.get("message") != null) {
errorMsg = responseBody.get("message").toString();
}
throw new RuntimeException("任务提交失败: " + errorMsg);
}
} catch (com.fasterxml.jackson.core.JsonParseException e) {
logger.error("解析API响应为JSON失败响应内容可能是HTML或其他格式", e);
@@ -448,17 +461,24 @@ public class RealAIService {
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
// 参考Comfly项目响应格式{"task_id": "xxx"}
// 支持两种响应格式{"task_id": "xxx"} 或 {"id": "xxx"}
String taskId = null;
if (responseBody.containsKey("task_id")) {
logger.info("文生视频任务提交成功task_id: {}", responseBody.get("task_id"));
taskId = String.valueOf(responseBody.get("task_id"));
} else if (responseBody.containsKey("id")) {
taskId = String.valueOf(responseBody.get("id"));
}
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
logger.info("文生视频任务提交成功task_id: {}", taskId);
// 转换为统一的响应格式
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", responseBody);
result.put("task_id", responseBody.get("task_id"));
result.put("task_id", taskId);
return result;
} else {
logger.error("文生视频任务提交失败响应中缺少task_id: {}", responseBody);
logger.error("文生视频任务提交失败响应中缺少task_id或id: {}", responseBody);
throw new RuntimeException("任务提交失败: 响应格式不正确");
}
} catch (com.fasterxml.jackson.core.JsonParseException e) {
@@ -1146,11 +1166,8 @@ public class RealAIService {
}
}
String apiUrl = settings.getPromptOptimizationApiUrl();
if (apiUrl == null || apiUrl.isEmpty()) {
apiUrl = getEffectiveApiBaseUrl(); // 使用默认API端点
}
// 构建请求URL
// 提示词优化使用统一的 API base URL
String apiUrl = getEffectiveApiBaseUrl();
String url = apiUrl + "/v1/chat/completions";
String optimizationModel = settings.getPromptOptimizationModel();
@@ -1290,10 +1307,8 @@ public class RealAIService {
String systemPrompt = getOptimizationPrompt(type);
String apiUrl = settings.getPromptOptimizationApiUrl();
if (apiUrl == null || apiUrl.isEmpty()) {
apiUrl = getEffectiveApiBaseUrl();
}
// 提示词优化使用统一的 API base URL
String apiUrl = getEffectiveApiBaseUrl();
String url = apiUrl + "/v1/chat/completions";
// 使用支持视觉的模型

View File

@@ -1,9 +1,11 @@
package com.example.demo.service;
import com.example.demo.config.DynamicApiConfig;
import com.example.demo.model.SystemSettings;
import com.example.demo.repository.SystemSettingsRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +17,9 @@ public class SystemSettingsService {
private static final Logger logger = LoggerFactory.getLogger(SystemSettingsService.class);
private final SystemSettingsRepository repository;
@Autowired
private DynamicApiConfig dynamicApiConfig;
public SystemSettingsService(SystemSettingsRepository repository) {
this.repository = repository;
@@ -22,14 +27,13 @@ public class SystemSettingsService {
/**
* 获取唯一的系统设置;若不存在则初始化默认值。
* 注意:套餐价格已移至 membership_levels 表管理
*/
@Transactional
public SystemSettings getOrCreate() {
List<SystemSettings> all = repository.findAll();
if (all.isEmpty()) {
SystemSettings defaults = new SystemSettings();
defaults.setStandardPriceCny(9); // 默认标准版 9 元
defaults.setProPriceCny(29); // 默认专业版 29 元
defaults.setPointsPerGeneration(1); // 默认每次消耗 1 点
defaults.setSiteName("AIGC Demo");
defaults.setSiteSubtitle("现代化的Spring Boot应用演示");
@@ -39,11 +43,10 @@ public class SystemSettingsService {
defaults.setEnablePaypal(true);
defaults.setContactEmail("support@example.com");
defaults.setPromptOptimizationModel("gpt-5.1-thinking");
defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat");
defaults.setStoryboardSystemPrompt("");
SystemSettings saved = repository.save(defaults);
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
logger.info("Initialized default SystemSettings: points={}",
saved.getPointsPerGeneration());
return saved;
}
return all.get(0);
@@ -51,23 +54,26 @@ public class SystemSettingsService {
@Transactional
public SystemSettings update(SystemSettings updated) {
logger.info("要更新的值: standardPriceCny={}, proPriceCny={}",
updated.getStandardPriceCny(), updated.getProPriceCny());
// 使用原生更新方法强制更新数据库
Long id = updated.getId();
if (id == null) {
id = 1L; // 默认ID
// 如果 API Key 为空,保留原来的值
if (updated.getAiApiKey() == null || updated.getAiApiKey().trim().isEmpty()) {
SystemSettings existing = getOrCreate();
updated.setAiApiKey(existing.getAiApiKey());
}
int rows1 = repository.updateStandardPrice(id, updated.getStandardPriceCny());
int rows2 = repository.updateProPrice(id, updated.getProPriceCny());
// 保存更新
SystemSettings saved = repository.save(updated);
logger.info("系统设置保存成功: id={}", saved.getId());
logger.info("更新标准价格影响行数: {}, 更新专业价格影响行数: {}", rows1, rows2);
// 刷新运行时配置
if (saved.getAiApiKey() != null && !saved.getAiApiKey().isEmpty()) {
dynamicApiConfig.updateApiKey(saved.getAiApiKey());
dynamicApiConfig.updateImageApiKey(saved.getAiApiKey());
}
if (saved.getAiApiBaseUrl() != null && !saved.getAiApiBaseUrl().isEmpty()) {
dynamicApiConfig.updateApiBaseUrl(saved.getAiApiBaseUrl());
dynamicApiConfig.updateImageApiBaseUrl(saved.getAiApiBaseUrl());
}
// 重新查询返回最新数据
SystemSettings saved = getOrCreate();
logger.info("系统设置保存成功: id={}, standardPriceCny={}", saved.getId(), saved.getStandardPriceCny());
return saved;
}
}

View File

@@ -33,6 +33,9 @@ public class TaskCleanupService {
@Autowired
private ImageToVideoTaskRepository imageToVideoTaskRepository;
@Autowired
private StoryboardVideoTaskRepository storyboardVideoTaskRepository;
@Autowired
private CompletedTaskArchiveRepository completedTaskArchiveRepository;
@@ -63,10 +66,13 @@ public class TaskCleanupService {
// 2. 清理图生视频任务
Map<String, Object> imageCleanupResult = cleanupImageToVideoTasks();
// 3. 清理任务队列
// 3. 清理分镜视频任务
Map<String, Object> storyboardCleanupResult = cleanupStoryboardVideoTasks();
// 4. 清理任务队列
Map<String, Object> queueCleanupResult = cleanupTaskQueue();
// 4. 清理过期的归档记录
// 5. 清理过期的归档记录
Map<String, Object> archiveCleanupResult = cleanupExpiredArchives();
// 汇总结果
@@ -74,6 +80,7 @@ public class TaskCleanupService {
result.put("message", "任务清理完成");
result.put("textToVideo", textCleanupResult);
result.put("imageToVideo", imageCleanupResult);
result.put("storyboardVideo", storyboardCleanupResult);
result.put("taskQueue", queueCleanupResult);
result.put("archiveCleanup", archiveCleanupResult);
@@ -126,10 +133,8 @@ public class TaskCleanupService {
}
}
// 删除原始任务记录
if (!completedTasks.isEmpty()) {
textToVideoTaskRepository.deleteAll(completedTasks);
}
// 删除失败任务记录,保留成功任务的创作历史
// 成功任务保留在原表中,用户可以查看历史记录
if (!failedTasks.isEmpty()) {
textToVideoTaskRepository.deleteAll(failedTasks);
}
@@ -138,7 +143,7 @@ public class TaskCleanupService {
result.put("cleaned", cleanedCount);
result.put("total", archivedCount + cleanedCount);
logger.info("文生视频任务理完成: 归档{}个, 清理{}个", archivedCount, cleanedCount);
logger.info("文生视频任务理完成: 归档{}个成功任务(保留原记录), 清理{}个失败任务", archivedCount, cleanedCount);
} catch (Exception e) {
logger.error("清理文生视频任务失败", e);
@@ -186,10 +191,7 @@ public class TaskCleanupService {
}
}
// 删除原始任务记录
if (!completedTasks.isEmpty()) {
imageToVideoTaskRepository.deleteAll(completedTasks);
}
// 删除失败任务记录,保留成功任务的创作历史
if (!failedTasks.isEmpty()) {
imageToVideoTaskRepository.deleteAll(failedTasks);
}
@@ -198,7 +200,7 @@ public class TaskCleanupService {
result.put("cleaned", cleanedCount);
result.put("total", archivedCount + cleanedCount);
logger.info("图生视频任务理完成: 归档{}个, 清理{}个", archivedCount, cleanedCount);
logger.info("图生视频任务理完成: 归档{}个成功任务(保留原记录), 清理{}个失败任务", archivedCount, cleanedCount);
} catch (Exception e) {
logger.error("清理图生视频任务失败", e);
@@ -208,6 +210,47 @@ public class TaskCleanupService {
return result;
}
/**
* 清理分镜视频任务
* 只删除失败任务,保留成功任务的创作历史
*/
private Map<String, Object> cleanupStoryboardVideoTasks() {
Map<String, Object> result = new HashMap<>();
try {
// 查找失败的任务
List<StoryboardVideoTask> failedTasks = storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.FAILED);
int cleanedCount = 0;
// 记录失败任务到清理日志
for (StoryboardVideoTask task : failedTasks) {
try {
FailedTaskCleanupLog log = FailedTaskCleanupLog.fromStoryboardVideoTask(task);
failedTaskCleanupLogRepository.save(log);
cleanedCount++;
} catch (Exception e) {
logger.error("记录失败分镜视频任务日志失败: {}", task.getTaskId(), e);
}
}
// 只删除失败任务记录,保留成功任务的创作历史
if (!failedTasks.isEmpty()) {
storyboardVideoTaskRepository.deleteAll(failedTasks);
}
result.put("cleaned", cleanedCount);
logger.info("分镜视频任务处理完成: 清理{}个失败任务(成功任务保留)", cleanedCount);
} catch (Exception e) {
logger.error("清理分镜视频任务失败", e);
result.put("error", e.getMessage());
}
return result;
}
/**
* 清理任务队列
*/

View File

@@ -1492,7 +1492,15 @@ public class TaskQueueService {
}
if (taskQueue.getRealTaskId() == null) {
logger.warn("任务 {} 的 realTaskId 为空标记为失败并返还积分", taskId);
// 增加时间窗口保护只有任务创建超过5分钟后 realTaskId 为空标记为失败
// 避免任务刚创建还在等待外部API响应时就被错误地标记为失败
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
if (taskQueue.getCreatedAt() != null && taskQueue.getCreatedAt().isAfter(fiveMinutesAgo)) {
logger.debug("任务 {} 的 realTaskId 为空但创建时间未超过5分钟跳过本次检查", taskId);
return;
}
logger.warn("任务 {} 的 realTaskId 为空且已超过5分钟标记为失败并返还积分", taskId);
// 标记任务为失败
String errorMessage = "任务提交失败未能成功提交到外部API请检查网络或稍后重试";
@@ -1544,6 +1552,15 @@ public class TaskQueueService {
if (taskData != null) {
logger.info("任务状态响应: taskId={}, taskData={}", taskQueue.getTaskId(), taskData);
// 处理嵌套格式:{code=success, data={status=processing, url=...}}
if (taskData.containsKey("code") && taskData.containsKey("data")) {
Object innerData = taskData.get("data");
if (innerData instanceof Map) {
taskData = (Map<?, ?>) innerData;
logger.info("检测到嵌套格式提取内层data: {}", taskData);
}
}
String status = (String) taskData.get("status");
// 支持大小写不敏感的状态检查
if (status != null) {
@@ -1578,6 +1595,17 @@ public class TaskQueueService {
}
}
// 格式3: 直接在根级别的 url 字段新API格式
if (resultUrl == null) {
Object urlField = taskData.get("url");
if (urlField != null) {
String urlStr = urlField.toString();
if (!urlStr.trim().isEmpty() && !urlStr.equals("null")) {
resultUrl = urlStr;
}
}
}
logger.info("解析到的结果URL: taskId={}, resultUrl={}", taskQueue.getTaskId(),
resultUrl != null ? (resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl) : "null");

View File

@@ -10,13 +10,13 @@ import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.TaskStatus;
import com.example.demo.repository.TaskStatusRepository;
import com.example.demo.config.DynamicApiConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -60,11 +60,8 @@ public class TaskStatusPollingService {
@Autowired
private UserErrorLogService userErrorLogService;
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
private String apiKey;
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
private String apiBaseUrl;
@Autowired
private DynamicApiConfig dynamicApiConfig;
/**
* 系统启动时恢复处理中的任务
@@ -127,9 +124,9 @@ public class TaskStatusPollingService {
// 有外部任务ID查询外部API状态
try {
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
HttpResponse<String> response = Unirest.get(url)
.header("Authorization", "Bearer " + apiKey)
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
.asString();
if (response.getStatus() == 200) {
@@ -301,9 +298,9 @@ public class TaskStatusPollingService {
try {
// 使用正确的 API 端点GET /v2/videos/generations/{task_id}
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
HttpResponse<String> response = Unirest.get(url)
.header("Authorization", "Bearer " + apiKey)
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
.asString();
if (response.getStatus() == 200) {
@@ -565,159 +562,163 @@ public class TaskStatusPollingService {
return false;
}
// 只有状态变化时才更新(触发器条件)
if (taskStatus.getStatus() == status) {
logger.debug("任务状态未变化,跳过更新: taskId={}, status={}", taskId, status);
return true;
}
taskStatus.setStatus(status);
taskStatus.setUpdatedAt(LocalDateTime.now());
if (resultUrl != null) {
taskStatus.setResultUrl(resultUrl);
}
if (errorMessage != null) {
taskStatus.setErrorMessage(errorMessage);
}
if (status == TaskStatus.Status.COMPLETED) {
taskStatus.setProgress(100);
taskStatus.setCompletedAt(LocalDateTime.now());
}
taskStatusRepository.save(taskStatus);
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
// 手动同步业务表状态(避免依赖数据库触发器)
if (taskId != null) {
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
// 同步分镜视频任务
try {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
convertToStoryboardTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.setStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
storyboardVideoTaskRepository.save(task);
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
}
});
} catch (Exception e) {
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
} else if (taskId.startsWith("img2vid_")) {
// 同步图生视频任务
try {
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.ImageToVideoTask.TaskStatus newTaskStatus =
convertToImageToVideoTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.updateStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
imageToVideoTaskRepository.save(task);
logger.info("已同步 ImageToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
}
});
} catch (Exception e) {
logger.warn("同步 ImageToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
} else if (taskId.startsWith("txt2vid_")) {
// 同步文生视频任务
try {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.TextToVideoTask.TaskStatus newTaskStatus =
convertToTextToVideoTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.updateStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
textToVideoTaskRepository.save(task);
logger.info("已同步 TextToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
}
});
} catch (Exception e) {
logger.warn("同步 TextToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
// 只有状态变化时才更新task_status表
boolean statusChanged = taskStatus.getStatus() != status;
if (statusChanged) {
taskStatus.setStatus(status);
taskStatus.setUpdatedAt(LocalDateTime.now());
if (resultUrl != null) {
taskStatus.setResultUrl(resultUrl);
}
// 如果是失败状态,记录错误日志
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
try {
String username = null;
String taskType = "UNKNOWN";
if (taskId.startsWith("img2vid_")) {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("txt2vid_")) {
taskType = "TEXT_TO_VIDEO";
username = textToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
if (errorMessage != null) {
taskStatus.setErrorMessage(errorMessage);
}
if (status == TaskStatus.Status.COMPLETED) {
taskStatus.setProgress(100);
taskStatus.setCompletedAt(LocalDateTime.now());
}
taskStatusRepository.save(taskStatus);
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
}
// 无论状态是否变化,都要确保业务表同步(防止业务表状态不一致)
syncBusinessTableStatus(taskId, status, resultUrl, errorMessage);
return true;
}
/**
* 同步业务表状态
*/
private void syncBusinessTableStatus(String taskId, TaskStatus.Status status, String resultUrl, String errorMessage) {
if (taskId == null) return;
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
// 同步分镜视频任务
try {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
convertToStoryboardTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.setStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
storyboardVideoTaskRepository.save(task);
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
}
if (username != null) {
userErrorLogService.logErrorAsync(
username,
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
errorMessage,
"TaskStatusPollingService.updateTaskStatusWithCascade",
taskId,
taskType
);
logger.info("已记录错误日志: taskId={}, username={}, errorMessage={}", taskId, username, errorMessage);
} else {
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
});
} catch (Exception e) {
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
} else if (taskId.startsWith("img2vid_")) {
// 同步图生视频任务
try {
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.ImageToVideoTask.TaskStatus newTaskStatus =
convertToImageToVideoTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.updateStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
imageToVideoTaskRepository.save(task);
logger.info("已同步 ImageToVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
}
} catch (Exception logException) {
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
}
});
} catch (Exception e) {
logger.warn("同步 ImageToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
} else if (taskId.startsWith("txt2vid_")) {
// 同步文生视频任务
try {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
com.example.demo.model.TextToVideoTask.TaskStatus newTaskStatus =
convertToTextToVideoTaskStatus(status);
if (task.getStatus() != newTaskStatus) {
task.updateStatus(newTaskStatus);
task.setUpdatedAt(LocalDateTime.now());
if (errorMessage != null) {
task.setErrorMessage(errorMessage);
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
task.setCompletedAt(LocalDateTime.now());
} else if (status == TaskStatus.Status.FAILED) {
task.setCompletedAt(LocalDateTime.now());
}
textToVideoTaskRepository.save(task);
logger.info("已同步 TextToVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
}
});
} catch (Exception e) {
logger.warn("同步 TextToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
}
return true;
// 如果是失败状态,记录错误日志
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
try {
String username = null;
String taskType = "UNKNOWN";
if (taskId.startsWith("img2vid_")) {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("txt2vid_")) {
taskType = "TEXT_TO_VIDEO";
username = textToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
}
if (username != null) {
userErrorLogService.logErrorAsync(
username,
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
errorMessage,
"TaskStatusPollingService.updateTaskStatusWithCascade",
taskId,
taskType
);
} else {
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
}
} catch (Exception logException) {
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
}
}
}
/**

View File

@@ -534,8 +534,9 @@ public class UserService {
for (com.example.demo.model.Order order : paidOrders) {
// 检查是否已经在支付记录中处理过(避免重复)
// 通过Payment关联的Order ID来匹配而不是通过orderId字符串
boolean alreadyProcessed = successfulPayments.stream()
.anyMatch(p -> p.getOrderId() != null && p.getOrderId().equals(order.getOrderNumber()));
.anyMatch(p -> p.getOrder() != null && p.getOrder().getId().equals(order.getId()));
if (!alreadyProcessed) {
// 从订单描述或订单项中提取积分数量
@@ -613,30 +614,55 @@ public class UserService {
if (matcher.find()) {
return Integer.valueOf(matcher.group(1));
}
// 如果是会员订阅,根据订单金额计算积分
// 如果是会员订阅,从数据库读取积分配置
if (item.getProductName().contains("标准版") || item.getProductName().contains("专业版")) {
// 标准版200积分/月专业版1000积分/月
if (item.getProductName().contains("标准版")) {
return 200;
} else if (item.getProductName().contains("专业版")) {
return 1000;
// 从membership_levels表读取积分配置禁止硬编码
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
.orElse(null);
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
.orElse(null);
if (item.getProductName().contains("标准版") && standardLevel != null) {
return standardLevel.getPointsBonus();
} else if (item.getProductName().contains("专业版") && proLevel != null) {
return proLevel.getPointsBonus();
}
}
}
}
}
// 方法3根据订单类型和金额估算
// 方法3根据订单类型和金额计算积分(从数据库读取配置)
if (order.getOrderType() != null) {
if (order.getOrderType() == com.example.demo.model.OrderType.SUBSCRIPTION) {
// 订阅订单:根据金额算积分
// 标准版:$59 = 200积分专业版$259 = 1000积分
// 订阅订单:根据金额算积分(从数据库读取,禁止硬编码)
if (order.getTotalAmount() != null) {
double amount = order.getTotalAmount().doubleValue();
if (amount >= 250) {
return 1000; // 专业版
} else if (amount >= 50) {
return 200; // 标准版
// 使用OrderService的逻辑从数据库读取积分配置
try {
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
int standardPrice = standardLevel.getPrice().intValue();
int standardPoints = standardLevel.getPointsBonus();
int proPrice = proLevel.getPrice().intValue();
int proPoints = proLevel.getPointsBonus();
int amountInt = order.getTotalAmount().intValue();
// 判断套餐类型允许10%的价格浮动范围)
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
return proPoints; // 专业版积分
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
return standardPoints; // 标准版积分
} else if (amountInt >= proPrice) {
return proPoints;
} else if (amountInt >= standardPrice) {
return standardPoints;
}
} catch (Exception e) {
logger.error("从数据库读取会员等级配置失败: {}", e.getMessage(), e);
}
}
}

View File

@@ -1,9 +1,9 @@
#Updated by API Key Management
#Thu Dec 18 13:00:49 CST 2025
#Mon Dec 22 13:46:28 CST 2025
ai.api.base-url=https\://ai.comfly.chat
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
ai.api.key=sk-I9Z4qYL0Me7MtcQzDcBa9e7bDa23442a88768a4e0c17C0E4
ai.image.api.base-url=https\://ai.comfly.chat
ai.image.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
ai.image.api.key=sk-I9Z4qYL0Me7MtcQzDcBa9e7bDa23442a88768a4e0c17C0E4
alipay.app-id=9021000157616562
alipay.charset=UTF-8
alipay.domain=https\://vionow.com

View File

@@ -0,0 +1,19 @@
-- 此迁移文件已废弃,佣金相关字段的删除已移至 V13__Remove_Commission_Fields.sql
-- 保留此文件以避免迁移版本冲突

View File

@@ -0,0 +1,26 @@
-- 删除 users 表中的佣金相关字段
-- 注意MySQL 5.7及以下版本不支持 DROP COLUMN IF EXISTS
-- 如果字段不存在,执行会报错,但可以忽略
-- 删除 commission 字段(如果存在)
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'commission');
SET @sqlstmt := IF(@exist > 0, 'ALTER TABLE users DROP COLUMN commission', 'SELECT "commission字段不存在跳过删除"');
PREPARE stmt FROM @sqlstmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 删除 frozen_commission 字段(如果存在)
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'frozen_commission');
SET @sqlstmt := IF(@exist > 0, 'ALTER TABLE users DROP COLUMN frozen_commission', 'SELECT "frozen_commission字段不存在跳过删除"');
PREPARE stmt FROM @sqlstmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -102,53 +102,38 @@ CREATE TABLE IF NOT EXISTS user_memberships (
UNIQUE KEY unique_active_membership (user_id, status)
);
-- 视频生成任务表
CREATE TABLE IF NOT EXISTS video_tasks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(100) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
task_type VARCHAR(50) NOT NULL, -- TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO
title VARCHAR(200) NOT NULL,
description TEXT,
input_text TEXT,
input_image_url VARCHAR(500),
output_video_url VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, COMPLETED, FAILED
progress INT NOT NULL DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 用户作品表
CREATE TABLE IF NOT EXISTS user_works (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
work_type VARCHAR(50) NOT NULL, -- VIDEO, IMAGE, STORYBOARD
cover_image VARCHAR(500),
video_url VARCHAR(500),
tags VARCHAR(500),
is_public BOOLEAN NOT NULL DEFAULT TRUE,
view_count INT NOT NULL DEFAULT 0,
like_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
username VARCHAR(100) NOT NULL COMMENT '用户名',
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
work_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO', 'STORYBOARD_VIDEO') NOT NULL COMMENT '作品类型',
title VARCHAR(200) COMMENT '作品标题',
description TEXT COMMENT '作品描述',
prompt TEXT COMMENT '生成提示词',
result_url VARCHAR(500) COMMENT '结果视频URL',
thumbnail_url VARCHAR(500) COMMENT '缩略图URL',
duration VARCHAR(10) COMMENT '视频时长',
aspect_ratio VARCHAR(10) COMMENT '宽高比',
quality VARCHAR(20) COMMENT '画质',
file_size VARCHAR(20) COMMENT '文件大小',
points_cost INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
status ENUM('PROCESSING', 'COMPLETED', 'FAILED', 'DELETED') NOT NULL DEFAULT 'PROCESSING' COMMENT '作品状态',
is_public BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否公开',
view_count INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞次数',
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
tags VARCHAR(500) COMMENT '标签',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at TIMESTAMP NULL COMMENT '完成时间',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_username_status (username, status),
INDEX idx_task_id (task_id),
INDEX idx_work_type (work_type),
INDEX idx_is_public_status (is_public, status),
INDEX idx_created_at (created_at)
);
-- 系统配置表
CREATE TABLE IF NOT EXISTS system_configs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value TEXT,
description VARCHAR(500),
config_type VARCHAR(50) NOT NULL DEFAULT 'STRING', -- STRING, NUMBER, BOOLEAN, JSON
is_public BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

View File

@@ -14,6 +14,7 @@
</div>
<div class="card-body">
<form th:action="@{/settings}" th:object="${settings}" method="post">
<input type="hidden" th:field="*{id}">
<h5 class="mb-3">基础信息</h5>
<div class="mb-3">
<label class="form-label">站点名称</label>
@@ -69,6 +70,26 @@
<label class="form-label">每次生成消耗资源点</label>
<input type="number" class="form-control" th:field="*{pointsPerGeneration}" min="0" required>
</div>
<h5 class="mb-3">API 配置</h5>
<div class="mb-3">
<label class="form-label">AI API Base URL</label>
<input type="text" class="form-control" th:field="*{aiApiBaseUrl}" placeholder="https://ai.comfly.chat">
<div class="form-text">视频生成、图片生成、提示词优化统一使用此地址</div>
</div>
<div class="mb-3">
<label class="form-label">AI API Key</label>
<input type="password" class="form-control" th:field="*{aiApiKey}" placeholder="留空则不修改">
<div class="form-text">API 密钥,留空表示不修改</div>
</div>
<div class="mb-3">
<label class="form-label">提示词优化模型</label>
<input type="text" class="form-control" th:field="*{promptOptimizationModel}" placeholder="gpt-5.1-thinking">
</div>
<div class="mb-3">
<label class="form-label">分镜图系统引导词</label>
<textarea class="form-control" th:field="*{storyboardSystemPrompt}" rows="3" placeholder="可选的系统引导词"></textarea>
</div>
<div class="text-end">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i>保存

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Some files were not shown because too many files have changed in this diff Show More