286 Commits
Author SHA1 Message Date
root e06a739cdd 1 2026-08-14 16:36:11 +08:00
root fe57416493 1 2026-08-14 16:19:07 +08:00
root c47fac821c 1 2026-08-14 16:16:30 +08:00
root a4417e8976 1 2026-08-14 16:11:07 +08:00
root 3194356859 1 2026-08-14 16:06:07 +08:00
root 978475f0cd 1 2026-08-14 15:59:47 +08:00
sjy 5be40ead52 对公转账信息 2026-08-14 15:55:41 +08:00
sjy 7fc58e62ce 修复对公转账 2026-08-14 15:54:21 +08:00
root 5eed7686c9 1 2026-08-14 15:52:52 +08:00
root 4a2355a902 1 2026-08-14 15:47:51 +08:00
root 7ab5c9a5c9 1 2026-08-14 15:42:12 +08:00
root 556ea4dbd2 1 2026-08-14 15:39:22 +08:00
root 049bbb0d24 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:36:22 +08:00
root bedf39d4f7 1 2026-08-14 15:35:54 +08:00
sjy abb479c469 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:34:53 +08:00
GinHa 04fbcc8d8b Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:33:00 +08:00
GinHa c2971f828f 团队积分V2 2026-08-14 15:32:55 +08:00
root 45576e7d97 1 2026-08-14 15:30:29 +08:00
GinHa 1fb75676fc Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:26:21 +08:00
GinHa fac54b5667 团队积分V1 2026-08-14 15:23:46 +08:00
root 1565eeebc5 1 2026-08-14 15:22:13 +08:00
root a754a9e004 1 2026-08-14 15:13:21 +08:00
root 289e458e7c 1 2026-08-14 14:49:35 +08:00
root 3f18b28227 1 2026-08-14 14:46:50 +08:00
root 7c3ef21762 增加银行流水和定时任务程序 2026-08-14 14:35:25 +08:00
root 148b89c3ca 1 2026-08-14 14:03:36 +08:00
root ae92f8b78c 1 2026-08-14 13:56:09 +08:00
sjy 4db879199c 保留修改 2026-08-14 13:29:00 +08:00
root 59116b93d8 123 2026-08-14 13:20:03 +08:00
root 07d476e9ac 1122 2026-08-14 12:51:24 +08:00
root 5afcfe1513 1 2026-08-14 12:16:10 +08:00
sjy 516106c045 对公转账 2026-08-13 17:59:08 +08:00
sjy 5e504eddaf Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 17:57:38 +08:00
sjy da3f2cac49 冲突 2026-08-13 17:56:58 +08:00
root a31b58316e 1 2026-08-13 16:39:10 +08:00
root 299dc549cd 1 2026-08-13 16:38:15 +08:00
root c091960b87 1 2026-08-13 16:21:40 +08:00
root ee380cfcc1 1 2026-08-13 16:20:55 +08:00
root 9b13968762 1 2026-08-13 16:10:43 +08:00
root 7dcedf974d 1、后台菜单增加定时任务和银行交易
2、增加后台配置银行账户和请求网址
2026-08-13 15:12:16 +08:00
root 33a5cc4f94 1 2026-08-13 11:54:26 +08:00
root b4b629a497 1 2026-08-13 11:50:50 +08:00
root 2aa737edaa 1 2026-08-13 11:34:25 +08:00
root fe855e0fa2 1 2026-08-13 11:31:27 +08:00
root 437be9dac0 “单设备登录(同端互斥)”功能和后台全局用户配置 2026-08-13 10:29:42 +08:00
sjy 780769ddee 冲突解决 2026-08-13 10:02:22 +08:00
sjy b917f19db5 会员充值修改 2026-08-13 09:56:11 +08:00
root 4754151cbb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 09:38:36 +08:00
root 58e061c9e7 前台登陆逻辑修改,请帮我实现“单设备登录(同端互斥)”功能 2026-08-13 09:37:39 +08:00
GinHa 6ed82b2e04 积分消耗明细改版V2 build app/admin 2026-08-12 11:53:22 +08:00
GinHa eb7c60684e 积分消耗明细改版V1 2026-08-12 11:49:53 +08:00
GinHa e3719eae09 会员积分改版V10 2026-08-11 15:38:53 +08:00
GinHa 45b9409fe8 会员积分改版V9 2026-08-11 14:27:21 +08:00
GinHa 81ef619ad3 会员积分改版V8 追加订单自动过期celery定时任务 2026-08-11 14:11:59 +08:00
GinHa a49ed802f4 会员积分改版V7 2026-08-11 13:23:22 +08:00
GinHa 809ff47120 会员积分改版V6 修复订单异步时区校验BUG 2026-08-11 11:57:55 +08:00
GinHa 890c1ea83e 会员积分改版V5 版本迁移合并 2026-08-11 10:30:43 +08:00
GinHa ba32d6f55e 会员积分改版V4 | 修复历史素材云兼容BUG | build app/admin 2026-08-11 10:27:06 +08:00
GinHa 392b9f4117 merge main 2026-08-11 10:16:38 +08:00
GinHa a1b82635c7 会员积分改版V3 2026-08-11 10:02:08 +08:00
GinHa 41b7cd395b 会员积分改版V2 2026-08-11 09:43:12 +08:00
GinHa b9fd07f293 会员积分改版V1 2026-08-11 09:24:18 +08:00
root f38f9b09ab 1 2026-08-11 09:24:16 +08:00
root 5bcc7888c2 1 2026-08-10 19:05:07 +08:00
root 0e47719d5c 1 2026-08-10 18:59:31 +08:00
root decabb5c42 1 2026-08-10 18:58:31 +08:00
root 80d0fd26f1 1 2026-08-10 18:47:34 +08:00
root 487c15779d 1 2026-08-10 18:45:26 +08:00
root 190cf54f0f 1 2026-08-10 18:39:29 +08:00
root baf97b5fc5 1 2026-08-10 18:31:45 +08:00
root 7237e8715f 1 2026-08-10 17:59:24 +08:00
root c1092f0deb 1 2026-08-10 17:38:47 +08:00
root e83f963534 1 2026-08-10 17:33:50 +08:00
root 9f7dc731b4 1 2026-08-10 17:23:59 +08:00
root 2bbcb75328 1 2026-08-10 16:53:13 +08:00
root 86c65f9ec8 1 2026-08-10 16:42:47 +08:00
root d1e8eb7316 1 2026-08-10 16:29:26 +08:00
root e364c46ce4 1 2026-08-10 16:11:04 +08:00
root a699c9bab8 1 2026-08-10 16:04:15 +08:00
root 7777454b16 1 2026-08-10 16:00:44 +08:00
root e8e590948c 1 2026-08-10 15:47:07 +08:00
root 6d30347cdb 1、增加订单开发票功能和发票抬头添加功能
2、一个订单只能在一个开票里,不允许多开
2026-08-10 15:39:00 +08:00
sjy e77d78db1e “同步sun分支(开票管理)” 2026-08-10 10:47:21 +08:00
sjy e2a2b6a16f Merge branch 'sun' 2026-08-10 10:42:09 +08:00
sjy e4fe42991d 完成sun分支功能 2026-08-10 10:39:01 +08:00
root ab16456579 1 2026-08-07 14:03:58 +08:00
root 40689de151 1 2026-08-07 13:59:43 +08:00
root c691416434 1 2026-08-07 13:51:46 +08:00
root d0de2f321a 1 2026-08-07 13:50:04 +08:00
root ec4d507890 1 2026-08-07 13:25:49 +08:00
root bffa786d47 1 2026-08-07 12:11:42 +08:00
root f530870370 1 2026-08-07 12:06:56 +08:00
root afac8dac09 1 2026-08-07 12:04:06 +08:00
root a241329ea6 1 2026-08-07 11:52:30 +08:00
root 9f89e186d6 1 2026-08-07 11:49:34 +08:00
root e24465ac39 1 2026-08-07 11:45:10 +08:00
root 1498a11a9b 1 2026-08-07 11:36:02 +08:00
root 9538f6157d 1、增加消息推送的前台横幅显示
2、增加apikey的整体配额增加和修改记录显示
2026-08-07 11:35:41 +08:00
root f5d7cfadb4 1、图片生成同步接口超时风险
2、图片返回的 URL 是本地路径
3、视频生成中媒体文件重复下载
4、幂等性检查无数据库唯一约束
5、幂等键冲突返回 409 改为返回已有任务信息
6、虚拟素材库配额校验 TOCTOU
7、项目级联删除与独立素材删除任务并发冲突
2026-08-06 18:57:14 +08:00
root 44b865e1ca 1 2026-08-06 17:53:06 +08:00
root 61c3761745 1 2026-08-06 17:47:59 +08:00
root 1029555fdc 1 2026-08-06 17:35:13 +08:00
root 166a020bed 1 2026-08-06 17:07:33 +08:00
root 820f601b32 1 2026-08-06 16:50:34 +08:00
root 70f2261d68 1 2026-08-06 16:44:28 +08:00
root 0fb20777f7 1 2026-08-06 14:48:01 +08:00
root 990cdd3ff5 1 2026-08-06 14:23:12 +08:00
root 395d979208 1 2026-08-06 14:18:45 +08:00
root dc3c4f7e4d 1 2026-08-06 14:12:46 +08:00
root 4766bab453 1 2026-08-06 13:55:51 +08:00
root 1e54e3f7f4 1 2026-08-06 13:39:46 +08:00
root 277231339c 1 2026-08-06 13:38:20 +08:00
root d6e6c77278 1 2026-08-06 13:14:37 +08:00
root 0c511f3451 1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
2026-08-06 13:13:28 +08:00
root a55d4d649c 1、修复后台首页数据概览的团队积分消耗排行情况
2、修复后台交易流水数据
2026-08-05 11:59:27 +08:00
root 517bcc3531 1 2026-08-05 10:24:56 +08:00
root ceb930bd5a 1、修复后台首页数据概览的团队积分消耗排行情况
2、修复前台团队的退款统计问题
2026-08-05 10:17:12 +08:00
root 5de637ab6b 1 2026-08-04 18:41:50 +08:00
root 6192aba4b2 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-04 18:05:03 +08:00
root ebd22d62a1 1、修改后台首页团队消耗没有算回退的数据
2、修改前台团队积分不可以下载回退积分的数据
2026-08-04 18:05:02 +08:00
sjy fe24e51b97 积分充值修改 2026-08-04 17:33:52 +08:00
root 97edb17062 修改行业提示词带素材库素材未生效的问题 2026-08-03 17:59:45 +08:00
sjy 5d2c63ced1 ai创建替换缩略图 2026-08-03 13:47:39 +08:00
root d2c85b03df 1 2026-07-31 09:51:11 +08:00
root 33d5bfd997 1 2026-07-31 09:49:41 +08:00
sjy 15deed1cf4 修改缩略图 2026-07-28 17:33:48 +08:00
root 7077d05d6a 1 2026-07-28 14:22:56 +08:00
root 570292266e 修复图片生成积分后台计算逻辑不对没有加上每张积分 2026-07-28 11:43:15 +08:00
root 9b3541264f 修复行业提示词优化最终不使用附件还是传到模型的错误 2026-07-28 10:34:48 +08:00
root 1f711fed7f 1 2026-07-27 20:52:47 +08:00
root 2d9abd5218 1 2026-07-27 20:52:39 +08:00
root cfcec301d2 1 2026-07-27 20:49:20 +08:00
root 3d144a4640 1 2026-07-27 20:45:43 +08:00
root 8d5c900411 1 2026-07-27 20:15:59 +08:00
root 8fbf239e01 Merge branch 'online' of https://gitee.com/wg123/video-gen 2026-07-27 19:28:27 +08:00
root 9114b45841 Merge branch 'online' 2026-07-27 19:18:38 +08:00
sjy 0576169773 爆款切换缩略图 2026-07-27 17:44:17 +08:00
sjy 1bdafa25cb 爆款拆镜修改 2026-07-27 17:22:59 +08:00
root 4eb0360134 1 2026-07-24 18:26:20 +08:00
sjy ac73de70b4 编辑提示词修改 2026-07-24 17:58:55 +08:00
sjy 0195175231 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 17:56:56 +08:00
sjy af1dd20789 本地修改 2026-07-24 17:56:42 +08:00
root da08fc43cb 1 2026-07-24 17:27:39 +08:00
GinHa acac39c73c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 16:12:48 +08:00
GinHa 8325ae1f71 视频提词API超时600秒 | 错误信息重新捕获 2026-07-24 16:12:41 +08:00
root 03ed98ce9b 1 2026-07-24 16:07:05 +08:00
root bc7eee681d 爆款和拆镜error消息正则中文 2026-07-24 15:56:15 +08:00
GinHa 663c4fa9db 拆镜 AI拆片 API 可重复执行 2026-07-24 14:30:28 +08:00
GinHa 4685b475af 修复冻结积分BUG | 拆镜状态异常BUG 2026-07-24 14:00:37 +08:00
GinHa 357657d7cd Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-24 12:06:22 +08:00
GinHa e55077a833 max_token save 10000 2026-07-24 12:06:13 +08:00
sjy 0e20981383 页面样式优化 2026-07-24 11:29:04 +08:00
GinHa a445198ecb 版本迁移兼容修复 2026-07-24 09:38:12 +08:00
GinHa 42f103a945 积分冻结释放 merge | build admin 2026-07-24 09:30:50 +08:00
GinHa 68e902b4a4 积分冻结释放 2026-07-24 09:18:05 +08:00
root 053dde7546 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-23 18:37:05 +08:00
root cc0f8e5fd8 1 2026-07-23 18:37:04 +08:00
sjy d5cb11dc60 行业提词优化,积分不足判断 2026-07-23 17:41:44 +08:00
sjy 7ea612d374 暂存修改 2026-07-23 13:07:33 +08:00
sjy 19d6580704 暂存修改 2026-07-23 13:06:11 +08:00
GinHa 920d884e92 celery 升级优化V2 | 日志调整 | 前端BUG修复 | build admin/app 2026-07-23 12:10:55 +08:00
GinHa 9463b94a3e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-23 12:07:34 +08:00
GinHa 2931e67226 celery 升级优化V2 | 日志调整 | 前端BUG修复 2026-07-23 12:07:23 +08:00
root 06c2159b15 1 2026-07-23 09:45:16 +08:00
root 7ecae0eb61 1 2026-07-23 09:15:25 +08:00
root 54b8e1a360 消息添加增加富文本 2026-07-23 09:11:04 +08:00
root 4fdbe0c946 1 2026-07-22 16:56:42 +08:00
root ed2e1715d1 1 2026-07-22 16:47:19 +08:00
GinHa 06bf5c4db7 celery 升级容灾移除冗余文件 2026-07-22 15:15:37 +08:00
sjy 73243ff497 状态修改 2026-07-22 15:13:22 +08:00
sjy 966d1ff5d3 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 15:12:50 +08:00
root cb682b4867 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 14:53:18 +08:00
root 6b934b07dc 1 2026-07-22 14:53:03 +08:00
sjy 6db0e2d3d5 合并修改 2026-07-22 14:52:08 +08:00
GinHa 69e7dec807 celery 容灾升级 2026-07-22 14:48:29 +08:00
sjy d0ccb04b07 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-22 14:34:47 +08:00
sjy eee8105d0e 合并修改 2026-07-22 14:34:07 +08:00
root 3f1c4063b0 修复。image_batch_service.py 中的 claim.task_snapshot 是 SimpleNamespace(ChatGenerationTask 的快照),传给 owner_include_media_references() 时因为类型不匹配报错 2026-07-22 14:33:19 +08:00
sjy eec5862465 爆款,拆镜流程修改 2026-07-22 14:26:57 +08:00
root 6b78d5830d 1 2026-07-22 14:04:54 +08:00
root d111977023 1 2026-07-22 13:44:37 +08:00
GinHa b41e1d6607 移除提词fps参数 2026-07-22 12:10:54 +08:00
root def054c147 1 2026-07-22 12:01:46 +08:00
root 1cb957d20d 1 2026-07-22 11:10:32 +08:00
root a63039c146 1 2026-07-22 10:02:54 +08:00
root d8c5258822 1 2026-07-22 10:01:21 +08:00
root 7afd5b1d5e 1 2026-07-22 09:59:27 +08:00
root 04fad1af9d 1 2026-07-22 09:44:08 +08:00
root b32ed4129c 1 2026-07-22 09:34:21 +08:00
root 9b13d01d72 增加预扣积分 2026-07-22 09:27:33 +08:00
root 4afda8992c 项目积分不足直接返回 2026-07-21 19:28:02 +08:00
root 6f37134c8c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-21 19:22:55 +08:00
root 7683ebb186 1 2026-07-21 19:22:50 +08:00
GinHa 0aa884304d 爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制 build app admin 2026-07-21 14:04:26 +08:00
GinHa 4f1ccd3f37 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-21 14:01:22 +08:00
GinHa 79c09151ba 爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制 2026-07-21 14:01:08 +08:00
root b374a541ea 1 2026-07-21 10:46:26 +08:00
root 56f380aa33 1 2026-07-21 10:43:58 +08:00
root 65fc424583 1 2026-07-21 10:40:54 +08:00
root 5538ddaa43 1 2026-07-21 10:34:23 +08:00
root ea06edec3e 1 2026-07-21 10:26:10 +08:00
root 6283bb8ceb 1 2026-07-21 10:25:30 +08:00
root fb3b95b687 1 2026-07-21 10:12:11 +08:00
root 3c6deed786 1 2026-07-21 10:10:16 +08:00
root 2a9c2d868c 1 2026-07-21 10:08:00 +08:00
root 76d03e7de1 后台概览页面修改 2026-07-21 10:03:32 +08:00
root 880e7ed515 1 2026-07-20 18:03:53 +08:00
sjy 40efcf55cf 项目生成模块选择器闪动修改 2026-07-20 15:41:00 +08:00
GinHa 1aaef451b8 项目图片生成提词拦截开放 2026-07-20 15:37:51 +08:00
GinHa 2403b38cae Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 14:11:52 +08:00
GinHa c0a58fdac3 项目/AI生成链路合并 版本迁移修复 2026-07-20 14:11:45 +08:00
root c025303ef8 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 14:01:28 +08:00
root a2e98ef3a3 1 2026-07-20 14:01:22 +08:00
GinHa 4af74acf25 项目/AI生成链路合并 admin build 2026-07-20 13:50:39 +08:00
GinHa d5a964a4d9 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-20 13:48:27 +08:00
GinHa fe5a59d725 项目/AI生成链路合并 2026-07-20 13:48:17 +08:00
root 53e01f6f74 1、修改联系我们后台接口程序和界面
2、修改提示词优化使用模型规则,改为按照后台设置排序倒序使用对应模型,如果失败使用下一个
2026-07-20 10:28:17 +08:00
root 46b59ff76c 1 2026-07-20 09:26:18 +08:00
root c145efd0b1 1 2026-07-17 19:36:22 +08:00
root 4104774174 1 2026-07-17 19:35:35 +08:00
root 4563b686e0 1 2026-07-17 19:30:47 +08:00
root e264214b77 1 2026-07-17 19:21:16 +08:00
root a7d8bd25bb 1 2026-07-17 19:10:39 +08:00
root bbc9bd95bd 1 2026-07-17 19:09:45 +08:00
sjy d202316708 merge main 2026-07-17 18:11:02 +08:00
sjy 519c1e58b9 样式优化 2026-07-17 18:02:39 +08:00
root bba29d79a2 1 2026-07-17 17:15:04 +08:00
GinHa 34ca98f9eb 修复LLM 爆款/拆镜返回中文提词 2026-07-17 16:04:41 +08:00
GinHa 1aa7ff02f4 修复LLM 爆款/拆镜重复资源BUG 2026-07-17 15:39:06 +08:00
GinHa 2d3d096fd7 修复LLM base64资源定位BUG 2026-07-17 14:58:05 +08:00
root 8ca6f0fedb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-17 09:14:36 +08:00
root 5b4449fe97 修改行业智造名字 2026-07-17 09:14:21 +08:00
sjy 71ec6f7d47 媒体生成百分比显示 2026-07-16 17:58:16 +08:00
sjy f0ff573650 媒体生成百分比显示 2026-07-16 17:52:49 +08:00
GinHa c5292fcf03 超分独立cli 2026-07-16 17:50:42 +08:00
GinHa edfeea6f98 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-16 16:55:27 +08:00
GinHa 395702fbd5 修复行业智造图片生成BUG 2026-07-16 16:54:42 +08:00
root 098a639be6 1 2026-07-16 15:38:48 +08:00
GinHa a7c99839de 超分 build admin 2026-07-16 15:04:28 +08:00
GinHa 6a7de32b3a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-16 15:02:14 +08:00
GinHa 3db586cd60 超分功能完成 2026-07-16 15:02:03 +08:00
root 52f53a0c63 1 2026-07-16 14:47:36 +08:00
root c80e2150f5 1 2026-07-16 14:43:20 +08:00
root 4d872036b2 调整后台文字模型请求发送图片或者视频的方式base64,而不是现在的链接形式,增加后台配置 2026-07-16 13:36:37 +08:00
root 38040dfb0d 1 2026-07-16 09:40:40 +08:00
root e44885461b 1 2026-07-16 09:39:19 +08:00
root 01c55721ca 1 2026-07-16 09:33:13 +08:00
root 085e85967a 1 2026-07-16 09:29:15 +08:00
root 41feb480ee 1、修改前台登陆样式背景可使用视频
2、增加后台配置前台背景样式
2026-07-16 09:28:43 +08:00
sjy 5036efe9aa Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 17:15:13 +08:00
sjy 3188d77b88 生成数量添加 2026-07-15 17:10:49 +08:00
root 3e900a43f0 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 13:51:36 +08:00
root 6db989dc42 1 2026-07-15 13:51:21 +08:00
sjy b1a8c3ed5a “爆款复刻/拆镜复刻积分不足提示” 2026-07-15 13:38:33 +08:00
GinHa 51673453d8 AI创作批量生成任务 V4 修复客户端AI创作排序BUG 2026-07-15 13:30:59 +08:00
GinHa 7d9c50a74d AI创作批量生成任务 V3 修复AI创作客户端排序 2026-07-15 13:23:02 +08:00
GinHa 0ffb35e2df AI创作批量生成任务 main V2 build push 2026-07-15 13:10:15 +08:00
GinHa 9756a86304 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-15 13:03:23 +08:00
GinHa 51f9deecde AI创作批量生成任务 main V1 init 2026-07-15 13:03:13 +08:00
root a9190ba4e1 修改复制的问题 2026-07-15 10:11:53 +08:00
root b4166ecadb 修改用户积分修改默认 2026-07-14 17:58:02 +08:00
sjy 4ade6bb347 “修改用户名/验证码滑动发送提示” 2026-07-14 17:23:28 +08:00
root 9637e5ba79 修改时区显示问题 2026-07-14 14:13:29 +08:00
root 21ff42653c 增加修改用户名 2026-07-14 11:18:28 +08:00
sjy 5b937f652b “真人人像/虚拟素材批量上传” 2026-07-13 17:54:01 +08:00
GinHa b2b322bcfb Merge branch 'main' into online 2026-07-13 17:34:26 +08:00
root dc2e527977 1 2026-07-13 17:32:21 +08:00
root 6ff8866ca3 修改后台权限的问题 2026-07-13 17:23:56 +08:00
sjy 6039b5ee75 “重新编辑修改” 2026-07-11 17:24:41 +08:00
GinHa adfc2aa4f4 Merge branch 'main' into online 2026-07-11 17:15:44 +08:00
sjy 399aefe61f “重新编辑修改” 2026-07-11 17:07:47 +08:00
sjy 4ab2baae43 “重新编辑修改” 2026-07-11 15:57:34 +08:00
root fed6ba2e19 1 2026-07-11 15:41:23 +08:00
root 18fe13dd9f 支付统计增加手机号 2026-07-11 15:35:54 +08:00
sjy 36e4bec90e “重新编辑修改” 2026-07-11 15:32:57 +08:00
root af60e6720b 1 2026-07-11 13:49:36 +08:00
GinHa 21208e68d8 Merge branch 'main' into online 2026-07-11 13:25:53 +08:00
root b79162654d 1 2026-07-11 13:23:01 +08:00
sjy 778da67b34 “拆镜状态修改” 2026-07-11 13:18:33 +08:00
GinHa a7cd5a634c celery 2小时结果留存 2026-07-11 13:16:21 +08:00
GinHa 0962fa7820 Merge branch 'main' into online 2026-07-11 13:05:21 +08:00
root 344fb789a4 1 2026-07-11 13:00:06 +08:00
root 6cc1655c69 1 2026-07-11 12:48:29 +08:00
root c64cf06c33 1 2026-07-11 12:38:03 +08:00
root 508732bc76 1 2026-07-10 19:23:52 +08:00
root 614ae1b4d6 1 2026-07-10 19:20:53 +08:00
485 changed files with 82598 additions and 17555 deletions
+953
View File
@@ -0,0 +1,953 @@
# 对外开放模型 API v3 接口文档
## 概述
本文档描述视频/图片生成平台的对外开放 API v3 接口。外部调用方通过 API Key 认证,调用 AI 视频和图片生成能力。
- **Base URL**: `http://your-domain.com/api/v3`
- **认证方式**: `Authorization: Bearer {api-key}`
- **数据格式**: JSON
- **字符编码**: UTF-8
---
## 认证
所有接口均需在请求头中携带 API Key:
```
Authorization: Bearer vk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 错误响应
认证失败时返回:
```json
// 401 API Key 无效或过期
{
"error": {
"code": "invalid_api_key",
"message": "无效的 API Key"
}
}
// 429 配额不足
{
"error": {
"code": "quota_exceeded",
"message": "配额不足 (需要 1.00 元, 剩余 0.50 元)"
}
}
```
---
## 接口列表
### 1. 获取可用模型列表
获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)。
```
GET /api/v3/models
```
**请求头:**
| 参数 | 必填 | 说明 |
|------|------|------|
| Authorization | 是 | `Bearer {api-key}` |
**响应示例:**
```json
{
"models": [
{
"model": "doubao-seedance-2-0-260128",
"engine_type": "video",
"engine_id": "eng_xxxx",
"supported_ratios": ["16:9", "9:16", "1:1", "4:3"],
"supported_resolutions": ["480p", "720p", "1080p"],
"supported_durations": [3, 4, 5, 6, 7, 8, 9, 10, 15]
},
{
"model": "doubao-seedream-5-0-260128",
"engine_type": "image",
"engine_id": "eng_yyyy",
"supported_sizes": ["2K", "4K"]
}
]
}
```
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| models | array | 可用模型列表 |
| models[].model | string | 模型名称 |
| models[].engine_type | string | 引擎类型: `video` / `image` |
| models[].engine_id | string | 引擎 ID |
| models[].supported_ratios | string[] | 视频支持的比例列表 |
| models[].supported_resolutions | string[] | 视频支持的分辨率列表 |
| models[].supported_durations | int[] | 视频支持的时长列表(秒) |
| models[].supported_sizes | string[] | 图片支持的尺寸列表 |
---
### 2. 创建视频生成任务(异步)
创建视频生成任务,接口立即返回 `task_id`,调用方通过轮询查询任务状态和结果。
**并发排队机制:**
- 每个 API Key 可配置最大并发视频任务数(`max_concurrent_video_tasks`
- 未超并发:任务立即执行,`status="queued"`
- 超过并发:任务排队等待,`status="pending_queue"`
- 当有任务完成/失败时,自动从队列中启动下一个任务
```
POST /api/v3/videos
```
**请求头:**
| 参数 | 必填 | 说明 |
|------|------|------|
| Authorization | 是 | `Bearer {api-key}` |
| Content-Type | 是 | `application/json` |
**请求体:**
```json
{
"model": "doubao-seedance-2-0-260128",
"content": [
{
"type": "text",
"text": "一只猫在草地上奔跑"
},
{
"type": "image_url",
"image_url": {"url": "https://..."},
"role": "reference_image"
},
{
"type": "video_url",
"video_url": {"url": "https://..."},
"role": "reference_video"
},
{
"type": "audio_url",
"audio_url": {"url": "https://..."},
"role": "reference_audio"
}
],
"ratio": "16:9",
"duration": 5,
"resolution": "1080p",
"generate_audio": true,
"watermark": false,
"idempotency_key": "unique-key-123"
}
```
**请求字段:**
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| model | string | 是 | - | 模型名称 |
| content | array | 是 | - | 生成内容数组(见下方) |
| ratio | string | 否 | `16:9` | 视频比例: `16:9` / `9:16` / `1:1` / `4:3` / `3:4` / `21:9` |
| duration | int | 否 | `5` | 视频时长(秒): 3-15 |
| resolution | string | 否 | `480p` | 分辨率: `480p` / `720p` / `1080p` |
| generate_audio | bool | 否 | `true` | 是否生成音频 |
| watermark | bool | 否 | `false` | 是否添加水印 |
| idempotency_key | string | 否 | - | 幂等键,防止重复创建 |
**content 数组元素 (ApiVideoContentPart)**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | 内容类型: `text` / `image_url` / `video_url` / `audio_url` |
| text | string | 否 | 文本内容(type=text 时必填) |
| image_url | object | 否 | 图片URL对象: `{"url": "..."}`type=image_url 时必填) |
| video_url | object | 否 | 视频URL对象: `{"url": "..."}`type=video_url 时必填) |
| audio_url | object | 否 | 音频URL对象: `{"url": "..."}`type=audio_url 时必填) |
| role | string | 否 | 参考角色: `first_frame` / `last_frame` / `reference_image` / `reference_video` / `reference_audio` |
**响应示例:**
```json
{
"id": "cgt-20260730183334-wdgfl"
}
```
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 任务 ID,用于查询状态 |
---
### 3. 查询视频任务状态
根据 `task_id` 查询视频生成任务的状态和结果。
```
GET /api/v3/videos/{task_id}
```
**请求头:**
| 参数 | 必填 | 说明 |
|------|------|------|
| Authorization | 是 | `Bearer {api-key}` |
**路径参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| task_id | string | 创建任务时返回的 task_id |
**响应示例(排队中):**
```json
{
"id": "cgt-20260730183334-wdgfl",
"model": "doubao-seedance-2-0-mini-260615",
"status": "queued",
"created_at": 1785407620,
"updated_at": 1785407620,
"content": null,
"duration": null,
"ratio": null,
"resolution": null
}
```
**响应示例(运行中):**
```json
{
"id": "cgt-20260730183334-wdgfl",
"model": "doubao-seedance-2-0-mini-260615",
"status": "running",
"created_at": 1785407620,
"updated_at": 1785407650,
"content": null,
"duration": null,
"ratio": null,
"resolution": null
}
```
**响应示例(成功):**
```json
{
"id": "cgt-20260730183334-wdgfl",
"model": "doubao-seedance-2-0-mini-260615",
"status": "succeeded",
"created_at": 1785407620,
"updated_at": 1785407723,
"content": {
"video_url": "https://..."
},
"duration": 4,
"ratio": "9:16",
"resolution": "480p"
}
```
**响应示例(失败):**
```json
{
"id": "cgt-20260730183334-wdgfl",
"model": "doubao-seedance-2-0-mini-260615",
"status": "failed",
"created_at": 1785407620,
"updated_at": 1785407650,
"content": null,
"duration": null,
"ratio": null,
"resolution": null
}
```
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 任务 ID |
| model | string | 模型名称 |
| status | string | 任务状态(见下方状态说明) |
| created_at | int | 创建时间戳(Unix) |
| updated_at | int | 更新时间戳(Unix) |
| content | object | 视频内容(成功时返回,包含 video_url) |
| duration | int | 视频时长(秒) |
| ratio | string | 视频比例 |
| resolution | string | 分辨率 |
**任务状态说明:**
| 状态 | 说明 |
|------|------|
| `queued` | 排队中 |
| `running` | 任务运行中 |
| `succeeded` | 任务成功 |
| `failed` | 任务失败 |
| `expired` | 任务超时 |
---
### 4. 生成图片(同步)
同步生成图片,接口阻塞等待完成后直接返回结果。
```
POST /api/v3/images
```
**请求头:**
| 参数 | 必填 | 说明 |
|------|------|------|
| Authorization | 是 | `Bearer {api-key}` |
| Content-Type | 是 | `application/json` |
**请求体:**
```json
{
"model": "doubao-seedream-5-0-260128",
"prompt": "一只可爱的猫咪",
"size": "2K",
"response_format": "url",
"watermark": false,
"image": ["https://example.com/ref1.jpg"],
"output_format": "png",
"sequential_image_generation": "auto",
"generation_count": 1
}
```
**请求字段:**
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| model | string | 是 | - | 模型名称 |
| prompt | string | 是 | - | 图片描述提示词 |
| size | string | 否 | `2K` | 图片尺寸: `2K` / `4K``2048x2048` |
| response_format | string | 否 | `url` | 返回格式: `url` / `b64_json` |
| watermark | bool | 否 | `false` | 是否添加水印 |
| image | string[] | 否 | - | 参考图片 URL 列表 |
| output_format | string | 否 | - | 输出格式: `jpeg` / `png` / `webp` |
| sequential_image_generation | string | 否 | - | 组图模式: `auto` 开启 |
| generation_count | int | 否 | `1` | 生成数量: 1-5 |
**响应示例 (200 OK)**
```json
{
"created": 1721000000,
"data": [
{
"url": "https://volc.example.com/image/xxx.png",
"size": "2K",
"output_format": "png"
}
],
"model": "doubao-seedream-5-0-260128"
}
```
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| created | int | 创建时间戳(Unix) |
| data | array | 图片结果列表 |
| data[].url | string | 图片 URL |
| data[].b64_json | string | Base64 编码图片(response_format=b64_json 时) |
| data[].size | string | 图片尺寸 |
| data[].output_format | string | 输出格式 |
| model | string | 使用的模型名称 |
---
## 虚拟素材库接口(中转)
虚拟素材库用于在**火山方舟私域可信素材服务**中管理专属的图片/视频素材(如客户专属虚拟人)。数据与前台用户私域素材库完全隔离,归属按 API Key 管理。
**启用前置条件:**
1. 管理员在后台「API Key 管理 → 详情 → 虚拟素材库配额」中配置项目数/素材数/存储 MB 上限(默认 0=不可用)
2. 任一上限大于 0 即表示该 API Key 启用了虚拟素材库功能
3. 所有操作占用对应配额,超出上限返回 403 `quota_exceeded`
**生命周期流程(新,一步到位):**
1. 创建虚拟项目(CreateAssetGroup 建远端组)
2. 直接调用「项目下创建素材」接口,**仅传一个公网可访问的 URL**(http/https 图片/视频直链)
- 服务端先将该 URL 的文件**下载保存到本地存储系统**(路径见下),占用存储配额
- 保存成功后,再将**本地公网 URL** 同步提交给火山平台 CreateAsset 做异步审核
3. 素材状态 Creating → 轮询 `/assets/{id}``/assets/{id}/sync` 主动刷新 → 状态 Active(可使用)
4. AI 创作时通过 `/selectable-assets` 选择器拿到已就绪素材
5. 素材/项目删除(软删本地 + 同步清理本地落盘文件 → 异步删火山远端,返回 `remote_delete_status=pending`
**素材文件本地保存路径(服务端自动处理,调用方无需关心):**
- 图片:`/uploads/images/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
- 视频:`/uploads/videos/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
**技术细节 & 错误处理(保证数据一致性):**
1. URL 下载阶段失败(网络/超时/4xx/5xx/大文件/非法 MIME):立即清理临时文件,不占用任何配额,返回对应错误码
2. 下载成功但**配额不足**:立即删除已下载的本地文件,释放磁盘,再抛 403 `quota_exceeded`
3. 视频时长:若请求未传 `video_duration`,服务端自动 ffprobe 探测;两者都失败则删本地文件并 400 要求显式传时长
4. 本地写库成功但**火山 CreateAsset 失败**:保留本地文件(已占配额和素材数),素材状态标记为 `Failed`,错误信息记录在 `error_message` / `moderation_json`。调用方可选择:
- 保留并排查(后续可调用 DELETE 删除 → 自动清理本地文件 + 返还配额)
- 直接 DELETE 重试
5. 删除素材:**本地 commit 时同步删除本地落盘文件**(目录穿越防御,仅允许删 `/uploads` 目录内),返还存储配额和素材数配额;火山远端异步删除
---
### 5. 获取配额配置
获取当前 API Key 的虚拟素材库配额上限和已使用量,判断是否可用。
```
GET /api/v3/virtual-portrait/config
```
**响应示例:**
```json
{
"project_limit": 5,
"asset_limit": 50,
"storage_mb_limit": 500,
"project_used": 2,
"asset_used": 18,
"storage_mb_used": 128.43,
"enabled": true
}
```
**响应字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| project_limit | int | 虚拟项目上限,0=不可创建 |
| asset_limit | int | 虚拟素材总数上限(图片+视频),0=不可上传 |
| storage_mb_limit | int | 上传存储上限 MB,0=不可上传文件 |
| project_used | int | 已创建项目数(未删除) |
| asset_used | int | 已上传素材数(未删除) |
| storage_mb_used | float | 已占用存储 MB |
| enabled | bool | 该 Key 是否可使用虚拟素材库功能(任一上限>0即可) |
---
### 6. 获取枚举元数据
返回素材/项目所有枚举值及其说明,便于前端展示筛选选项。
```
GET /api/v3/virtual-portrait/enums
```
**响应示例:**
```json
{
"asset_type": { "Image": "图片素材", "Video": "视频素材" },
"asset_status": {
"creating": "创建中/审核中",
"active": "已就绪/可用",
"failed": "失败",
"deleting": "删除中"
},
"project_status": {
"creating_remote_group": "远端组创建中",
"active": "就绪",
"create_group_failed": "远端组创建失败",
"deleting": "删除中"
},
"remote_delete_status": {
"none": "未删除",
"pending": "待异步删除",
"processing": "远端删除中",
"deleted": "远端已删除",
"failed": "远端删除失败"
}
}
```
---
### 7. 虚拟项目 CRUD
#### 7.1 创建项目
创建一个虚拟素材项目(同步调火山 CreateAssetGroup 创建远端素材组)。
```
POST /api/v3/virtual-portrait/projects
```
**请求体:**
```json
{
"name": "客户A的虚拟人素材",
"description": "用于客户A的电商视频生成(可选)"
}
```
**字段说明:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 是 | 项目名称,1-100 字符 |
| description | string | 否 | 项目描述,最多 500 字符 |
**响应(VpV3ProjectOut):**
```json
{
"Id": "0019xxxxxxxxxxxxxxxx"
}
```
#### 7.2 查询项目列表
```
GET /api/v3/virtual-portrait/projects
```
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认 1 |
| page_size | int | 否 | 每页数量 1-100,默认 20 |
| keyword | string | 否 | 项目名称模糊搜索 |
| status | string | 否 | 项目状态筛选(不传查全部) |
**响应:** `{ "items": [...], "total": N, "page": X, "page_size": Y }`
#### 7.3 项目详情
```
GET /api/v3/virtual-portrait/projects/{project_id}
```
#### 7.4 更新项目
修改展示信息(名称/描述),不会重新创建火山远端 Group。
```
PUT /api/v3/virtual-portrait/projects/{project_id}
```
#### 7.5 删除项目
软删项目和其下所有素材。**本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset**,接口返回 `remote_delete_status=pending` 表示远端删除处理中,可通过项目详情接口轮询最终状态。
```
DELETE /api/v3/virtual-portrait/projects/{project_id}
```
**响应示例:**
```json
{ "success": true, "remote_delete_status": "pending" }
```
---
### 8. 虚拟素材 CRUD
#### 8.1 在项目下创建素材
创建素材(一步到位:**仅传 URL**:
1. 服务端先将 `source_url`(公网 http(s))下载保存到本地存储系统(自动校验 URL/网络/MIME/大小)
2. 下载成功后占用 **本地存储配额 & 素材数配额
3. 再将本地公网 URL 同步提交给火山方舟 CreateAsset 进行异步审核
```
POST /api/v3/virtual-portrait/projects/{project_id}/assets
```
**请求体:**
```json
{
"source_url": "https://cdn.example.com/avatars/portrait_01.png",
"name": "虚拟人正面照片",
"asset_type": "Image"
}
```
**请求字段(新):**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| source_url | string | 是 | **公网可访问的 http(s) 图片/视频直链**,最多 2000 字符。<br/>⚠️ 不允许指向内网/本机地址(SSRF 防御) |
| name | string | 否 | 素材展示名,1-100 字符;不传自动从 URL 文件名或响应头 Content-Disposition 推断 |
| asset_type | string | 是 | `Image` / `Video` |
| video_duration | float | Video 可选 | 视频时长秒(1-60)。Video 不传会自动 ffprobe 探测,两者都失败则返回 400 需显式传入 |
| video_cover_url | string | 否(Video | 视频封面图 URL(可选,仅 Video 用) |
> 💡 **不再需要**`upload_resource_id` / `file_size_bytes` / `mime_type` —— 服务端自动探测并写入。
**创建流程时序(服务端内部处理步骤):**
1. 校验 URL 格式(http/https + 非内网) → 400
2. 下载 URL 文件到临时目录
- HTTP 4xx/5xx → 502(含前 200B 响应片段);连接/读取超时 → 502
- Content-Type 非法 → 415application/octet-stream 除外);大小超限 → 413
- 任一步失败:立即清理临时文件,不占配额
3. 配额校验(素材数 + 存储 MB,按真实大小)→ 不足则删除刚下载的本地文件,403
4. Video 时长合并校验(payload 优先,否则 ffprobe 探测) → 非法删本地文件并 400
5. 写 VpV3Assetstatus=Creating+ 刷新 next_poll_at
6. 调火山 CreateAsset(url=本地公网 URL) → 成功返回 Creating + remote_asset_id;失败则 status=Failed, error_message=错误
**响应:VpV3AssetOut**(字段见素材详情)
创建成功后 `status=Creating`,火山审核 3-30 秒,建议:
- 轮询 GET /assets/{id} 或 POST /assets/{id}/sync 主动刷新
- 直到 status=Active 才能在 AI 创作中使用
- 如果 status=Failed,读取 `error_message` / `moderation_json` 查看原因;可选择 DELETE 后重试
#### 8.2 查询项目下素材列表
```
GET /api/v3/virtual-portrait/projects/{project_id}/assets
```
**Query 参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| page / page_size | int | 分页,默认 1/20 |
| status | string | 素材状态筛选:Creating/Active/Failed/Deleting |
| keyword | string | 素材名称模糊搜索 |
| asset_type | string | Image / Video |
#### 8.3 素材详情
```
GET /api/v3/virtual-portrait/assets/{asset_id}
```
#### 8.4 主动同步素材状态
主动调火山 GetAsset 刷新素材状态、URL、审核结果(轮询中断或前端主动刷新时使用)。
```
POST /api/v3/virtual-portrait/assets/{asset_id}/sync
```
#### 8.5 删除素材
软删素材。本地 commit 时**同步删除本地落盘文件**(自动清理 `/uploads/...` 目录下的文件,带目录穿越防御),再投递 Celery 异步任务删除火山远端 Asset,返回 `remote_delete_status=pending` 表示处理中。
```
DELETE /api/v3/virtual-portrait/assets/{asset_id}
```
**响应示例:** `{ "success": true, "remote_delete_status": "pending" }`
> 说明:
> - 本地文件删除失败只打日志,不会影响素材状态置为 deleting + soft-delete(避免远端删除也回滚)
> - 删除成功后存储/素材数配额会自动返还(`/config` 接口再次查询可见 used 降低)
---
### 9. AI 创作选择器素材
只返回当前 API Key 虚拟素材库中 **status=Active** 的图片/视频素材,提供给 AI 创作参考素材选择器使用。
```
GET /api/v3/virtual-portrait/selectable-assets
```
**Query 参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| page / page_size | int | 分页,默认 1/201-100 |
| project_id | string | 可选,按项目筛选 |
| keyword | string | 可选,素材名称模糊搜索 |
| asset_type | string | 可选,Image / Video |
**响应字段(VpV3SelectableAssetOut):**
| 字段 | 类型 | 说明 |
|------|------|------|
| asset_id | string | 素材 ID,供后续带入生成(预留使用:`source=vp_v3_asset, asset_id` |
| project_id | string | 所属项目 ID |
| name | string | 素材名称 |
| asset_type | string | Image/Video |
| status | string | Active |
| source_url | string | 原始上传 URL |
| preview_url | string | 显示用预览 URL(直接绑定 img/video src |
| video_duration | float | 视频时长秒(Video 时有值) |
| video_cover_url | string | 视频封面 |
| file_size_bytes | int | 文件大小字节 |
| created_at | datetime | 创建时间 |
---
### 虚拟素材库配额限制错误
当 API Key 的虚拟素材库配额不足时,会返回 403:
```json
{
"error": {
"code": "quota_exceeded",
"message": "虚拟素材库配额不足:素材总数 上限 50,已使用 50,本次需要 1,超出上限"
}
}
```
如果管理员完全没有配置配额(全 0),任何虚拟素材库操作都返回:
```json
{
"error": {
"code": "forbidden",
"message": "当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额"
}
}
```
---
## 价格计算规则
API 采用**先扣后退回**策略:任务创建/生成前预扣配额,失败时自动退回。
### 视频价格公式
```
基础费用 = (base_price + per_second_price × duration) × price_ratio
传入视频附加 = (input_video_base_price + input_video_per_second_price × 视频时长) × input_video_ratio
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
总价格 = 基础费用 + 传入视频附加 + 传入图片附加
```
### 图片价格公式
```
基础费用 = base_price × price_ratio
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
总价格 = 基础费用 + 传入图片附加
```
### 价格查找优先级
1. 引擎专属规则: `gen_type + engine_id + resolution`
2. 降级: `gen_type + resolution`(取 base_price 最高的)
3. 未配置价格的模型不会出现在可用列表中
---
## 状态码说明
| 状态码 | 说明 |
|--------|------|
| 200 | 请求成功 |
| 202 | 任务已创建(视频接口) |
| 401 | API Key 无效或缺失 |
| 403 | API Key 过期或无权限 |
| 404 | 资源不存在 |
| 422 | 请求参数校验失败 |
| 429 | 配额不足或并发超限 |
| 500 | 服务器内部错误 |
| 504 | 生成超时(图片接口) |
---
## 使用示例
### cURL 示例
```bash
# 1. 获取可用模型
curl -X GET http://localhost:8000/api/v3/models \
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
# 2. 创建视频任务
curl -X POST http://localhost:8000/api/v3/videos \
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "doubao-seedance-2-0-260128",
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
"ratio": "16:9",
"duration": 5,
"resolution": "1080p"
}'
# 3. 查询视频状态
curl -X GET http://localhost:8000/api/v3/videos/{task_id} \
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
# 4. 生成图片
curl -X POST http://localhost:8000/api/v3/images \
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "doubao-seedream-5-0-260128",
"prompt": "一只可爱的猫咪",
"size": "2K"
}'
```
### Python 示例
```python
import requests
BASE_URL = "http://localhost:8000/api/v3"
API_KEY = "vk_xxxxxxxxxxxx"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# 获取模型列表
resp = requests.get(f"{BASE_URL}/models", headers=HEADERS)
models = resp.json()["models"]
# 创建视频任务
resp = requests.post(f"{BASE_URL}/videos", headers=HEADERS, json={
"model": "doubao-seedance-2-0-260128",
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
"ratio": "16:9",
"duration": 5,
"resolution": "1080p",
})
task_id = resp.json()["task_id"]
# 轮询视频状态
import time
while True:
resp = requests.get(f"{BASE_URL}/videos/{task_id}", headers=HEADERS)
data = resp.json()
if data["status"] == "completed":
print(f"视频URL: {data['video_url']}")
break
elif data["status"] == "failed":
print(f"失败: {data['error']}")
break
time.sleep(30)
# 生成图片
resp = requests.post(f"{BASE_URL}/images", headers=HEADERS, json={
"model": "doubao-seedream-5-0-260128",
"prompt": "一只可爱的猫咪",
"size": "2K",
})
images = resp.json()["data"]
for img in images:
print(f"图片URL: {img['url']}")
```
---
## HTTP 状态码
所有接口 HTTP 状态码固定返回 **200**,业务结果通过响应体中的 `code` 字段判断:
| code | 说明 |
|------|------|
| 0 | 成功 |
| 40000 | 请求参数错误 |
| 40001 | 模型+分辨率未配置价格 |
| 40100 | API Key 无效或缺失 |
| 40300 | API Key 过期或无权限 |
| 40400 | 资源不存在 |
| 42200 | 参数校验失败 |
| 42900 | 配额不足或并发超限 |
| 50000 | 服务器内部错误 |
| 50400 | 生成超时(图片接口) |
---
## 统一响应格式
**成功响应:**
```json
{
"code": 0,
"data": { ... },
"message": "ok"
}
```
**错误响应:**
```json
{
"code": 40001,
"data": null,
"message": "模型 'eng_xxxx' 在分辨率 '1080p' 下未配置,无法生成"
}
```
---
## 注意事项
1. **配额预扣**: 视频任务创建时预扣配额,失败时自动退回
2. **并发限制**: 每个 API Key 有最大并发视频任务数限制
3. **轮询间隔**: 视频任务建议轮询间隔 30 秒(前 10 分钟可缩短至 30 秒,之后逐步增加)
4. **超时时间**: 视频任务最长 24 小时,超时自动失败并退回配额
5. **幂等键**: 视频接口支持 `idempotency_key`,相同键重复请求返回同一任务
6. **图片同步**: 图片接口为同步阻塞调用,建议设置 300 秒超时
7. **统一格式**: 所有接口 HTTP 状态码固定 200,通过 `code` 字段判断业务结果
---
## 部署与运维
### Celery Worker 启动命令
API v3 视频异步生成依赖以下 3 个 Celery 队列:
| 队列 | 用途 | 推荐并发 |
|------|------|---------|
| `gen_api_create` | 视频任务创建(调用 Volcano Ark SDK | 2-4 |
| `gen_api_poll` | 视频状态轮询 | 2-4 |
| `gen_api_download` | 视频下载与超分 | 2-4 |
```bash
# 启动 API v3 专用 Worker
celery -A app.tasks.celery_app worker -l info \
-Q gen_api_create,gen_api_poll,gen_api_download \
--concurrency=4 -n worker_api@%h
# 启动 Beat 调度器(定时恢复任务)
celery -A app.tasks.celery_app beat -l info
```
### 容灾恢复
服务重启后,Worker 会自动触发恢复扫描:
- 扫描 48 小时内未完成的 `ApiGenerationTask`
- 重新入队中断的 Celery 任务
- 每分钟定时扫描(Beat 调度)
### 日志目录
```
storage/logs/external/
├── requests/ # 外部 API 请求/响应日志
├── models/ # 模型调用日志
├── upscale/ # 超分轮询日志
└── errors/ # 错误日志
```
+69
View File
@@ -383,6 +383,75 @@ async def _cleanup_urls(): # 实际逻辑写在 async 函数
- 队列路由在 `celery_app.py``task_routes` 配置
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
#### Celery 队列清单(12 个)
| 队列 | 用途 | 推荐并发 |
|------|------|---------|
| `gen_chatapi_create` | ChatAPI 生成任务创建 | 2-4 |
| `gen_provider_poll` | 轮询火山引擎生成状态 | 2-4 |
| `gen_result_download` | 下载生成的视频/图片结果 | 2-4 |
| `gen_video_upscale_local` | 本地视频超分(FFmpeg | 1-2 |
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit | 1-2 |
| `gen_recovery` | 容灾恢复任务 | 1 |
| `gen_private_portrait` | 真人素材认证与同步 | 1-2 |
| `gen_shot_analysis` | 拆镜分析 | 1-2 |
| `gen_shot_split` | 拆镜切片 | 1-2 |
| `gen_api_create` | API v3 视频任务创建 | 2-4 |
| `gen_api_poll` | API v3 视频状态轮询 | 2-4 |
| `gen_api_download` | API v3 视频下载 | 2-4 |
| `gen_api_upscale` | API v3 超分 | 2-4 |
| `default` | 默认队列(用户 OAuth、清理任务等) | 1-2 |
#### 完整启动命令
```bash
# ── 单机部署(所有队列一个 Worker)──
celery -A app.tasks.celery_app worker -l info \
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
--concurrency=4
# ── 生产环境(按功能分离 Worker)──
# 业务 Worker
celery -A app.tasks.celery_app worker -l info \
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
--concurrency=4 -n worker_busy@%h
# 超分 Worker
celery -A app.tasks.celery_app worker -l info \
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
--concurrency=2 -n worker_upscale@%h
# 恢复 Worker
celery -A app.tasks.celery_app worker -l info \
-Q gen_recovery --concurrency=1 -n worker_recovery@%h
# 其他 Worker
celery -A app.tasks.celery_app worker -l info \
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
--concurrency=2 -n worker_other@%h
# Beat 调度器(定时任务)
celery -A app.tasks.celery_app beat -l info
```
#### Beat 定时任务清单
| 任务 | 频率 | 说明 |
|------|------|------|
| `dispatch-due-poll-tasks` | 每分钟 | 调度到期的轮询任务 |
| `generation-create-recovery` | 每分钟 | 恢复未完成的创建任务 |
| `module-async-recovery` | 每分钟 | 恢复模块异步任务 |
| `video-upscale-recovery` | 每分钟 | 恢复未完成的超分任务 |
| `generation-download-recovery` | 每分钟 | 恢复未完成的下载任务 |
| `shot-split-recovery` | 每分钟 | 恢复拆镜切片任务 |
| `shot-analysis-recovery` | 每分钟 | 恢复拆镜分析任务 |
| `api-generation-recovery` | 分钟 | 恢复 API v3 未完成任务 |
| `celery-runtime-reconcile` | 每 5 分钟 | Worker 实例协调 |
| `celery-runtime-registry-gc` | 每 10 分钟 | Worker 注册表 GC |
| `private-portrait-sync-due-assets` | 每分钟 | 同步到期素材 |
| `private-portrait-recover-remote-deletes` | 每 5 分钟 | 恢复远程删除任务 |
### 2.14 配置约定
```python
+117 -7
View File
@@ -234,29 +234,72 @@ sudo systemctl start videogen-api
### 6. Celery Worker(可选)
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
Celery 使用 **6 个队列**,按功能分离:
Celery 使用 **12 个队列**,按功能分离:
| 队列 | 用途 |
|------|------|
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
| `gen_provider_poll` | 轮询火山引擎生成状态 |
| `gen_result_download` | 下载生成的视频/图片结果 |
| `gen_video_upscale_local` | 本地视频超分(FFmpeg |
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit |
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
| `gen_private_portrait` | 真人素材认证与同步 |
| `gen_shot_analysis` | 拆镜分析 |
| `gen_shot_split` | 拆镜切片 |
| `gen_api_create` | **API v3 视频任务创建** |
| `gen_api_poll` | **API v3 视频状态轮询** |
| `gen_api_download` | **API v3 视频下载** |
| `gen_api_upscale` | **API v3 超分处理(本地/远程)** |
| `default` | 默认队列(用户 OAuth、清理任务等) |
#### 启动命令
```bash
# 启动 Worker(消费所有队列
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
# ── 启动 Worker(消费所有队列,单机部署)──
celery -A app.tasks.celery_app worker -l info \
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
--concurrency=4
# ── 按功能分离 Worker(生产环境推荐)──
# 业务 Worker:处理生成创建、轮询、下载
celery -A app.tasks.celery_app worker -l info \
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
--concurrency=4 \
-n worker_busy@%h
# 超分 Worker:处理本地和远程超分(含 API v3 超分)
celery -A app.tasks.celery_app worker -l info \
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
--concurrency=2 \
-n worker_upscale@%h
# 恢复 Worker:处理容灾恢复(低频任务)
celery -A app.tasks.celery_app worker -l info \
-Q gen_recovery \
--concurrency=1 \
-n worker_recovery@%h
# 其他 Worker:真人素材、拆镜、默认
celery -A app.tasks.celery_app worker -l info \
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
--concurrency=2 \
-n worker_other@%h
# ── 启动 Celery Beat(定时任务调度器)──
celery -A app.tasks.celery_app beat -l info
```
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`
#### systemd 服务文件
**业务 Worker** `/etc/systemd/system/videogen-worker-busy.service`
```ini
[Unit]
Description=VideoGen Celery Worker
Description=VideoGen Celery Worker (Busy)
After=network.target redis.service
[Service]
@@ -264,7 +307,7 @@ Type=simple
User=www-data
WorkingDirectory=/opt/video-gen-api
Environment=PATH=/opt/video-gen-api/.venv/bin
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download --concurrency=4 -n worker_busy@%h
Restart=always
RestartSec=5
@@ -272,6 +315,73 @@ RestartSec=5
WantedBy=multi-user.target
```
**超分 Worker** `/etc/systemd/system/videogen-worker-upscale.service`
```ini
[Unit]
Description=VideoGen Celery Worker (Upscale)
After=network.target redis.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/video-gen-api
Environment=PATH=/opt/video-gen-api/.venv/bin
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale --concurrency=2 -n worker_upscale@%h
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**恢复 Worker** `/etc/systemd/system/videogen-worker-recovery.service`
```ini
[Unit]
Description=VideoGen Celery Worker (Recovery)
After=network.target redis.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/video-gen-api
Environment=PATH=/opt/video-gen-api/.venv/bin
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_recovery --concurrency=1 -n worker_recovery@%h
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**Beat 调度器** `/etc/systemd/system/videogen-beat.service`
```ini
[Unit]
Description=VideoGen Celery Beat
After=network.target redis.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/video-gen-api
Environment=PATH=/opt/video-gen-api/.venv/bin
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app beat -l info
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
# 启用并启动所有服务
sudo systemctl daemon-reload
sudo systemctl enable videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
sudo systemctl start videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
```
### 7. Docker 部署(可选)
项目提供 `Dockerfile``docker-compose.yml`
+3 -1
View File
@@ -1,5 +1,7 @@
# VITE_API_BASE=http://192.168.120.17:8000
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
#VITE_API_BASE=https://apiforeign.minzhongzc.com
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
#VITE_API_BASE=http://localhost:8000
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
@import "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";:root{--font-sans:"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--nav-bg:#08080c;--nav-surface:#ffffff0a;--nav-border:#ffffff0f;--nav-text:#e8e8ec;--nav-text-muted:#8b8fa3;--nav-hover:#ffffff14;--nav-active:#ffffff1f}*,:before,:after{box-sizing:border-box}html,body{font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#1a1a2e;letter-spacing:-.01em;background:#f8f9fc;margin:0;padding:0}#root{min-height:100vh}
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
<script type="module" crossorigin src="/assets/index-B0W8S7jH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
<div id="root"></div>
+82
View File
@@ -13,6 +13,7 @@
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13"
@@ -1428,6 +1429,12 @@
"node": ">=8"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
@@ -1437,6 +1444,12 @@
"node": ">=0.8"
}
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1761,6 +1774,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -1780,6 +1812,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1841,6 +1879,35 @@
"node": ">=0.8"
}
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -1868,6 +1935,21 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-quill-new": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21",
"quill": "~2.0.3"
},
"peerDependencies": {
"quill-delta": "^5.1.0",
"react": "^16 || ^17 || ^18 || ^19",
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dayjs": "^1.11.21",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"xlsx-js-style": "^1.2.0",
"zustand": "^5.0.13"
+20 -2
View File
@@ -21,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminCreditProducts from './pages/AdminCreditProducts';
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
@@ -32,12 +34,19 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminVideoUpscale from './pages/AdminVideoUpscale';
import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
import AdminOAuthList from './pages/AdminOAuthList';
import AdminMaterialList from './pages/AdminMaterialList';
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
import AdminApiKeys from './pages/AdminApiKeys';
import AdminApiModelPricings from './pages/AdminApiModelPricings';
import AdminApiUsage from './pages/AdminApiUsage';
import AdminInvoices from './pages/AdminInvoices';
import AdminBankTransactions from './pages/AdminBankTransactions';
import AdminScheduledTasks from './pages/AdminScheduledTasks';
import { useAdminStore } from './store';
@@ -93,11 +102,20 @@ const App = () => {
<Route path="image-engines" element={<AdminImageEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="credit-products" element={<AdminCreditProducts />} />
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<Route path="video-upscale" element={<AdminVideoUpscale />} />
<Route path="api-keys" element={<AdminApiKeys />} />
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
<Route path="api-usage" element={<AdminApiUsage />} />
<Route path="invoices" element={<AdminInvoices />} />
<Route path="bank-transactions" element={<AdminBankTransactions />} />
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
+406 -50
View File
@@ -17,6 +17,8 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
BankAccount, ScheduledTask,
} from '../types';
import type {
@@ -41,6 +43,18 @@ import type {
HomeMaterialWatermarkQueryParams,
} from '../types';
// ── Video Upscale ────────────────────────────────────────
export async function getVideoUpscaleConfig(): Promise<VideoUpscaleConfigOut> {
return api.get<VideoUpscaleConfigOut>('/admin/video-upscale/config');
}
export async function saveVideoUpscaleConfig(payload: VideoUpscaleConfigSavePayload): Promise<VideoUpscaleConfigOut> {
return api.put<VideoUpscaleConfigOut>('/admin/video-upscale/config', payload);
}
// ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
@@ -195,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminTeamDetail(teamId: string): Promise<AdminTeam> { return api.get(`/admin/teams/${teamId}`); }
export async function getAdminTeamSubscriptions(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/subscriptions`); }
export async function getAdminTeamMemberUsage(teamId: string, subscriptionId?: string): Promise<any[]> {
const q = subscriptionId ? `?subscription_id=${encodeURIComponent(subscriptionId)}` : ''; return api.get(`/admin/teams/${teamId}/member-usage${q}`);
}
export async function getAdminTeamManagerHistory(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/manager-history`); }
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
@@ -203,6 +224,106 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
}
export async function queryBankTransactions(params: {
accountId: string;
startDate: string;
endDate: string;
dcFlag?: number;
page?: number;
pageSize?: number;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
qs.set('account_id', params.accountId);
qs.set('start_date', params.startDate);
qs.set('end_date', params.endDate);
if (params.dcFlag !== undefined) qs.set('dc_flag', String(params.dcFlag));
if (params.page) qs.set('page', String(params.page));
if (params.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/bank/transactions?${qs.toString()}`);
}
// ── Bank Account Management ────────────────────────────────
export async function listBankAccounts(): Promise<{ items: BankAccount[] }> {
return api.get('/admin/bank/accounts');
}
export async function createBankAccount(payload: {
account_name: string;
bank_name: string;
account_no: string;
is_active?: boolean;
is_default?: boolean;
description?: string;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/bank/accounts', payload);
}
export async function updateBankAccount(
accountId: string,
payload: Partial<{
account_name: string;
bank_name: string;
account_no: string;
is_active: boolean;
is_default: boolean;
description: string;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/bank/accounts/${accountId}`, payload);
}
export async function deleteBankAccount(accountId: string): Promise<{ message: string }> {
await api.delete(`/admin/bank/accounts/${accountId}`);
return { message: '删除成功' };
}
// ── Scheduled Tasks ────────────────────────────────────────
export async function listScheduledTasks(): Promise<{ items: ScheduledTask[] }> {
return api.get('/admin/scheduled-tasks');
}
export async function createScheduledTask(payload: {
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config?: Record<string, unknown> | string;
is_active?: boolean;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/scheduled-tasks', payload);
}
export async function updateScheduledTask(
taskId: string,
payload: Partial<{
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config: Record<string, unknown> | string;
is_active: boolean;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/scheduled-tasks/${taskId}`, payload);
}
export async function deleteScheduledTask(taskId: string): Promise<{ message: string }> {
await api.delete(`/admin/scheduled-tasks/${taskId}`);
return { message: '删除成功' };
}
export async function runScheduledTask(taskId: string): Promise<{ message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/run`, {});
}
export async function toggleScheduledTask(taskId: string): Promise<{ is_active: boolean; message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/toggle`, {});
}
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
}
export async function getModelConfigs(): Promise<ModelConfig[]> {
return api.get('/admin/model-configs');
}
@@ -224,6 +345,14 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value });
}
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
return api.post('/admin/system-configs', { key, value, description });
}
export async function resetActivityBanner(): Promise<{ siteBannerVersion: number }> {
return api.post('/admin/system-configs/banner/reset');
}
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global');
}
@@ -276,6 +405,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
return { url: res.url };
}
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || '上传失败');
}
return res.json();
}
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
}
@@ -289,10 +434,12 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
setMaybe(params, 'charge_kind', filters?.chargeKind);
setMaybe(params, 'charge_action', filters?.chargeAction);
setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene);
@@ -315,8 +462,9 @@ export async function deleteIndustryConfig(id: string): Promise<void> {
await api.delete(`/admin/industry-configs/${id}`);
}
export async function getVideoEngines(): Promise<any[]> {
return api.get('/admin/video-engines');
export async function getVideoEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/video-engines${query}`);
}
export async function saveVideoEngine(engine: any): Promise<any> {
@@ -328,8 +476,9 @@ export async function deleteVideoEngine(id: string): Promise<void> {
await api.delete(`/admin/video-engines/${id}`);
}
export async function getImageEngines(): Promise<any[]> {
return api.get('/admin/image-engines');
export async function getImageEngines(options?: { includeDeleted?: boolean }): Promise<any[]> {
const query = options?.includeDeleted ? '?include_deleted=true' : '';
return api.get(`/admin/image-engines${query}`);
}
export async function saveImageEngine(engine: any): Promise<any> {
@@ -354,6 +503,131 @@ export async function deleteCreditRatio(id: string): Promise<void> {
await api.delete(`/admin/credit-ratios/${id}`);
}
// === API 模型价格管理 ===
export async function getApiModelPricings(): Promise<any[]> {
return api.get('/admin/api-model-pricings');
}
export async function saveApiModelPricing(pricing: any): Promise<any> {
if (pricing.id) return api.put(`/admin/api-model-pricings/${pricing.id}`, pricing);
return api.post('/admin/api-model-pricings', pricing);
}
export async function deleteApiModelPricing(id: string): Promise<void> {
await api.delete(`/admin/api-model-pricings/${id}`);
}
// === API Key 管理 ===
export async function getApiKeys(params?: {
skip?: number;
limit?: number;
companyName?: string;
isActive?: boolean;
}): Promise<any> {
const query = new URLSearchParams();
if (params?.skip !== undefined) query.set('skip', String(params.skip));
if (params?.limit !== undefined) query.set('limit', String(params.limit));
if (params?.companyName) query.set('company_name', params.companyName);
if (params?.isActive !== undefined) query.set('is_active', String(params.isActive));
const qs = query.toString();
return api.get(`/admin/api-keys${qs ? '?' + qs : ''}`);
}
export async function getApiKeyDetail(id: string): Promise<any> {
return api.get(`/admin/api-keys/${id}`);
}
export async function createApiKey(data: any): Promise<any> {
return api.post('/admin/api-keys', data);
}
export async function updateApiKey(id: string, data: any): Promise<any> {
return api.put(`/admin/api-keys/${id}`, data);
}
export async function deleteApiKey(id: string): Promise<void> {
await api.delete(`/admin/api-keys/${id}`);
}
export async function getApiKeyUsage(id: string, days?: number, page?: number, pageSize?: number): Promise<any> {
const params = new URLSearchParams();
if (days) params.set('days', String(days));
if (page) params.set('page', String(page));
if (pageSize) params.set('page_size', String(pageSize));
const qs = params.toString() ? `?${params.toString()}` : '';
return api.get(`/admin/api-keys/${id}/usage${qs}`);
}
export async function adjustApiKeyQuota(
id: string,
data: {
action: 'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle';
quotaLimitDelta?: number;
quotaLimit?: number | null;
quotaCycle?: string | null;
reason?: string | null;
},
): Promise<any> {
return api.post(`/admin/api-keys/${id}/quota-adjust`, {
action: data.action,
quota_limit_delta: data.quotaLimitDelta,
quota_limit: data.quotaLimit,
quota_cycle: data.quotaCycle,
reason: data.reason,
});
}
export async function getApiKeyUpscaleConfig(id: string): Promise<any> {
return api.get(`/admin/api-keys/${id}/upscale`);
}
export async function saveApiKeyUpscaleConfig(id: string, data: any): Promise<any> {
return api.put(`/admin/api-keys/${id}/upscale`, data);
}
// === V3 虚拟素材库配额 ===
export async function getApiKeyVpV3Quota(id: string): Promise<any> {
return api.get(`/admin/api-keys/${id}/vp-v3-quota`);
}
export async function saveApiKeyVpV3Quota(id: string, data: { projectLimit: number; assetLimit: number; storageMbLimit: number; remark?: string | null }): Promise<any> {
return api.post(`/admin/api-keys/${id}/vp-v3-quota`, {
project_limit: data.projectLimit,
asset_limit: data.assetLimit,
storage_mb_limit: data.storageMbLimit,
remark: data.remark,
});
}
export async function revealApiKey(id: string): Promise<any> {
return api.get(`/admin/api-keys/${id}/reveal`);
}
// === 整体消耗列表 ===
export async function getApiUsageAll(params?: {
skip?: number;
limit?: number;
apiKeyId?: string;
genType?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<any> {
const query = new URLSearchParams();
if (params?.skip !== undefined) query.set('skip', String(params.skip));
if (params?.limit !== undefined) query.set('limit', String(params.limit));
if (params?.apiKeyId) query.set('api_key_id', params.apiKeyId);
if (params?.genType) query.set('gen_type', params.genType);
if (params?.status) query.set('status', params.status);
if (params?.startDate) query.set('start_date', params.startDate);
if (params?.endDate) query.set('end_date', params.endDate);
const qs = query.toString();
return api.get(`/admin/api-keys/usage/all${qs ? '?' + qs : ''}`);
}
export async function getPaymentConfigs(): Promise<any[]> {
return api.get('/admin/payment-configs');
}
@@ -366,40 +640,56 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
const q = new URLSearchParams();
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
if (params?.status) q.set('status', params.status); if (params?.startDate) q.set('start_date', params.startDate); if (params?.endDate) q.set('end_date', params.endDate);
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: string; orderSource?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method);
if (params?.status) qs.set('status', params.status);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.method) qs.set('payment_method', params.method); if (params?.orderSource) qs.set('order_source', params.orderSource); if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone); if (params?.startDate) qs.set('start_date', params.startDate); if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
// 退款接口后端仍保留用于兼容旧调用,但当前版本固定返回“暂未开放订单退款”。
export async function refundPaymentOrder(orderNo: string): Promise<void> { await api.post(`/admin/payment-orders/${orderNo}/refund`); }
// ── Invoice Management ───────────────────────────────────
export async function getAdminInvoices(params?: {
page?: number;
pageSize?: number;
status?: string;
phone?: string;
startDate?: string;
endDate?: string;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
return api.get(`/admin/invoices?${qs.toString()}`);
}
export async function getAdminInvoiceDetail(id: string): Promise<any> {
return api.get(`/admin/invoices/${id}`);
}
export async function updateInvoiceStatus(id: string, data: {
status: 'success' | 'failed';
failureReason?: string;
}): Promise<void> {
await api.put(`/admin/invoices/${id}/status`, {
status: data.status,
failure_reason: data.failureReason,
});
}
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
@@ -479,8 +769,16 @@ export async function deleteRechargePackage(id: string): Promise<void> {
// ── Operation Logs ──────────────────────────────────────
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
const q = page ? `?page=${page}` : '';
export async function getOperationLogs(params?: {
page?: number;
pageSize?: number;
action?: string;
}): Promise<{ total: number; items: any[] }> {
const sp = new URLSearchParams();
if (params?.page) sp.set('page', String(params.page));
if (params?.pageSize) sp.set('page_size', String(params.pageSize));
if (params?.action) sp.set('action', params.action);
const q = sp.toString() ? `?${sp.toString()}` : '';
return api.get(`/admin/operation-logs${q}`);
}
@@ -582,28 +880,30 @@ export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
// ── Generation Records (Admin) ─────────────────────────────
export async function getAdminGenerationRecords(params?: {
userId?: string; status?: string; page?: number; pageSize?: number;
userId?: string;
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}): Promise<{ total: number; items: any[] }> {
const q = new URLSearchParams();
if (params?.userId) q.set('user_id', params.userId);
if (params?.status) q.set('status', params.status);
if (params?.engineId) q.set('engine_id', params.engineId);
if (params?.includeMediaReferences !== undefined) {
q.set('include_media_references', String(params.includeMediaReferences));
}
if (params?.startDate) q.set('start_date', params.startDate);
if (params?.endDate) q.set('end_date', params.endDate);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString();
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
}
export async function adminUpdateGenerationStatus(
recordId: string, status: string, videoUrl?: string
): Promise<void> {
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
}
export async function adminGenerateVideo(
recordId: string, aspectRatio: string, resolution: string, image_size: string
): Promise<void> {
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution, image_size });
}
// ── Generation AI Engines (Admin) ─────────────────────────────
@@ -649,8 +949,13 @@ export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryP
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
}
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
export async function getAdminHotOpeningTaskDetail(
projectId: string,
flowVersion: 'v1' | 'v2',
): Promise<ReplicationProjectDetailOut> {
return flowVersion === 'v2'
? api.get<ReplicationProjectDetailOut>(`/v2/hot-opening-replications/tasks/${projectId}`)
: api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
}
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
@@ -689,8 +994,13 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
}
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
export async function getAdminShotProjectDetail(
projectId: string,
flowVersion: 'v1' | 'v2',
): Promise<ReplicationProjectDetailOut> {
return flowVersion === 'v2'
? api.get<ReplicationProjectDetailOut>(`/v2/shot-replications/projects/${projectId}`)
: api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
}
@@ -1047,3 +1357,49 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
}
// ── Dynamic Credit Products ───────────────────────────────
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
return { ...raw, renewalEnabled: raw.renewalEnabled === true, tierRank: raw.tierRank == null ? null : Number(raw.tierRank), monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
firstPurchasePrice: Number(raw.firstPurchasePrice || 0), regularPrice: Number(raw.regularPrice || 0), activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
price: Number(raw.price || 0), grantCredits: Number(raw.grantCredits || 0), sortOrder: Number(raw.sortOrder || 0), isActive: raw.isActive === true, isDeleted: raw.isDeleted === true };
}
export async function getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).map(normalizeCreditProduct);
}
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload)); }
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload)); }
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/renewal`, { renewal_enabled: renewalEnabled })); }
export async function setCreditProductStatus(id: string, isActive: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/status`, { is_active: isActive })); }
export async function softDeleteCreditProduct(id: string): Promise<void> { await api.delete(`/admin/credit-management/products/${id}`); }
export async function getAdminUserCreditSummary(userId: string): Promise<any> { return api.get(`/admin/credit-management/users/${userId}/summary`); }
export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise<any[]> { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (status) params.set('status', status); return api.get(`/admin/credit-management/users/${userId}/balances?${params.toString()}`); }
export async function getAdminUserSubscriptions(userId: string): Promise<any[]> { return api.get(`/admin/credit-management/users/${userId}/subscriptions`); }
export async function createAdminOfflineSubscription(userId: string, payload: { productId: string; quantity: number; paymentMethod: 'bank_transfer' | 'cash' | 'other'; actualPaidAmount?: number; offlineTradeNo?: string; offlinePaymentDetail?: string; remark?: string; }): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/offline-subscriptions`, { product_id: payload.productId, quantity: payload.quantity, payment_method: payload.paymentMethod, actual_paid_amount: payload.actualPaidAmount, offline_trade_no: payload.offlineTradeNo || null, offline_payment_detail: payload.offlinePaymentDetail || null, remark: payload.remark || null });
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/grant`, payload); }
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); }
// ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies');
}
export async function createLlmBillingPolicy(payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.post('/admin/llm-billing/policies', payload);
}
export async function updateLlmBillingPolicy(id: string, payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.put(`/admin/llm-billing/policies/${id}`, payload);
}
export async function getLlmBillingExecutions(params?: { page?: number; pageSize?: number; sceneCode?: string; status?: string; userId?: string }): Promise<{ items: import('../types').LlmBillingExecution[]; total: number }> {
const query = new URLSearchParams();
query.set('page', String(params?.page || 1));
query.set('page_size', String(params?.pageSize || 20));
if (params?.sceneCode) query.set('scene_code', params.sceneCode);
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('user_id', params.userId);
return api.get(`/admin/llm-billing/executions?${query.toString()}`);
}
@@ -0,0 +1,192 @@
import React, { useState } from 'react';
import {
Modal, Radio, InputNumber, Input, Select, Space, Typography, Tag, Divider, message,
} from 'antd';
import { adjustApiKeyQuota } from '../api';
interface QuotaAdjustModalProps {
open: boolean;
keyId: string;
companyName: string;
quotaLimit: number | null;
quotaUsed: number;
quotaCycle: string | null;
onCancel: () => void;
onSuccess: () => void;
}
const QuotaAdjustModal: React.FC<QuotaAdjustModalProps> = ({
open, keyId, companyName, quotaLimit, quotaUsed, quotaCycle, onCancel, onSuccess,
}) => {
const [action, setAction] = useState<'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'>('adjust');
const [delta, setDelta] = useState<number>(0);
const [newLimit, setNewLimit] = useState<number | null>(quotaLimit);
const [newCycle, setNewCycle] = useState<string | null>(quotaCycle);
const [reason, setReason] = useState<string>('');
const [loading, setLoading] = useState(false);
const cycleLabel = (cycle: string | null) => {
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
return cycle ? map[cycle] || cycle : '无限';
};
const handleOk = async () => {
setLoading(true);
try {
const payload: any = { action, reason: reason || undefined };
if (action === 'adjust') payload.quotaLimitDelta = delta;
if (action === 'set_limit') payload.quotaLimit = newLimit;
if (action === 'change_cycle') payload.quotaCycle = newCycle;
await adjustApiKeyQuota(keyId, payload);
message.success('配额调整成功');
onSuccess();
} catch (e: any) {
message.error(e?.response?.data?.detail || '调整失败');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
setAction('adjust');
setDelta(0);
setNewLimit(quotaLimit);
setNewCycle(quotaCycle);
setReason('');
onCancel();
};
// 预览计算
const previewLimit = action === 'adjust'
? round((quotaLimit || 0) + delta)
: action === 'set_limit'
? newLimit
: quotaLimit;
function round(n: number) {
return Math.round(n * 100) / 100;
}
return (
<Modal
title="调整配额"
open={open}
onOk={handleOk}
onCancel={handleCancel}
okText="确认调整"
cancelText="取消"
confirmLoading={loading}
width={480}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text strong>{companyName}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<Tag color="blue"> {quotaUsed.toFixed(2)} </Tag>
<Tag color="green"> {quotaLimit != null ? `${quotaLimit.toFixed(2)}` : '无限'}</Tag>
<Tag color="purple">{cycleLabel(quotaCycle)}</Tag>
</div>
<Divider style={{ margin: '8px 0' }} />
<Radio.Group value={action} onChange={e => setAction(e.target.value)} style={{ width: '100%' }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Radio value="adjust">
<Space>
<Typography.Text></Typography.Text>
{action === 'adjust' && (
<InputNumber
min={0}
step={10}
value={delta}
onChange={v => setDelta(v || 0)}
addonAfter="元"
style={{ width: 160 }}
/>
)}
</Space>
</Radio>
<Radio value="reset_usage">
<Space>
<Typography.Text></Typography.Text>
{action === 'reset_usage' && (
<Typography.Text type="secondary">
{quotaUsed.toFixed(2)} 0.00
</Typography.Text>
)}
</Space>
</Radio>
<Radio value="set_limit">
<Space>
<Typography.Text></Typography.Text>
{action === 'set_limit' && (
<>
<InputNumber
min={0}
step={10}
value={newLimit}
onChange={setNewLimit}
addonAfter="元"
placeholder="留空=无限"
style={{ width: 160 }}
/>
<Typography.Text type="secondary">
{quotaLimit != null ? `${quotaLimit.toFixed(2)}` : '无限'}
</Typography.Text>
</>
)}
</Space>
</Radio>
<Radio value="change_cycle">
<Space>
<Typography.Text></Typography.Text>
{action === 'change_cycle' && (
<Select
value={newCycle}
onChange={setNewCycle}
allowClear
placeholder="选择周期"
style={{ width: 140 }}
options={[
{ label: '每日', value: 'daily' },
{ label: '每月', value: 'monthly' },
{ label: '一次性', value: 'one_time' },
{ label: '无限', value: null },
]}
/>
)}
</Space>
</Radio>
</Space>
</Radio.Group>
{action === 'adjust' && delta > 0 && (
<div style={{ padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 13 }}>
<strong style={{ color: '#1677ff' }}>{previewLimit != null ? `${previewLimit.toFixed(2)}` : '无限'}</strong>
</div>
)}
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Input.TextArea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="请输入调整原因..."
rows={2}
maxLength={500}
style={{ marginTop: 4 }}
/>
</div>
</Space>
</Modal>
);
};
export default QuotaAdjustModal;
@@ -0,0 +1,59 @@
import React from 'react';
import { Empty, Spin, Tag, Typography } from 'antd';
import { PlayCircleFilled } from '@ant-design/icons';
import type { GenerationAITaskOut } from '../../types';
import { resolveGenerationUiState } from '../../utils/generationTaskStatus';
interface Props {
task: GenerationAITaskOut;
resolveUrl: (url?: string | null) => string;
onPreview: (url: string, type: 'image' | 'video', title: string) => void;
}
const spanByCount = (count: number, index: number): number => {
if (count <= 1) return 6;
if (count === 2 || count === 4) return 3;
if (count === 3) return index < 2 ? 3 : 6;
return index < 3 ? 2 : 3;
};
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, resolveUrl, onPreview }) => {
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
const sortedChildren = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
const items: GenerationAITaskOut[] = sortedChildren.length
? sortedChildren
: (count > 1
? Array.from({ length: count }, (_, index) => ({ ...task, id: `${task.id}-${index + 1}`, generationIndex: index + 1, childItems: [] }))
: [task]);
return (
<div style={{ width: '100%', height: 430, display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0,1fr))', gridAutoRows: 'minmax(0,1fr)', gap: items.length > 1 ? 8 : 0 }}>
{items.map((item, index) => {
const uiState = resolveGenerationUiState(item);
const isVideo = item.genType === 'video';
const resultUrl = resolveUrl(isVideo ? item.videoUrl : item.imageUrl);
const coverUrl = resolveUrl(item.videoCoverUrl);
const active = uiState.isActive;
return (
<div key={item.id} style={{ gridColumn: `span ${spanByCount(items.length, index)}`, minWidth: 0, minHeight: 0, border: '1px solid #edf0f5', borderRadius: 10, overflow: 'hidden', position: 'relative', background: '#f8f9fc' }}>
{resultUrl && uiState.isSuccess ? (
<button type="button" onClick={() => onPreview(resultUrl, isVideo ? 'video' : 'image', `生成结果 ${item.generationIndex || index + 1}`)} style={{ width: '100%', height: '100%', padding: 0, border: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}>
{isVideo ? (coverUrl ? <img src={coverUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : <video src={resultUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />) : <img src={resultUrl} alt="生成图片" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', color: '#fff', fontSize: 38, filter: 'drop-shadow(0 3px 8px rgba(0,0,0,.35))' }} /> : null}
</button>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9, padding: 12, textAlign: 'center' }}>
{active ? <Spin size="small" /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={null} />}
<Tag color={uiState.color}>{uiState.label}</Tag>
{item.errorMessage && !active ? <Typography.Text type="danger" style={{ fontSize: 11 }}>{item.errorMessage}</Typography.Text> : null}
</div>
)}
{items.length > 1 ? <span style={{ position: 'absolute', top: 6, left: 6, padding: '1px 7px', borderRadius: 10, color: '#fff', background: 'rgba(17,24,39,.58)', fontSize: 11 }}>#{item.generationIndex || index + 1}</span> : null}
</div>
);
})}
</div>
);
};
export default GenerationTaskResourceGrid;
+760
View File
@@ -0,0 +1,760 @@
import React, { useEffect, useState } from 'react';
import dayjs from 'dayjs';
import {
Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, DollarOutlined,
} from '@ant-design/icons';
import QuotaAdjustModal from '../components/QuotaAdjustModal';
import {
getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig,
getApiKeyVpV3Quota, saveApiKeyVpV3Quota, getOperationLogs,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
interface EngineOption {
id: string;
name: string;
modelName: string;
genType: 'video' | 'image';
}
interface ApiKey {
id: string;
companyName: string;
apiKeyPrefix: string;
description: string | null;
callableModels?: Array<{ engineId: string; engineType: string; modelName: string }>;
quotaLimit: number | null;
quotaCycle: string | null;
quotaUsed: number;
validFrom: string | null;
validUntil: string | null;
maxConcurrentVideoTasks: number | null;
isActive: boolean;
lastUsedAt: string | null;
createdAt: string;
}
interface UpscaleRule {
targetResolution: string;
providerGenerationResolution: string;
processorKey: string;
}
const AdminApiKeys: React.FC = () => {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [modal, setModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
const [usageModal, setUsageModal] = useState<{ open: boolean; key: ApiKey | null; usage: any }>({ open: false, key: null, usage: null });
const [quotaModal, setQuotaModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
const [quotaLogs, setQuotaLogs] = useState<{ items: any[]; total: number; page: number; loading: boolean }>({ items: [], total: 0, page: 1, loading: false });
const [form] = Form.useForm();
const [engines, setEngines] = useState<EngineOption[]>([]);
const [upscaleRules, setUpscaleRules] = useState<UpscaleRule[]>([]);
const [upscaleEnabled, setUpscaleEnabled] = useState(false);
const [deleteSource, setDeleteSource] = useState(false);
// V3 虚拟素材库配额(编辑时加载)
const [vpV3Quota, setVpV3Quota] = useState<{
projectLimit: number; assetLimit: number; storageMbLimit: number;
projectUsed: number; assetUsed: number; storageMbUsed: number;
enabled: boolean; remark?: string | null;
}>({
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
enabled: false, remark: null,
});
const load = async () => {
setLoading(true);
try {
const [keysData, enginesData] = await Promise.all([
getApiKeys({ limit: 100 }),
getGenerationAiEngines(),
]);
setKeys(keysData?.items || keysData || []);
setTotal(keysData?.total || (keysData?.length || 0));
const allEngines: EngineOption[] = [
...(enginesData?.engine?.image || []).map((e: any) => ({
id: e.id,
name: e.name || e.modelName,
modelName: e.modelName,
genType: 'image' as const,
})),
...(enginesData?.engine?.video || []).map((e: any) => ({
id: e.id,
name: e.name || e.modelName,
modelName: e.modelName,
genType: 'video' as const,
})),
];
setEngines(allEngines);
} catch {
message.error('加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const openEdit = async (key: ApiKey | null = null) => {
if (key) {
// 将 callableModels 转换为引擎 ID 数组用于 Select
const selectedEngineIds = (key.callableModels || []).map((m: any) => m.engineId || m.engine_id);
form.setFieldsValue({
companyName: key.companyName || '',
description: key.description || '',
quotaLimit: key.quotaLimit || null,
quotaCycle: key.quotaCycle || 'monthly',
validUntil: key.validUntil ? dayjs(key.validUntil) : null,
maxConcurrentVideoTasks: key.maxConcurrentVideoTasks || null,
engineIds: selectedEngineIds,
});
// 并行加载:超分配置 + 虚拟素材库配额
await Promise.all([
loadUpscaleConfig(key.id),
(async () => {
try {
const quota = await getApiKeyVpV3Quota(key.id);
setVpV3Quota({
projectLimit: quota?.projectLimit ?? quota?.project_limit ?? 0,
assetLimit: quota?.assetLimit ?? quota?.asset_limit ?? 0,
storageMbLimit: quota?.storageMbLimit ?? quota?.storage_mb_limit ?? 0,
projectUsed: quota?.projectUsed ?? quota?.project_used ?? 0,
assetUsed: quota?.assetUsed ?? quota?.asset_used ?? 0,
storageMbUsed: quota?.storageMbUsed ?? quota?.storage_mb_used ?? 0,
enabled: !!quota?.enabled,
remark: quota?.remark ?? null,
});
} catch {
setVpV3Quota({
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
enabled: false, remark: null,
});
}
})(),
]);
} else {
form.resetFields();
form.setFieldsValue({ quotaCycle: 'monthly', quotaLimit: 100, engineIds: [] });
setUpscaleEnabled(false);
setDeleteSource(false);
setUpscaleRules([]);
setVpV3Quota({
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
enabled: false, remark: null,
});
}
setModal({ open: true, key });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
// 将选中的引擎 ID 转换为 callableModels 格式
const callableModels = (values.engineIds || []).map((id: string) => {
const engine = engines.find(e => e.id === id);
return {
engineId: id,
engineType: engine?.genType || 'video',
modelName: engine?.modelName || '',
};
});
const payload = {
companyName: values.companyName,
description: values.description || null,
quotaLimit: values.quotaLimit || null,
quotaCycle: values.quotaCycle || null,
validUntil: values.validUntil ? (values.validUntil.toISOString ? values.validUntil.toISOString() : values.validUntil) : null,
maxConcurrentVideoTasks: values.maxConcurrentVideoTasks || null,
callableModels,
};
console.log('API Key payload:', JSON.stringify(payload, null, 2));
if (modal.key?.id) {
await updateApiKey(modal.key.id, payload);
} else {
const result = await createApiKey(payload);
if (result?.apiKey) {
Modal.success({
title: 'API Key 创建成功',
content: (
<div>
<p> API Key</p>
<Typography.Paragraph copyable style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontFamily: 'monospace' }}>
{result.apiKey}
</Typography.Paragraph>
</div>
),
});
}
}
// 保存超分配置
if (modal.key?.id) {
await saveUpscaleConfig(modal.key.id);
// 保存 V3 虚拟素材库配额(编辑模式才需要,因为新建时还没有 id)
try {
await saveApiKeyVpV3Quota(modal.key.id, {
projectLimit: vpV3Quota.projectLimit || 0,
assetLimit: vpV3Quota.assetLimit || 0,
storageMbLimit: vpV3Quota.storageMbLimit || 0,
remark: vpV3Quota.remark ?? null,
});
} catch (qErr: any) {
message.warning(qErr?.response?.data?.detail || '虚拟素材库配额保存失败');
}
}
message.success('保存成功');
setModal({ open: false, key: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error('保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteApiKey(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const handleCopyKey = async (key: ApiKey) => {
try {
const result = await revealApiKey(key.id);
const plainKey: string | undefined = result?.apiKey || result?.data?.apiKey;
if (!plainKey) {
message.error('获取 API Key 失败');
return;
}
// 优先用 Clipboard API,不支持时回退到 execCommand
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(plainKey);
} catch {
fallbackCopy(plainKey);
}
} else {
fallbackCopy(plainKey);
}
message.success('API Key 已复制到剪贴板');
} catch (e: any) {
const msg = e?.response?.data?.detail || '复制失败';
message.error(msg);
}
};
const fallbackCopy = (text: string) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
};
const loadUsageDetail = async (keyId: string, page = 1, pageSize = 20) => {
try {
const usage = await getApiKeyUsage(keyId, 30, page, pageSize);
setUsageModal(prev => ({ ...prev, usage }));
} catch {
message.error('加载使用统计失败');
}
};
const loadQuotaLogs = async (keyId: string, page = 1) => {
setQuotaLogs(prev => ({ ...prev, loading: true }));
try {
// 从 operation_logs 中筛选 quota_adjust:* 且 path 包含该 keyId 的记录
const data = await getOperationLogs({ page, pageSize: 20, action: 'quota_adjust' });
const filtered = (data?.items || []).filter((item: any) => item.path?.includes(keyId));
setQuotaLogs({ items: filtered, total: filtered.length, page, loading: false });
} catch {
message.error('加载配额变更记录失败');
setQuotaLogs(prev => ({ ...prev, loading: false }));
}
};
const viewUsage = async (key: ApiKey) => {
try {
const usage = await getApiKeyUsage(key.id, 30, 1, 20);
setUsageModal({ open: true, key, usage });
loadQuotaLogs(key.id, 1);
} catch {
message.error('加载使用统计失败');
}
};
// ── 超分配置处理 ──
const handleAddUpscaleRule = () => {
setUpscaleRules([...upscaleRules, { targetResolution: '1080p', providerGenerationResolution: '720p', processorKey: 'volc_large_model_v1' }]);
};
const handleRemoveUpscaleRule = (idx: number) => {
setUpscaleRules(upscaleRules.filter((_, i) => i !== idx));
};
const handleUpscaleRuleChange = (idx: number, field: keyof UpscaleRule, value: string) => {
const newRules = [...upscaleRules];
newRules[idx] = { ...newRules[idx], [field]: value };
setUpscaleRules(newRules);
};
const loadUpscaleConfig = async (keyId: string) => {
try {
const config = await getApiKeyUpscaleConfig(keyId);
setUpscaleEnabled(config?.data?.enabled || false);
setDeleteSource(config?.data?.deleteSourceAfterSuccess || false);
setUpscaleRules(config?.data?.rules || []);
} catch {
setUpscaleEnabled(false);
setDeleteSource(false);
setUpscaleRules([]);
}
};
const saveUpscaleConfig = async (keyId: string) => {
try {
await saveApiKeyUpscaleConfig(keyId, {
data: {
enabled: upscaleEnabled,
deleteSourceAfterSuccess: deleteSource,
rules: upscaleRules,
},
});
message.success('超分配置已保存');
} catch {
message.error('保存超分配置失败');
}
};
const cycleLabel = (cycle: string | null) => {
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
return cycle ? map[cycle] || cycle : '无限';
};
const columns: ColumnsType<ApiKey> = [
{ title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true },
{
title: 'api-key',
dataIndex: 'apiKeyPrefix',
width: 180,
render: (v: string, r: ApiKey) => (
<Space size={4}>
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}****</code>
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleCopyKey(r)}></Button>
</Space>
),
},
{
title: '配额(元)',
dataIndex: 'quotaLimit',
width: 130,
render: (_v: number, r: ApiKey) => {
if (!r.quotaLimit) return <Tag></Tag>;
const used = r.quotaUsed || 0;
const limit = r.quotaLimit || 1;
const pct = Math.min(100, Math.round((used / limit) * 100));
return (
<div style={{ width: 110 }}>
<Progress percent={pct} size="small" format={() => `${used.toFixed(1)}/${limit}`} />
</div>
);
},
},
{
title: '周期',
dataIndex: 'quotaCycle',
width: 70,
render: (v: string | null) => <Tag>{cycleLabel(v)}</Tag>,
},
{
title: '状态',
dataIndex: 'isActive',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '有效期',
dataIndex: 'validUntil',
width: 100,
render: (v: string | null) => v ? new Date(v).toLocaleDateString() : '永久',
},
{
title: '最后使用',
dataIndex: 'lastUsedAt',
width: 150,
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 260,
render: (_: any, r: ApiKey) => (
<Space size={0}>
<Button type="link" size="small" icon={<DollarOutlined />} onClick={() => setQuotaModal({ open: true, key: r })}></Button>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => viewUsage(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ApiOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<Typography.Text strong style={{ fontSize: 16 }}>API Key </Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}> Key</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Table columns={columns} dataSource={keys} rowKey="id" loading={loading} pagination={false} scroll={{ x: 1100 }} />
</Card>
{/* 创建/编辑弹窗 */}
<Modal
title={modal.key ? '编辑 API Key' : '创建 API Key'}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, key: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={760}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="companyName" label="公司名称" rules={[{ required: true, message: '请输入公司名称' }]}>
<Input placeholder="公司名称" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea placeholder="备注信息" rows={2} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="quotaLimit" label="配额总额(元)" style={{ flex: 1 }}>
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="留空=无限" />
</Form.Item>
<Form.Item name="quotaCycle" label="配额周期" style={{ flex: 1 }}>
<Select>
<Select.Option value="daily"></Select.Option>
<Select.Option value="monthly"></Select.Option>
<Select.Option value="one_time"></Select.Option>
</Select>
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="validUntil" label="有效期至" style={{ flex: 1 }}>
<DatePicker style={{ width: '100%' }} placeholder="留空=永久" />
</Form.Item>
<Form.Item name="maxConcurrentVideoTasks" label="最大并发视频任务" style={{ flex: 1 }}>
<InputNumber min={1} style={{ width: '100%' }} placeholder="留空=无限" />
</Form.Item>
</div>
<Form.Item name="engineIds" label="可调用模型">
<Select
mode="multiple"
placeholder="选择该 Key 可调用的模型(留空=允许所有已定价模型)"
allowClear
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
options={(engines || []).map(e => ({
label: `[${e.genType === 'video' ? '视频' : '图片'}] ${e.name || e.modelName || e.id}`,
value: e.id,
}))}
notFoundContent={engines.length === 0 ? '暂无可用引擎' : null}
/>
</Form.Item>
</Form>
{/* 超分配置(仅编辑模式显示,新建时没ID) */}
{modal.key?.id && (
<>
<Divider />
<Typography.Text strong style={{ fontSize: 14 }}>🎬 </Typography.Text>
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text></Typography.Text>
<Switch checked={upscaleEnabled} onChange={setUpscaleEnabled} checkedChildren="启用" unCheckedChildren="关闭" />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text></Typography.Text>
<Switch checked={deleteSource} onChange={setDeleteSource} checkedChildren="是" unCheckedChildren="否" />
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(upscaleRules || []).map((rule, idx) => (
<Card key={idx} size="small" style={{ background: '#f8f9fc' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Select
value={rule.targetResolution}
onChange={v => handleUpscaleRuleChange(idx, 'targetResolution', v)}
style={{ width: 100 }}
options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))}
/>
<span></span>
<Select
value={rule.providerGenerationResolution}
onChange={v => handleUpscaleRuleChange(idx, 'providerGenerationResolution', v)}
style={{ width: 100 }}
options={['480p', '720p', '1080p'].map(r => ({ label: r, value: r }))}
/>
<Select
value={rule.processorKey}
onChange={v => handleUpscaleRuleChange(idx, 'processorKey', v)}
style={{ width: 140 }}
options={[
{ label: '本地FFmpeg', value: 'local_ffmpeg_crop_v1' },
{ label: '火山标准版', value: 'volc_standard_v1' },
{ label: '火山专业版', value: 'volc_professional_v1' },
{ label: '火山大模型', value: 'volc_large_model_v1' },
]}
/>
<Popconfirm title="确定删除此规则?" onConfirm={() => handleRemoveUpscaleRule(idx)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</Card>
))}
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={handleAddUpscaleRule}>
</Button>
</div>
</div>
</div>
</>
)}
{/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */}
{modal.key?.id && (
<>
<Divider />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 14 }}>🧩 V3 </Typography.Text>
<Tag color={vpV3Quota.enabled ? 'green' : 'default'}>
{vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
</Tag>
</div>
<div style={{ padding: '12px 16px', backgroundColor: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
0 = API Key 使 &gt; 0
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text strong></Typography.Text>
<Tag color="blue">使 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}</Tag>
</div>
<InputNumber
min={0}
max={10000}
style={{ width: '100%' }}
value={vpV3Quota.projectLimit}
onChange={(v) => setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))}
addonBefore="上限" addonAfter="个"
/>
<Progress
percent={vpV3Quota.projectLimit > 0 ? Math.min(100, Math.round((vpV3Quota.projectUsed || 0) * 100 / (vpV3Quota.projectLimit || 1))) : 0}
size="small"
style={{ marginTop: 6 }}
strokeColor={vpV3Quota.projectLimit > 0 && (vpV3Quota.projectUsed || 0) >= vpV3Quota.projectLimit ? '#ff4d4f' : '#1677ff'}
/>
</div>
<div>
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text strong></Typography.Text>
<Tag color="blue">使 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}</Tag>
</div>
<InputNumber
min={0}
max={1000000}
style={{ width: '100%' }}
value={vpV3Quota.assetLimit}
onChange={(v) => setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))}
addonBefore="上限" addonAfter="张"
/>
<Progress
percent={vpV3Quota.assetLimit > 0 ? Math.min(100, Math.round((vpV3Quota.assetUsed || 0) * 100 / (vpV3Quota.assetLimit || 1))) : 0}
size="small"
style={{ marginTop: 6 }}
strokeColor={vpV3Quota.assetLimit > 0 && (vpV3Quota.assetUsed || 0) >= vpV3Quota.assetLimit ? '#ff4d4f' : '#1677ff'}
/>
</div>
</div>
{vpV3Quota.storageMbUsed > 0 && (
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 12, color: '#475569' }}>
使<strong style={{ color: '#1e40af' }}>{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB</strong>
</div>
)}
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<Input.TextArea
rows={2}
maxLength={500}
placeholder="可选:配额配置说明"
value={vpV3Quota.remark ?? ''}
onChange={(e) => setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))}
style={{ marginTop: 4 }}
/>
</div>
</div>
</>
)}
</Modal>
{/* 使用统计 + 配额变更弹窗 */}
<Modal
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
open={usageModal.open}
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
footer={null} width={720}
>
<Tabs
defaultActiveKey="usage"
items={[
{
key: 'usage',
label: '使用统计',
children: usageModal.usage && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
<Card><Typography.Text type="secondary"></Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
<Card><Typography.Text type="secondary">()</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
<Card><Typography.Text type="secondary"></Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
</div>
<Table
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{
title: '类型', dataIndex: 'genType', width: 70,
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
},
{ title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true },
{ title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' },
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
]}
dataSource={usageModal.usage.items || []}
rowKey="id"
pagination={{
current: usageModal.usage.page || 1,
pageSize: usageModal.usage.pageSize || 20,
total: usageModal.usage.total || 0,
onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20),
showSizeChanger: true,
showTotal: (t) => `${t}`,
size: 'small',
}}
size="small"
/>
</div>
),
},
{
key: 'quota',
label: '配额变更',
children: (
<div>
<Table
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{ title: '管理员', dataIndex: 'username', width: 100, ellipsis: true },
{
title: '操作', dataIndex: 'action', width: 110,
render: (v: string) => {
const sub = v?.split(':')[1] || v;
const label: Record<string, string> = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' };
return <Tag color="blue">{label[sub] || v}</Tag>;
},
},
{
title: '变更详情', dataIndex: 'detail', width: 220,
ellipsis: true,
render: (v: string, row: any) => {
let detail: any = v;
if (typeof v === 'string') {
try { detail = JSON.parse(v); } catch { return v || '-'; }
}
if (!detail || typeof detail !== 'object') return '-';
const parts: string[] = [];
if (detail.old_limit != null || detail.new_limit != null) {
parts.push(`限额: ${detail.old_limit != null ? detail.old_limit.toFixed(2) : '-'}${detail.new_limit != null ? detail.new_limit.toFixed(2) : '无限'}`);
}
if (detail.old_used != null && detail.new_used != null && detail.old_used !== detail.new_used) {
parts.push(`已用: ${detail.old_used.toFixed(2)}${detail.new_used.toFixed(2)}`);
}
if (detail.old_cycle != null || detail.new_cycle != null) {
if (detail.old_cycle !== detail.new_cycle) {
parts.push(`周期: ${cycleLabel(detail.old_cycle)}${cycleLabel(detail.new_cycle)}`);
}
}
return parts.length > 0 ? <span style={{ fontSize: 12 }}>{parts.join(' | ')}</span> : '-';
},
},
{
title: '原因', dataIndex: 'detail', width: 120,
ellipsis: true,
render: (v: string) => {
let detail: any = v;
if (typeof v === 'string') {
try { detail = JSON.parse(v); } catch { /* */ }
}
return detail?.reason || '-';
},
},
]}
dataSource={quotaLogs.items}
rowKey={(r, i) => r.id || r.createdAt || i}
loading={quotaLogs.loading}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</div>
),
},
]}
/>
</Modal>
{/* 配额调整弹窗 */}
<QuotaAdjustModal
open={quotaModal.open}
keyId={quotaModal.key?.id || ''}
companyName={quotaModal.key?.companyName || ''}
quotaLimit={quotaModal.key?.quotaLimit ?? null}
quotaUsed={quotaModal.key?.quotaUsed || 0}
quotaCycle={quotaModal.key?.quotaCycle || null}
onCancel={() => setQuotaModal({ open: false, key: null })}
onSuccess={() => {
setQuotaModal({ open: false, key: null });
load();
}}
/>
</Space>
);
};
export default AdminApiKeys;
@@ -0,0 +1,297 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined, EditOutlined, DeleteOutlined, DollarOutlined,
} from '@ant-design/icons';
import {
getApiModelPricings, saveApiModelPricing, deleteApiModelPricing, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
type PricingGenType = 'image' | 'video';
interface ApiModelPricing {
id: string;
modelConfigId: string;
genType: PricingGenType | string;
resolution: string;
priceRatio: number;
basePrice: number;
perSecondPrice: number;
inputVideoRatio: number;
inputVideoBasePrice: number;
inputVideoPerSecondPrice: number;
inputImageRatio: number;
inputImageBasePrice: number;
inputImagePerImagePrice: number;
}
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
const AdminApiModelPricings: React.FC = () => {
const [pricings, setPricings] = useState<ApiModelPricing[]>([]);
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; pricing: ApiModelPricing | null }>({ open: false, pricing: null });
const [form] = Form.useForm();
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [pricingData, enginesData] = await Promise.all([
getApiModelPricings(),
getGenerationAiEngines(),
]);
setPricings(pricingData);
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
...engine,
genType: 'image' as const,
}));
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
...engine,
genType: 'video' as const,
}));
setEngines([...imageEngines, ...videoEngines]);
} catch {
message.error('加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const filteredEngines = engines.filter(e => e.genType === genType);
const selectedEngine = engines.find(e => e.id === selectedEngineId);
const resolutions: string[] = genType === 'video'
? (selectedEngine?.supportedResolutions?.length ? selectedEngine.supportedResolutions : DEFAULT_VIDEO_RESOLUTIONS)
: (selectedEngine?.supportedSizes?.length ? Object.keys(selectedEngine.supportedSizes) : DEFAULT_IMAGE_SIZES);
const openEdit = (pricing: ApiModelPricing | null = null) => {
if (pricing) {
form.setFieldsValue({
modelConfigId: pricing.modelConfigId,
genType: pricing.genType,
resolution: pricing.resolution,
priceRatio: pricing.priceRatio,
basePrice: pricing.basePrice,
perSecondPrice: pricing.perSecondPrice,
inputVideoRatio: pricing.inputVideoRatio,
inputVideoBasePrice: pricing.inputVideoBasePrice,
inputVideoPerSecondPrice: pricing.inputVideoPerSecondPrice,
inputImageRatio: pricing.inputImageRatio,
inputImageBasePrice: pricing.inputImageBasePrice,
inputImagePerImagePrice: pricing.inputImagePerImagePrice,
});
} else {
form.resetFields();
form.setFieldsValue({
genType: 'video',
priceRatio: 1.0,
basePrice: 0.0,
perSecondPrice: 0.00,
inputVideoRatio: 1.0,
inputVideoBasePrice: 0,
inputVideoPerSecondPrice: 0,
inputImageRatio: 1.0,
inputImageBasePrice: 0,
inputImagePerImagePrice: 0,
});
}
setModal({ open: true, pricing });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
...(modal.pricing?.id ? { id: modal.pricing.id } : {}),
modelConfigId: values.modelConfigId,
genType: values.genType,
resolution: values.resolution,
priceRatio: values.priceRatio,
basePrice: values.basePrice,
perSecondPrice: values.perSecondPrice || 0,
inputVideoRatio: values.inputVideoRatio || 1.0,
inputVideoBasePrice: values.inputVideoBasePrice || 0,
inputVideoPerSecondPrice: values.inputVideoPerSecondPrice || 0,
inputImageRatio: values.inputImageRatio || 1.0,
inputImageBasePrice: values.inputImageBasePrice || 0,
inputImagePerImagePrice: values.inputImagePerImagePrice || 0,
};
await saveApiModelPricing(payload);
message.success('保存成功');
setModal({ open: false, pricing: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error('保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteApiModelPricing(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const columns: ColumnsType<ApiModelPricing> = [
{
title: '类型',
dataIndex: 'genType',
width: 80,
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
},
{
title: '引擎',
dataIndex: 'modelConfigId',
width: 160,
render: (v: string) => {
const engine = engines.find(e => e.id === v);
return engine?.name || v;
},
},
{
title: '分辨率',
dataIndex: 'resolution',
width: 80,
},
{
title: '价格系数',
dataIndex: 'priceRatio',
width: 90,
render: (v: number) => <span style={{ color: v >= 2 ? '#f5222d' : v >= 1.5 ? '#faad14' : '#52c41a' }}>{v}</span>,
},
{
title: '基础价格(元)',
dataIndex: 'basePrice',
width: 110,
},
{
title: '每秒价格(元)',
dataIndex: 'perSecondPrice',
width: 120,
},
{
title: '传入视频(元)/每秒',
dataIndex: 'inputVideoBasePrice',
width: 120,
},
{
title: '传入图片(元)/每张',
dataIndex: 'inputImageBasePrice',
width: 120,
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 150,
render: (_: any, r: ApiModelPricing) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<DollarOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<Typography.Text strong style={{ fontSize: 16 }}>API </Typography.Text>
<Tag color="purple">{pricings.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}></Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Table
columns={columns}
dataSource={pricings}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
/>
</Card>
<Modal
title={modal.pricing ? '编辑价格' : '添加价格'}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, pricing: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="genType" label="引擎类型" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select onChange={() => { form.setFieldsValue({ modelConfigId: undefined, resolution: undefined }); }}>
<Select.Option value="video"></Select.Option>
<Select.Option value="image"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select placeholder="选择引擎" showSearch optionFilterProp="label">
{filteredEngines.map(e => (
<Select.Option key={e.id} value={e.id} label={e.name}>{e.name}</Select.Option>
))}
</Select>
</Form.Item>
</div>
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
<Select placeholder="选择分辨率">
{resolutions.map(r => (
<Select.Option key={r} value={r}>{r}</Select.Option>
))}
</Select>
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priceRatio" label="价格系数" rules={[{ required: true }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="basePrice" label="基础价格(元)" rules={[{ required: true }]} style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
</Form.Item>
</div>
{genType === 'video' && (
<Form.Item name="perSecondPrice" label="每秒价格(元)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
<Form.Item name="inputVideoBasePrice" label="传入视频(元)/每秒" style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="inputImageBasePrice" label="传入图片(元)/每张" style={{ flex: 1 }}>
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</Space>
);
};
export default AdminApiModelPricings;
+258
View File
@@ -0,0 +1,258 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
} from '@ant-design/icons';
import { getApiUsageAll } from '../api';
import dayjs from 'dayjs';
interface UsageItem {
id: string;
apiKeyId: string;
companyName: string;
apiKeyPrefix: string | null;
taskId: string | null;
requestType: string;
modelName: string;
genType: string;
creditsCost: number;
tokensUsed: number;
requestDurationMs: number;
duration: number | null;
resolution: string | null;
status: string;
errorMessage: string | null;
errorCode: string | null;
createdAt: string | null;
}
const AdminApiUsage: React.FC = () => {
const [items, setItems] = useState<UsageItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [searchText, setSearchText] = useState('');
const [filterGenType, setFilterGenType] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const load = useCallback(async () => {
setLoading(true);
try {
const params: any = {
skip: (page - 1) * pageSize,
limit: pageSize,
};
if (filterGenType) params.genType = filterGenType;
if (filterStatus) params.status = filterStatus;
if (dateRange[0]) params.startDate = dateRange[0].startOf('day').toISOString();
if (dateRange[1]) params.endDate = dateRange[1].endOf('day').toISOString();
if (searchText.trim()) params.search = searchText.trim();
const data = await getApiUsageAll(params);
setItems(data?.items || []);
setTotal(data?.total || 0);
} catch {
message.error('加载失败');
} finally {
setLoading(false);
}
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
useEffect(() => { load(); }, [load]);
const handleSearch = () => {
setPage(1);
load();
};
// 导出 CSV
const handleExport = () => {
const headers = ['时间', '公司', 'api-key', '类型', '模型', '时长(秒)', '分辨率', '消耗(元)', 'Token', '耗时(ms)', '状态', '错误信息'];
const rows = items.map(item => [
item.createdAt ? new Date(item.createdAt).toLocaleString() : '',
item.companyName || '',
item.apiKeyPrefix || '',
item.genType === 'video' ? '视频' : '图片',
item.modelName || '',
item.duration || '',
item.resolution || '',
(item.creditsCost || 0).toFixed(2),
item.tokensUsed || '',
item.requestDurationMs || '',
item.status === 'success' ? '成功' : '失败',
item.errorMessage || '',
]);
const csvContent = [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const BOM = '';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `api_usage_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('导出成功');
};
const columns = [
{
title: '时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => v ? new Date(v).toLocaleString() : '-',
},
{
title: '公司',
dataIndex: 'companyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: 'api-key',
dataIndex: 'apiKeyPrefix',
width: 110,
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
},
{
title: '类型',
dataIndex: 'genType',
width: 70,
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
},
{
title: '模型',
dataIndex: 'modelName',
width: 160,
ellipsis: true,
},
{
title: '消耗(元)',
dataIndex: 'creditsCost',
width: 90,
render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}</span>,
},
{
title: 'Token',
dataIndex: 'tokensUsed',
width: 80,
render: (v: number) => v || '-',
},
{
title: '耗时(ms)',
dataIndex: 'requestDurationMs',
width: 90,
render: (v: number) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>,
},
{
title: '错误信息',
dataIndex: 'errorMessage',
width: 200,
ellipsis: true,
render: (v: string) => v ? <span style={{ color: '#f5222d' }}>{v}</span> : '-',
},
];
// 统计
const totalCost = items.reduce((sum, item) => sum + (item.creditsCost || 0), 0);
const successCount = items.filter(i => i.status === 'success').length;
const failedCount = items.filter(i => i.status === 'failed').length;
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<TableOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<Typography.Text strong style={{ fontSize: 16 }}>API </Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Space>
<Tag color="blue">: {totalCost.toFixed(2)} </Tag>
<Tag color="green">: {successCount}</Tag>
<Tag color="red">: {failedCount}</Tag>
<Button icon={<ExportOutlined />} onClick={handleExport}></Button>
<Button icon={<ReloadOutlined />} onClick={handleSearch}></Button>
</Space>
</div>
</Card>
{/* 筛选栏 */}
<Card variant="outlined" style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Input
placeholder="搜索公司名或 Key 前缀"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onPressEnter={handleSearch}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="类型"
value={filterGenType}
onChange={v => { setFilterGenType(v); setPage(1); }}
allowClear
style={{ width: 100 }}
>
<Select.Option value="video"></Select.Option>
<Select.Option value="image"></Select.Option>
</Select>
<Select
placeholder="状态"
value={filterStatus}
onChange={v => { setFilterStatus(v); setPage(1); }}
allowClear
style={{ width: 100 }}
>
<Select.Option value="success"></Select.Option>
<Select.Option value="failed"></Select.Option>
</Select>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }}
/>
<Button type="primary" onClick={handleSearch}></Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps || 50); },
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1300 }}
/>
</Card>
</Space>
);
};
export default AdminApiUsage;
@@ -0,0 +1,266 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
import dayjs from 'dayjs';
import { BankOutlined, SearchOutlined } from '@ant-design/icons';
import { queryBankTransactions, listBankAccounts } from '../api';
import { formatDate } from '../utils/formatDate';
import type { BankAccount } from '../types';
const { RangePicker } = DatePicker;
interface TransactionRecord {
id?: string;
accountId?: string;
accountNo?: string;
transactionNo?: string;
transactionTime?: string;
transactionAmount?: string;
balanceDirection?: string;
balanceAfter?: string;
counterpartyName?: string;
counterpartyAccount?: string;
counterpartyBank?: string;
remark?: string;
digestCode?: string;
syncBatch?: string;
isSynced?: boolean;
createdAt?: string;
}
const AdminBankTransactions: React.FC = () => {
const [data, setData] = useState<TransactionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 银行账户
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
// 搜索条件
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [dcFlag, setDcFlag] = useState<number | undefined>(undefined);
useEffect(() => {
loadAccounts();
}, []);
const loadAccounts = async () => {
try {
const res = await listBankAccounts();
const items = res.items || [];
setAccounts(items);
// 默认选中默认账户(或第一个账户)
const defaultAccount = items.find(a => a.isDefault) || items[0];
if (defaultAccount) {
setSelectedAccountId(defaultAccount.id);
}
// 默认日期范围:今天
const today = new Date().toISOString().slice(0, 10);
setDateRange([today, today]);
} catch (err: any) {
message.error(err?.message || '加载银行账户失败');
}
};
const fetchData = useCallback(async () => {
if (!selectedAccountId || !dateRange[0] || !dateRange[1]) return;
setLoading(true);
try {
const res = await queryBankTransactions({
accountId: selectedAccountId,
startDate: dateRange[0],
endDate: dateRange[1],
dcFlag,
page,
pageSize,
});
setData(res.items || []);
setTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '查询失败');
} finally {
setLoading(false);
}
}, [selectedAccountId, dateRange, dcFlag, page, pageSize]);
useEffect(() => {
if (selectedAccountId && dateRange[0] && dateRange[1]) {
fetchData();
}
}, [fetchData]);
const handleSearch = () => {
if (!selectedAccountId) {
message.warning('请选择银行账户');
return;
}
if (!dateRange[0] || !dateRange[1]) {
message.warning('请选择交易时间区间');
return;
}
setPage(1);
fetchData();
};
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
const columns = [
{
title: '交易流水号',
dataIndex: 'transactionNo',
width: 180,
render: (v: string) => v || '-',
},
{
title: '交易时间',
dataIndex: 'transactionTime',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
{
title: '交易金额',
dataIndex: 'transactionAmount',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '借贷方向',
dataIndex: 'balanceDirection',
width: 100,
render: (v: string) => v === 'DR' ? '借/出金' : v === 'CR' ? '贷/入金' : '-',
},
{
title: '交易后余额',
dataIndex: 'balanceAfter',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '对方户名',
dataIndex: 'counterpartyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: '对方账号',
dataIndex: 'counterpartyAccount',
width: 180,
render: (v: string) => v || '-',
},
{
title: '对方开户行',
dataIndex: 'counterpartyBank',
width: 150,
render: (v: string) => v || '-',
},
{
title: '摘要码',
dataIndex: 'digestCode',
width: 100,
render: (v: string) => v || '-',
},
{
title: '备注',
dataIndex: 'remark',
width: 150,
render: (v: string) => v || '-',
},
{
title: '同步时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<BankOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Space wrap style={{ marginBottom: 16 }}>
<Select
placeholder="选择银行账户"
value={selectedAccountId}
onChange={setSelectedAccountId}
style={{ width: 260 }}
allowClear
options={accounts.map(a => ({
value: a.id,
label: `${a.accountNo} - ${a.accountName}(${a.bankName})`,
}))}
/>
<RangePicker
value={dateRange[0] && dateRange[1] ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : undefined}
onChange={(dates, dateStrings) => {
if (Array.isArray(dateStrings)) {
setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
}
}}
/>
<Select
placeholder="借贷方向"
value={dcFlag}
onChange={setDcFlag}
style={{ width: 140 }}
allowClear
>
<Select.Option value={0}>/</Select.Option>
<Select.Option value={1}>/</Select.Option>
</Select>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
{selectedAccount && (
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 8, fontSize: 13, color: '#64748b' }}>
<strong></strong>{selectedAccount.bankName} | {selectedAccount.accountNo} | {selectedAccount.accountName}
</div>
)}
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey={(r) => r.id || `${r.transactionNo || ''}-${Math.random()}`}
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
</div>
);
};
export default AdminBankTransactions;
@@ -30,7 +30,7 @@ const AdminContactRequests: React.FC = () => {
const { user } = useAdminStore();
const fetchData = async () => {
if (!user?.isAdmin) return;
// if (!user?.isAdmin) return;
setLoading(true);
try {
const query = new URLSearchParams();
@@ -39,7 +39,7 @@ const AdminContactRequests: React.FC = () => {
if (isHandledFilter !== null) {
query.set('is_handled', String(isHandledFilter));
}
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/contact/requests?${query.toString()}`);
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/admin/contact-requests?${query.toString()}`);
setData(res.items);
setTotal(res.total);
} catch (err: any) {
@@ -55,7 +55,7 @@ const AdminContactRequests: React.FC = () => {
const handleMarkHandled = async (id: string) => {
try {
await api.put(`/contact/requests/${id}/handle`);
await api.put(`/admin/contact-requests/${id}/handle`);
message.success('已标记为处理');
fetchData();
} catch (err: any) {
@@ -65,7 +65,7 @@ const AdminContactRequests: React.FC = () => {
const handleDelete = async (id: string) => {
try {
await api.delete(`/contact/requests/${id}`);
await api.delete(`/admin/contact-requests/${id}`);
message.success('已删除');
fetchData();
} catch (err: any) {
@@ -0,0 +1,469 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct,
getCreditProducts,
setCreditProductRenewal,
setCreditProductStatus,
softDeleteCreditProduct,
updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
const formatMoney = (value?: number | null): string => {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<CreditProduct | null>(null);
const [open, setOpen] = useState(false);
const [renewalEnabled, setRenewalEnabled] = useState(true);
const [form] = Form.useForm();
const replaceItem = (product: CreditProduct) => {
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
};
const load = async () => {
setLoading(true);
try {
setItems(await getCreditProducts(type));
} catch (error: any) {
message.error(error?.message || '加载积分商品失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [type]);
const openEditor = (item?: CreditProduct) => {
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
setEditing(item || null);
setRenewalEnabled(nextRenewalEnabled);
setOpen(true);
form.resetFields();
if (item) {
form.setFieldsValue({
productCode: item.productCode,
name: item.name,
description: item.description || undefined,
featuresText: (item.features || []).join('\n'),
tierCode: item.tierCode,
tierRank: item.tierRank,
billingCycle: item.billingCycle,
monthlyGrantCredits: item.monthlyGrantCredits,
firstPurchasePrice: item.firstPurchasePrice,
regularPrice: item.regularPrice,
activityPrice: item.activityPrice,
activityRange: item.activityStartAt && item.activityEndAt
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
: undefined,
renewalEnabled: nextRenewalEnabled,
price: item.price,
grantCredits: item.grantCredits,
validityMonths: item.validityMonths ?? 1,
sortOrder: item.sortOrder,
isActive: item.isActive,
});
return;
}
form.setFieldsValue({
productCode: undefined,
name: undefined,
description: undefined,
featuresText: undefined,
isActive: true,
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: true,
validityMonths: 1,
});
};
const save = async () => {
try {
const values = await form.validateFields();
const activityRange = values.activityRange || [];
const payload: Record<string, unknown> = {
name: values.name,
description: values.description || null,
features: String(values.featuresText || '')
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
sort_order: values.sortOrder ?? 0,
};
if (!editing) {
payload.product_code = values.productCode;
payload.product_type = type;
payload.is_active = values.isActive ?? true;
}
if (isSubscriptionType(type)) {
if (!editing) {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
});
}
Object.assign(payload, {
monthly_grant_credits: values.monthlyGrantCredits,
first_purchase_price: values.firstPurchasePrice,
regular_price: values.regularPrice,
activity_price: values.activityPrice ?? null,
activity_start_at: activityRange[0]?.toISOString() || null,
activity_end_at: activityRange[1]?.toISOString() || null,
renewal_enabled: renewalEnabled,
price: values.regularPrice,
});
} else {
Object.assign(payload, {
price: values.price,
grant_credits: values.grantCredits,
validity_months: values.validityMonths,
});
}
let savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (editing && savedProduct.isActive !== (values.isActive === true)) {
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
}
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
if (editing) {
replaceItem(savedProduct);
} else {
setItems((current) => [...current, savedProduct].sort((a, b) => {
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
}));
}
message.success(editing ? '商品已更新' : '商品已创建');
setOpen(false);
setEditing(null);
setRenewalEnabled(true);
form.resetFields();
} catch (error: any) {
if (error?.errorFields) return;
message.error(error?.message || '保存失败');
}
};
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
const previous = product.renewalEnabled === true;
const optimistic = { ...product, renewalEnabled: enabled };
replaceItem(optimistic);
if (editing?.id === product.id) {
setEditing(optimistic);
setRenewalEnabled(enabled);
form.setFieldValue('renewalEnabled', enabled);
}
try {
const savedProduct = await setCreditProductRenewal(product.id, enabled);
if (savedProduct.renewalEnabled !== enabled) {
throw new Error('续费开关保存结果与提交值不一致');
}
replaceItem(savedProduct);
if (editing?.id === product.id) {
setEditing(savedProduct);
setRenewalEnabled(savedProduct.renewalEnabled === true);
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
}
message.success(enabled ? '已开启续费' : '已关闭续费');
} catch (error: any) {
const reverted = { ...product, renewalEnabled: previous };
replaceItem(reverted);
if (editing?.id === product.id) {
setEditing(reverted);
setRenewalEnabled(previous);
form.setFieldValue('renewalEnabled', previous);
}
message.error(error?.message || '更新续费状态失败');
}
};
const toggleStatus = async (row: CreditProduct) => {
try {
const saved = await setCreditProductStatus(row.id, !row.isActive);
replaceItem(saved);
message.success(saved.isActive ? '已上架' : '已下架');
} catch (error: any) {
message.error(error?.message || '商品状态更新失败');
}
};
const removeProduct = async (row: CreditProduct) => {
try {
await softDeleteCreditProduct(row.id);
message.success('商品已软删除,商品编码永久保留且不能恢复');
await load();
} catch (error: any) {
message.error(error?.message || '商品删除失败');
}
};
const columns = useMemo(() => isSubscriptionType(type) ? [
{
title: '套餐',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{
title: '等级',
key: 'tier',
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
},
{
title: '周期',
dataIndex: 'billingCycle',
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
},
{
title: '每月积分',
dataIndex: 'monthlyGrantCredits',
render: (value?: number) => Number(value || 0).toLocaleString(),
},
{
title: '首充价',
dataIndex: 'firstPurchasePrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '原价',
dataIndex: 'regularPrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '活动价',
key: 'activity',
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
? '-'
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
},
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary">-</Typography.Text>
: <Switch
checked={enabled === true}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => void toggleRenewal(row, checked)}
/>,
},
{
title: '状态',
key: 'status',
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
title: '增值包',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items, editing]);
const createButtonLabel = type === 'subscription'
? '新增个人订阅套餐'
: type === 'team_subscription'
? '新增团队订阅套餐'
: '新增积分增值包';
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '个人订阅套餐' },
{ key: 'team_subscription', label: '团队订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
<Modal
open={open}
title={editing ? '编辑积分商品' : '新增积分商品'}
onOk={save}
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
width={760}
destroyOnClose
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item
name="productCode"
label="商品编码"
rules={[{ required: true }]}
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input style={{ width: 260 }} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{isSubscriptionType(type) ? <>
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
disabled={!!editing}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
{ value: 'quarterly', label: '季' },
{ value: 'yearly', label: '年' },
]}
/>
</Form.Item>
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16} wrap>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
>
<Switch
checked={renewalEnabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => {
setRenewalEnabled(checked);
form.setFieldValue('renewalEnabled', checked);
}}
/>
</Form.Item>
</Space>
</> : <Space align="start" size={16}>
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
<Select
style={{ width: 140 }}
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
/>
</Form.Item>
</Space>}
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminCreditProducts;
@@ -3,12 +3,11 @@ import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
deleteCreditRatio, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
@@ -54,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, sysConfigs, enginesData] = await Promise.all([
const [ratioData, enginesData] = await Promise.all([
getCreditRatios(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
@@ -81,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
setRatios(ratioData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
setTextRateConfig({ id: textCfg.id });
}
} catch {
message.error('加载积分比例失败');
} finally {
@@ -171,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
}
};
const handleSaveTextRate = async () => {
if (!textRateConfig) return;
setSavingTextRate(true);
try {
await updateSystemConfig(textRateConfig.id, String(textRate));
message.success('文字积分费率已更新');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSavingTextRate(false);
}
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
@@ -338,40 +316,6 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
ceil(token数 x / 1000)1
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<Space.Compact style={{ width: 160 }}>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: '100%' }}
/>
<Typography.Text></Typography.Text>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1000 token = {textRate} 500 token = {(500 * textRate / 1000).toFixed(4)}
</Typography.Text>
</div>
</Card>
{/* Generation Credit Ratios */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
+213 -18
View File
@@ -3,12 +3,12 @@ import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -17,6 +17,11 @@ const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
totalCharge: 0,
totalHold: 0,
totalRefundReal: 0,
totalHoldRelease: 0,
netConsume: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
@@ -35,9 +40,20 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
};
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实消费', color: 'red' },
refund: { text: '真实退款', color: 'blue' },
pre_deduct: { text: '历史固定预扣', color: 'default' },
hold: { text: '历史预扣占用', color: 'gold' },
hold_release: { text: '历史预扣释放', color: 'green' },
};
const userScopeOptions = [
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
@@ -50,6 +66,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'expire', label: '过期' },
{ value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -85,6 +103,16 @@ const chargeKindOptions = [
{ value: 'team_internal', label: '团队内部转移' },
];
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实消费' },
{ value: 'refund', label: '真实退款' },
{ value: 'pre_deduct', label: '历史固定预扣' },
{ value: 'hold', label: '历史预扣占用' },
{ value: 'hold_release', label: '历史预扣释放' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
@@ -154,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
return {};
}
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
const label = item.tierLabel || item.tierCode || '未知等级';
const code = item.tierCode && item.tierCode !== label ? `${item.tierCode}` : '';
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
return `${label}${code}${rank}`;
}
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
return [
item.productTypeLabel,
item.productName,
subscriptionTierText(item),
item.billingCycleLabel,
].filter(Boolean).join(' · ');
}
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
const range = item.periodValidFrom && item.periodExpiresAt
? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}`
: '-';
return `${item.periodLabel || '周期未知'}${range}`;
}
function semanticAllocationText(record: AdminCreditRecord): string {
return (record.allocations || []).map((item) => {
const tier = item.tierLabel || item.tierCode
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `${item.tierCode}` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
: '未知等级';
const subscription = item.subscriptionNo
? `${item.subscriptionNo}${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
: '非订阅资金';
const period = item.periodLabel
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}${formatDate(item.periodExpiresAt)}` : ''}`
: '无月度周期';
return `${item.creditScopeLabel || '其他资金域'}${item.allocationActionLabel || '资金变动'}${subscription}${period}${n(item.amount)}积分`;
}).join('');
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
@@ -166,11 +232,13 @@ const AdminCreditRecords: React.FC = () => {
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [recordType, setRecordType] = useState('');
const [creditSubject, setCreditSubject] = useState('');
const [mediaType, setMediaType] = useState('');
const [chargeKind, setChargeKind] = useState('');
const [chargeAction, setChargeAction] = useState('');
const [sourceModule, setSourceModule] = useState('');
const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState('');
@@ -182,17 +250,19 @@ const AdminCreditRecords: React.FC = () => {
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
subscriptionNo: subscriptionNoFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined,
chargeAction: chargeAction || undefined,
sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined,
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
const load = async () => {
setLoading(true);
@@ -217,10 +287,12 @@ const AdminCreditRecords: React.FC = () => {
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setSubscriptionNoFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setChargeAction('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
@@ -254,15 +326,24 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join('') || '-' },
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionProductText(item)}`).join('') || '-' },
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionPeriodText(item)}`).join('') || '-' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.sceneNameSnapshot || r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0}` },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
@@ -274,6 +355,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -284,22 +366,24 @@ const AdminCreditRecords: React.FC = () => {
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['订阅实例筛选', subscriptionNoFilter || '不限'],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund],
['总真实消费', exportSummary.totalConsume],
['总真实退款', exportSummary.totalRefund],
['净消耗(总消费 − 总退款,≥ 0)', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume],
['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume],
['视频分析积分', exportSummary.analysisConsume],
['图片消费积分(仅真实消费)', exportSummary.imageConsume],
['视频消费积分(仅真实消费)', exportSummary.videoConsume],
['提词消费积分(仅真实消费)', exportSummary.textConsume],
['视频分析积分(仅真实消费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
@@ -320,13 +404,55 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{
title: '资金构成', key: 'fundingScope', width: 190,
render: (_: any, r: AdminCreditRecord) => (
<Space size={[4, 4]} wrap>
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue"> {n(r.teamAllocationAmount)}</Tag>}
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple"> {n(r.personalAllocationAmount)}</Tag>}
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary"></Typography.Text>}
</Space>
),
},
{
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · {n(item.amount)} </div>
</div>
))}
</div>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '月度周期', key: 'subscriptionPeriods', width: 300,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b' }}>
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}` : '-'}
</div>
</div>
))}
</div>
) : '-',
},
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.sceneNameSnapshot || r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} <div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.llmSuccessCallCount || 0)} / {n(r.llmFailedCallCount || 0)}</div></div> },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
@@ -336,11 +462,50 @@ const AdminCreditRecords: React.FC = () => {
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} />
<div>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}></div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>退</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>退</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
<div>
<div style={{ color: '#6366f1' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
<div>
<div style={{ color: '#94a3b8' }}> / </div>
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
</div>
</Space>
</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
@@ -364,10 +529,12 @@ const AdminCreditRecords: React.FC = () => {
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input placeholder="订阅实例号(PS/TS" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={chargeAction} onChange={(v) => { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
@@ -394,7 +561,35 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2050 }}
expandable={{
rowExpandable: (record) => Boolean(record.allocations?.length),
expandedRowRender: (record) => (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `${a.tierCode}` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ${formatDate(a.periodExpiresAt)}` : '-' },
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ${formatDate(a.expiresAt)}` : '-' },
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
]}
scroll={{ x: 2390 }}
/>
),
}}
scroll={{ x: 3350 }}
/>
</Card>
</div>
+360 -351
View File
@@ -1,14 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
FileTextOutlined,
DollarOutlined,
WalletOutlined,
ArrowUpOutlined,
CalendarOutlined,
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
} from '@ant-design/icons';
import { getAdminStats, getSystemConfigs } from '../api';
import type { AdminStats, SystemConfig } from '../types';
@@ -17,10 +11,25 @@ import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const MODULE_LABELS: Record<string, string> = {
'ai_creation': 'AI创作',
'generation_record': '项目生成',
'hot_opening_replicate': '爆款开头复刻',
'shot_replicate': '拆镜复刻',
'payment': '支付充值',
'admin': '后台管理',
'team': '团队管理',
'unknown': '历史未知',
'other': '其他',
};
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899', '#14b8a6', '#f97316'];
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
const [activeRange, setActiveRange] = useState<string>('today');
const [siteName, setSiteName] = useState<string>('数据概览');
const load = async () => {
@@ -39,377 +48,377 @@ const AdminDashboard: React.FC = () => {
const configs = await getSystemConfigs();
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
if (siteConfig) {
const title = `${siteConfig.value} 管理后台`;
setSiteName(title);
document.title = title;
setSiteName(`${siteConfig.value} 管理后台`);
document.title = `${siteConfig.value} 管理后台`;
}
} catch {
setSiteName('数据概览');
document.title = '数据概览';
}
} catch { /* ignore */ }
};
useEffect(() => {
load();
loadSiteName();
}, []);
useEffect(() => { load(); loadSiteName(); }, []);
const handleToday = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
setStartDate(dates);
loadWithDates(dates);
};
const handleYesterday = () => {
const yesterday = dayjs().subtract(1, 'day');
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
setStartDate(dates);
loadWithDates(dates);
};
const handleWeek = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const handleMonth = () => {
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
setStartDate(dates);
loadWithDates(dates);
};
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
if (range) setActiveRange(range);
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
setLoading(true);
getAdminStats(start, end).then(data => {
setStats(data);
setLoading(false);
}).catch(() => {
setLoading(false);
});
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
};
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()], 'today');
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')], 'yesterday'); };
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()], 'week');
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()], 'month');
const handleDateChange = (dates: any) => {
if (dates) {
setStartDate([dates[0], dates[1]]);
loadWithDates([dates[0], dates[1]]);
}
};
const baseStats = stats ? [
{
title: '用户数量',
value: stats.totalUsers,
lastPeriodValue: stats.lastPeriodUsers,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
prefix: '',
suffix: '位用户',
description: '平台注册用户总数'
},
{
title: '总项目数',
value: stats.totalProjects,
lastPeriodValue: stats.lastPeriodProjects,
icon: <ProjectOutlined />,
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
prefix: '',
suffix: '个项目',
description: '创建的项目总数'
},
{
title: '项目记录',
value: stats.totalRecords,
lastPeriodValue: stats.lastPeriodRecords,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
prefix: '',
suffix: '条记录',
description: '项目记录总数'
},
{
title: '创作记录',
value: stats.totalGenerations,
lastPeriodValue: stats.lastPeriodGenerations,
icon: <PlayCircleOutlined />,
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
prefix: '',
suffix: '次创作',
description: 'AI创作记录总数'
},
] : [];
const financeStats = stats ? [
{
title: '支付宝收入',
value: stats.todayAlipayRevenue,
icon: <WalletOutlined />,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
prefix: '¥',
suffix: '',
description: '支付宝收款',
tag: '支付宝'
},
{
title: '微信收入',
value: stats.todayWechatRevenue,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
prefix: '¥',
suffix: '',
description: '微信收款',
tag: '微信支付'
},
{
title: '总收入',
value: stats.totalRevenue,
lastPeriodValue: stats.lastPeriodRevenue,
icon: <ArrowUpOutlined />,
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
prefix: '¥',
suffix: '',
description: '平台总收入',
tag: '总收入'
},
{
title: '消耗积分',
value: stats.creditsConsumedToday,
lastPeriodValue: stats.lastPeriodCreditsConsumed,
icon: <DollarOutlined />,
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
prefix: '',
suffix: '积分',
description: '用户消耗积分',
tag: '积分消耗'
},
] : [];
const StatCard: React.FC<{
title: string;
value: number;
lastPeriodValue?: number;
icon: React.ReactNode;
gradient: string;
prefix?: string;
suffix?: string;
description?: string;
tag?: string;
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
: null;
const isPositive = change !== null && parseFloat(change) >= 0;
return (
<Card
bordered={false}
loading={loading}
hoverable
style={{
borderRadius: 16,
border: '1px solid rgba(0,0,0,0.04)',
background: '#ffffff',
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
overflow: 'hidden'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 0'
}}>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
color: '#fff',
flexShrink: 0,
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 4
}}>
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
{tag && (
<span style={{
fontSize: 10,
padding: '2px 8px',
borderRadius: 10,
background: 'rgba(99,102,241,0.1)',
color: '#6366f1',
fontWeight: 500
}}>
{tag}
</span>
)}
</div>
<div style={{
fontSize: 28,
fontWeight: 800,
color: '#1e293b',
letterSpacing: -0.5,
marginBottom: 2
}}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</div>
{description && (
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
{description}
</Typography.Text>
)}
{change !== null && lastPeriodValue !== undefined && (
<div style={{
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid #f1f5f9',
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
</Typography.Text>
<span style={{
fontSize: 11,
fontWeight: 500,
color: isPositive ? '#10b981' : '#ef4444',
display: 'flex',
alignItems: 'center',
gap: 2
}}>
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
</span>
</div>
)}
</div>
</div>
</Card>
);
if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
};
return (
<div style={{ padding: 0 }}>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '24px 24px 32px',
borderRadius: 0,
marginBottom: -24,
position: 'relative',
overflow: 'hidden'
}}>
<div style={{
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
background: 'rgba(255,255,255,0.1)',
borderRadius: '50%'
}} />
<div style={{
position: 'absolute',
bottom: -30,
left: -30,
width: 150,
height: 150,
background: 'rgba(255,255,255,0.08)',
borderRadius: '50%'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
{siteName}
</Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
</Typography.Text>
<div>
{/* 日期筛选 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
<Space>
<Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}></Button>
<Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}></Button>
<Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}></Button>
<Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}></Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
<CalendarOutlined style={{ color: '#64748b' }} />
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
</div>
</div>
<div style={{ marginTop: 40 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
<Space>
<Button
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
size="small"
onClick={handleToday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleYesterday}
>
</Button>
<Button
type="default"
size="small"
onClick={handleWeek}
>
</Button>
<Button
type="default"
size="small"
onClick={handleMonth}
>
</Button>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
<DatePicker.RangePicker
value={startDate}
onChange={handleDateChange}
placeholder={['开始日期', '结束日期']}
size="small"
/>
</div>
{/* 核心数据 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{baseStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
<Row gutter={[12, 12]}>
{[
{ title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
].map(s => (
<Col xs={12} sm={8} md={4} key={s.title}>
<CompactStatCard {...s} loading={loading} />
</Col>
))}
</Row>
</div>
<div style={{ marginTop: 24 }}>
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}></Typography.Text>
{/* 图表区域 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
{financeStats.map((s) => (
<Col xs={12} sm={8} lg={6} key={s.title}>
<StatCard {...s} />
</Col>
))}
<Col xs={24} lg={12}>
<ChartCard title="每日积分消耗趋势" loading={loading}>
<LineChart data={stats?.dailyCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="各模块积分占比" loading={loading}>
<ModulePie data={stats?.periodCreditsByModule || []} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="团队积分消耗排行" loading={loading}>
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
</ChartCard>
</Col>
<Col xs={24} lg={12}>
<ChartCard title="模型使用次数" loading={loading}>
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
</ChartCard>
</Col>
</Row>
</div>
{/* 视频参数分布 */}
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}></Typography.Text>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<ChartCard title="分辨率分布" loading={loading}>
<PieBarChart data={stats?.videoResolutionUsage || []} />
</ChartCard>
</Col>
<Col xs={24} lg={8}>
<ChartCard title="画面比例分布" loading={loading}>
<PieBarChart data={stats?.videoRatioUsage || []} />
</ChartCard>
</Col>
<Col xs={24} lg={8}>
<ChartCard title="时长分布" loading={loading}>
<PieBarChart data={stats?.videoDurationUsage || []} />
</ChartCard>
</Col>
</Row>
</div>
</div>
);
};
// ── 图表卡片 ──
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
styles={{ body: { padding: '16px' } }}>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
</div>
<Spin spinning={loading}>{children}</Spin>
</Card>
);
// ── 核心数据小卡片 ──
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
styles={{ body: { padding: '12px 14px' } }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
{icon}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
</div>
</div>
</div>
</Card>
);
// ── 折线图(按日期+模块,固定展示选中日期往前7天)──
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
// 以数据中最新日期为基准,往前推 7 天;不足 7 天按实际天数
if (!data.length) return <EmptyChart />;
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
const maxDate = sortedDates[sortedDates.length - 1];
// 生成 [maxDate-6, maxDate] 共 7 天
const baseDayjs = dayjs(maxDate);
const sevenDays: string[] = [];
for (let i = 6; i >= 0; i--) sevenDays.push(baseDayjs.subtract(i, 'day').format('YYYY-MM-DD'));
const dateMap = new Map<string, number>();
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); });
const maxVal = Math.max(...sevenDays.map(d => dateMap.get(d) || 0), 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
{sevenDays.map(d => {
const val = dateMap.get(d) || 0;
const pct = (val / maxVal) * 100;
return (
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
onMouseEnter={e => {
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
if (tip) { tip.style.opacity = '1'; }
}}
onMouseLeave={e => {
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
if (tip) { tip.style.opacity = '0'; }
}}
>
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
</div>
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
{sevenDays.map(d => (
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
</div>
))}
</div>
</div>
);
};
// ── 模块积分占比(饼图)──
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
const moduleMap = new Map<string, number>();
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
// 计算饼图扇形路径
const size = 160;
const cx = size / 2;
const cy = size / 2;
const r = 68;
let cumAngle = -90; // 从顶部开始
const slices = modules.map(([mod, val], i) => {
const pct = val / total;
const angle = pct * 360;
const startAngle = cumAngle;
cumAngle += angle;
const endAngle = cumAngle;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const largeArc = angle > 180 ? 1 : 0;
const x1 = cx + r * Math.cos(startRad);
const y1 = cy + r * Math.sin(startRad);
const x2 = cx + r * Math.cos(endRad);
const y2 = cy + r * Math.sin(endRad);
const d = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
return { d, color: COLORS[i % COLORS.length], label: MODULE_LABELS[mod] || mod, val, pct };
});
return (
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
<defs>
{slices.map((s, i) => (
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
</linearGradient>
))}
</defs>
{slices.map((s, i) => (
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
onMouseEnter={e => {
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
}}
onMouseLeave={e => {
(e.target as SVGPathElement).style.transform = 'scale(1)';
(e.target as SVGPathElement).style.filter = 'none';
}}
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
/>
))}
<circle cx={cx} cy={cy} r={36} fill="#fff" />
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b"></text>
<text x={cx} y={cy + 12} textAnchor="middle" fontSize={13} fontWeight={700} fill="#1e293b">{total.toFixed(0)}</text>
</svg>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{slices.map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
<span>{s.label}</span>
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
);
};
// ── 横向柱状图(团队/模型)──
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
if (!data.length) return <EmptyChart />;
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
return (
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
{data.map((d, i) => {
const label = d.teamName || d.modelName || '-';
const val = (d as any)[valueKey] || 0;
const pct = (val / maxVal) * 100;
const c1 = COLORS[i % COLORS.length];
const c2 = COLORS[(i + 1) % COLORS.length];
return (
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
</div>
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
</div>
</div>
</div>
);
})}
</div>
);
};
// ── 视频参数分布(紧凑饼图+列表)──
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
if (!data.length) return <EmptyChart />;
// 按模型分组
const modelMap = new Map<string, { label: string; count: number }[]>();
data.forEach(d => {
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
});
const models = Array.from(modelMap.entries());
const total = data.reduce((s, d) => s + d.count, 0) || 1;
return (
<div style={{ height: 220, overflowY: 'auto' }}>
{models.map(([model, items], mi) => {
const modelTotal = items.reduce((s, it) => s + it.count, 0);
return (
<div key={model} style={{ marginBottom: 12 }}>
{/* 模型名称 + 总数 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
<span style={{ fontSize: 10, color: '#94a2b3' }}> {modelTotal} ({((modelTotal / total) * 100).toFixed(1)}%)</span>
</div>
{/* 各参数 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{items.map((item, i) => (
<div key={i} style={{
flex: '0 0 calc(50% - 2px)',
padding: '4px 8px',
background: '#f8fafc',
borderRadius: 6,
border: '1px solid #f1f5f9',
transition: 'all 0.2s',
cursor: 'default',
}}
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
</div>
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
};
const EmptyChart: React.FC = () => (
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
</div>
);
export default AdminDashboard;
@@ -29,9 +29,16 @@ import {
VideoCameraOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api';
import type {
GenerationAiImageEngine,
GenerationAIMediaReference,
GenerationAITaskOut,
GenerationAiVideoEngine,
} from '../types';
import { formatDate } from '../utils/formatDate';
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const { RangePicker } = DatePicker;
@@ -66,27 +73,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {},
};
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
pending: { color: 'default', text: '待处理', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const PIPELINE_STAGE_MAP: Record<string, string> = {
timeout: '任务超时',
queued: '已入队',
preparing: '准备中',
creating_provider_task: '创建任务中',
waiting_remote: '等待生成',
result_ready: '结果就绪',
downloading: '下载中',
done: '完成',
download_failed: '下载失败',
polling: '轮询中',
failed: '失败',
};
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -237,6 +223,9 @@ const AdminGenerationAiRecords: React.FC = () => {
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [reloadKey, setReloadKey] = useState(0);
const [engineListLoading, setEngineListLoading] = useState(false);
const [imageEngines, setImageEngines] = useState<GenerationAiImageEngine[]>([]);
const [videoEngines, setVideoEngines] = useState<GenerationAiVideoEngine[]>([]);
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
@@ -277,6 +266,64 @@ const AdminGenerationAiRecords: React.FC = () => {
load();
}, [load, reloadKey]);
useEffect(() => {
let cancelled = false;
const loadEngineOptions = async () => {
setEngineListLoading(true);
try {
const [images, videos] = await Promise.all([
getImageEngines({ includeDeleted: true }),
getVideoEngines({ includeDeleted: true }),
]);
if (!cancelled) {
setImageEngines(images || []);
setVideoEngines(videos || []);
}
} catch (error: any) {
if (!cancelled) {
message.error(error?.message || '加载模型引擎列表失败');
}
} finally {
if (!cancelled) setEngineListLoading(false);
}
};
void loadEngineOptions();
return () => {
cancelled = true;
};
}, []);
const engineOptions = useMemo(() => {
const toOption = (
engine: GenerationAiImageEngine | GenerationAiVideoEngine,
type: 'image' | 'video',
) => {
const deleted = Boolean(engine.deletedAt);
const typeText = type === 'image' ? '图片' : '视频';
const deletedText = deleted ? '[已删除]' : '';
const detailText = [engine.name, engine.modelName, engine.provider, engine.id]
.filter(Boolean)
.join(' / ');
const label = `[${typeText}]${deletedText} ${detailText}`;
return {
value: engine.id,
label,
searchText: [engine.id, engine.name, engine.modelName, engine.provider, typeText, deleted ? '已删除' : '']
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const merged = [
...imageEngines.map((engine) => toOption(engine, 'image')),
...videoEngines.map((engine) => toOption(engine, 'video')),
];
return Array.from(new Map(merged.map((option) => [option.value, option])).values());
}, [imageEngines, videoEngines]);
useEffect(() => {
if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE);
@@ -425,6 +472,25 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
},
},
{
title: '生成数量', key: 'generationCount', width: 150,
render: (_: any, r: GenerationAITaskOut) => {
const count = Math.max(1, Number(r.generationCount || 1));
if (count === 1) return <Tag>1</Tag>;
const children = r.childItems || [];
const completed = children.filter((item) => resolveGenerationUiState(item).isSuccess).length;
const failed = children.filter((item) => resolveGenerationUiState(item).isFailure).length;
const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length;
return (
<Space size={4} wrap>
<Tag color="purple">{count}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{completed}{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''}
</Typography.Text>
</Space>
);
},
},
{
title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => {
@@ -475,7 +541,7 @@ const AdminGenerationAiRecords: React.FC = () => {
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (r.status !== 'completed') {
if (!resolveGenerationUiState(r).isSuccess) {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
@@ -551,13 +617,14 @@ const AdminGenerationAiRecords: React.FC = () => {
{
title: '状态', dataIndex: 'status', width: 100,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
const state = resolveGenerationUiState({ status: v });
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
},
},
{
title: '阶段', dataIndex: 'pipelineStage', width: 120,
render: (v: string) => <Tag color="blue">{PIPELINE_STAGE_MAP[v] || v || '-'}</Tag>,
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
},
{
title: '时间', key: 'time', width: 170,
@@ -579,10 +646,10 @@ const AdminGenerationAiRecords: React.FC = () => {
], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
const renderResultImage = () => {
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -722,7 +789,7 @@ const AdminGenerationAiRecords: React.FC = () => {
};
const renderResultVideo = () => {
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -947,21 +1014,17 @@ const AdminGenerationAiRecords: React.FC = () => {
<Select
allowClear
showSearch
loading={engineListLoading}
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 180 }}
style={{ width: 260 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
optionFilterProp="label"
options={Array.from(
new Map(
records
.filter((r) => r.engineId)
.map((r) => [r.engineId, {
value: r.engineId,
label: getEngineName(r.engineSnapshot as any) || r.engineId,
}]),
).values(),
)}
filterOption={(input, option: any) =>
String(option?.searchText || option?.label || '')
.toLowerCase()
.includes(input.trim().toLowerCase())
}
options={engineOptions}
/>
<RangePicker
value={createdRange}
@@ -1036,8 +1099,8 @@ const AdminGenerationAiRecords: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
{preview.pipelineStage ? <Tag color="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
</div>
@@ -1045,6 +1108,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
<InfoItem label="用户ID" value={preview.userId || '-'} />
<InfoItem label="任务ID" value={preview.id} />
<InfoItem label="生成数量" value={`${preview.generationCount || 1}`} />
</div>
<div>
@@ -1122,40 +1186,18 @@ const AdminGenerationAiRecords: React.FC = () => {
</div>
) : null}
{preview.status === 'completed' ? (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
</Typography.Text>
{preview.genType === 'video' && preview.videoUrl ? (
<Button
size="small"
type="link"
icon={<PlayCircleOutlined />}
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
style={{ padding: 0 }}
>
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
style={{ padding: 0 }}
>
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div>
) : null}
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.generationCount || 1}
</Typography.Text>
<GenerationTaskResourceGrid
task={preview}
resolveUrl={apiUrl}
onPreview={handlePreviewResource}
/>
</div>
{preview.status === 'failed' && preview.errorMessage ? (
{previewStatusConfig?.isFailure && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -22,12 +23,13 @@ import {
CloseCircleOutlined,
SearchOutlined,
VideoCameraOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
import dayjs from 'dayjs';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate';
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
@@ -50,14 +52,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
references: {},
};
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
@@ -186,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationRecords: React.FC = () => {
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
const [total, setTotal] = useState(0);
@@ -194,13 +191,18 @@ const AdminGenerationRecords: React.FC = () => {
const [pageSize] = useState(20);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterUserId, setFilterUserId] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
const [reloadKey, setReloadKey] = useState(0);
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [updating, setUpdating] = useState<string | null>(null);
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string; image_size: string } | null>(null);
// 时间筛选(默认当天)
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const load = useCallback(async () => {
setLoading(true);
@@ -208,6 +210,10 @@ const AdminGenerationRecords: React.FC = () => {
const res = await getAdminGenerationRecords({
userId: filterUserId.trim() || undefined,
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
page,
pageSize,
});
@@ -240,6 +246,10 @@ const AdminGenerationRecords: React.FC = () => {
imageTokensUsed: item.imageTokensUsed || 0,
imageProportion: item.imageProportion,
imagePx: item.imagePx,
engineId: item.engineId,
engineName: item.engineName,
engineSnapshot: item.engineSnapshot,
includeMediaReferences: item.includeMediaReferences,
})));
setTotal(res.total || 0);
} catch {
@@ -247,12 +257,32 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
useEffect(() => {
load();
}, [load, reloadKey]);
useEffect(() => {
Promise.all([
getImageEngines({ includeDeleted: true }),
getVideoEngines({ includeDeleted: true }),
])
.then(([imageEngines, videoEngines]) => {
const items = [...(imageEngines || []), ...(videoEngines || [])];
const seen = new Set<string>();
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((acc, item: any) => {
const id = String(item?.id || '');
if (!id || seen.has(id)) return acc;
seen.add(id);
const deletedSuffix = item?.deletedAt ? '(已删除)' : '';
acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` });
return acc;
}, []));
})
.catch(() => setEngineOptions([]));
}, []);
useEffect(() => {
if (!preview) {
setResourceState(EMPTY_RESOURCE_STATE);
@@ -281,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
}, [preview]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterUserId('');
setFilterEngineId('');
setFilterIncludeMedia('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1);
};
@@ -349,33 +392,7 @@ const AdminGenerationRecords: React.FC = () => {
}, 0);
};
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
setUpdating(recordId);
try {
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
message.success('状态已更新');
load();
} catch (e: any) {
message.error(e?.message || '更新失败');
} finally {
setUpdating(null);
}
};
const handleGenerate = async () => {
if (!genModal) return;
setUpdating(genModal.record.id);
try {
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution, genModal.image_size);
message.success(`已提交${genModal.record.genType === 'video' ? '视频' : '图片'}生成`);
setGenModal(null);
load();
} catch (e: any) {
message.error(e?.message || '生成失败');
} finally {
setUpdating(null);
}
};
const columns = useMemo(() => [
{
@@ -416,21 +433,27 @@ const AdminGenerationRecords: React.FC = () => {
title: '参数', key: 'params', width: 160,
render: (_: any, r: AdminGenerationRecord) => (
r.genType === 'video' ? (
r.duration || r.aspectRatio || r.resolution ? (
<Space size={4} wrap>
{r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
</Space>
) : <Tag color="default"></Tag>
<Space size={4} wrap>
{r.duration ? <Tag>{r.duration}s</Tag> : null}
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default"></Tag> : null}
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
</Tag>
</Space>
) : (
r.imageSize || r.imageProportion || r.imagePx ? (
<Space size={4} wrap>
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
</Space>
) : <Tag color="default"></Tag>
<Space size={4} wrap>
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default"></Tag> : null}
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
</Tag>
</Space>
)
),
},
@@ -453,10 +476,15 @@ const AdminGenerationRecords: React.FC = () => {
{
title: '状态', dataIndex: 'status', width: 90,
render: (v: string) => {
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
const state = resolveGenerationUiState({ status: v });
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
},
},
{
title: '阶段', dataIndex: 'pipelineStage', width: 150,
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
},
{
title: '时间', key: 'time', width: 150,
render: (_: any, r: AdminGenerationRecord) => (
@@ -467,78 +495,20 @@ const AdminGenerationRecords: React.FC = () => {
),
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
render: (_: any, r: AdminGenerationRecord) => (
<Space size={4} wrap>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
</Button>
{r.status === 'generating' ? (
<Button
size="small"
danger
loading={updating === r.id}
onClick={() => {
Modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: '确定将此记录标记为失败?',
onOk: () => handleStatusUpdate(r.id, 'failed'),
});
}}
>
</Button>
) : null}
{r.status === 'failed' ? (
<Button
size="small"
type="primary"
danger
loading={updating === r.id}
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
>
</Button>
) : null}
{r.status === 'prompt_optimized' ? (
<>
<Button
size="small"
type="primary"
loading={updating === r.id}
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
style={{ background: '#6366f1', border: 'none' }}
>
{r.genType === 'video' ? '视频' : '图片'}
</Button>
<Button
size="small"
danger
loading={updating === r.id}
onClick={() => {
Modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: '确定将此记录标记为失败?',
onOk: () => handleStatusUpdate(r.id, 'failed'),
});
}}
>
</Button>
</>
) : null}
</Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
</Button>
),
},
], [handleOpenPreview, updating]);
], [handleOpenPreview]);
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
const renderResultImage = () => {
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.imageUrl) {
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
@@ -681,7 +651,7 @@ const AdminGenerationRecords: React.FC = () => {
};
const renderResultVideo = () => {
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
if (!preview.videoUrl) {
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
@@ -894,6 +864,27 @@ const AdminGenerationRecords: React.FC = () => {
{ value: 'failed', label: '失败' },
]}
/>
<Select
placeholder="引擎筛选"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 220 }}
value={filterEngineId || undefined}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
options={engineOptions}
/>
<Select
placeholder="附件状态"
allowClear
style={{ width: 130 }}
value={filterIncludeMedia || undefined}
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
options={[
{ value: 'true', label: '携带附件' },
{ value: 'false', label: '不携带附件' },
]}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
@@ -903,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
onPressEnter={handleSearch}
allowClear
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
@@ -956,7 +961,9 @@ const AdminGenerationRecords: React.FC = () => {
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}> / </Typography.Text>
<Space size={4} wrap>
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
{preview.videoUpscaleEnabled ? <Tag color="purple"></Tag> : null}
</Space>
</div>
</div>
@@ -1035,7 +1042,7 @@ const AdminGenerationRecords: React.FC = () => {
{renderReferences()}
{preview.status === 'completed' ? (
{resolveGenerationUiState(preview).isSuccess ? (
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
@@ -1045,7 +1052,7 @@ const AdminGenerationRecords: React.FC = () => {
) : null}
{/* Error message */}
{preview.status === 'failed' && preview.errorMessage ? (
{resolveGenerationUiState(preview).isFailure && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>: {preview.errorMessage}</Typography.Text>
</div>
@@ -1062,73 +1069,6 @@ const AdminGenerationRecords: React.FC = () => {
)}
</Modal>
{/* Generate modal */}
<Modal
title={(
<Space>
{genModal && (genModal.record.genType === 'video' ? <PlayCircleOutlined /> : <FileImageOutlined />)}
{genModal && (genModal.record.genType === 'video' ? '生成视频' : '生成图片')}
</Space>
)}
open={!!genModal}
onCancel={() => setGenModal(null)}
onOk={handleGenerate}
okText="提交生成"
cancelText="取消"
confirmLoading={genModal ? updating === genModal.record.id : false}
width={420}
>
{genModal ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
{genModal.record.genType === 'video' ? (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.ratio}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, ratio: v } : null))}
style={{ width: '100%' }}
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map((r) => ({ value: r, label: r }))}
/>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.resolution}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, resolution: v } : null))}
style={{ width: '100%' }}
options={['480p', '720p', '1080p'].map((r) => ({ value: r, label: r }))}
/>
</div>
</>
) : (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.imagePx || '-'}</Typography.Text>
</div>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{genModal.record.imageProportion || '-'}</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={genModal.image_size}
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, image_size: v } : null))}
style={{ width: '100%' }}
options={['2K', '4K'].map((r) => ({ value: r, label: r }))}
/>
</div>
</>
)}
</div>
) : null}
</Modal>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types';
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
</div>
<Space wrap>
<Select
@@ -208,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
),
},
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
{ title: '流程版本', dataIndex: 'flowVersion', width: 100, render: (v: string) => <Tag color={v === 'v2' ? 'blue' : 'default'}>{String(v || 'v1').toUpperCase()}</Tag> },
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
@@ -219,14 +219,12 @@ const AdminHotOpeningReplications: React.FC = () => {
fixed: 'right',
width: 110,
render: (_, record) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}></Button>
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}></Button>
),
},
]}
/>
</Space>
</Card>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -21,6 +21,11 @@ interface ImageEngine {
generateUrl: string;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
}
function parseJsonArray(val: unknown): any[] {
@@ -80,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => {
setLoading(true);
@@ -126,6 +132,11 @@ const AdminImageEngines: React.FC = () => {
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
multi_image_max_images: values.multiImageMaxImages ?? 15,
max_reference_image_count: values.maxReferenceImageCount ?? 14,
output_format: values.outputFormat ?? '',
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
@@ -168,6 +179,8 @@ const AdminImageEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
maxReferenceImageCount: 14, outputFormat: '',
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
@@ -232,6 +245,18 @@ const AdminImageEngines: React.FC = () => {
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: ImageEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -350,6 +375,33 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
2-5 API
</Typography.Paragraph>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="outputFormat" label="供应商输出格式">
<Select size="large" options={[
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
+435
View File
@@ -0,0 +1,435 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
import { formatDate } from '../utils/formatDate';
import type { InvoiceItem, InvoiceDetail } from '../types';
const AdminInvoices: React.FC = () => {
const [data, setData] = useState<InvoiceItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [phoneFilter, setPhoneFilter] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
// 详情弹窗
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
// 失败原因弹窗
const [failModalOpen, setFailModalOpen] = useState(false);
const [failReason, setFailReason] = useState('');
const [failTargetId, setFailTargetId] = useState<string | null>(null);
const [failSubmitting, setFailSubmitting] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAdminInvoices({
page,
pageSize,
status: statusFilter || undefined,
phone: phoneFilter || undefined,
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
});
setData(res.items);
setTotal(res.total);
} catch (err: any) {
message.error(err?.message || '获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [page, pageSize, statusFilter, queryCreatedRange]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
};
const handleReset = () => {
setStatusFilter(null);
setPhoneFilter('');
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
};
const handleViewDetail = async (id: string) => {
setDetailLoading(true);
setDetailModalOpen(true);
try {
const detail = await getAdminInvoiceDetail(id);
setCurrentDetail(detail);
} catch (err: any) {
message.error(err?.message || '获取详情失败');
setDetailModalOpen(false);
} finally {
setDetailLoading(false);
}
};
const handleMarkSuccess = async (id: string) => {
try {
await updateInvoiceStatus(id, { status: 'success' });
message.success('已标记为开具成功');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const handleOpenFailModal = (id: string) => {
setFailTargetId(id);
setFailReason('');
setFailModalOpen(true);
};
const handleConfirmFail = async () => {
if (!failReason.trim()) {
message.warning('请填写失败原因');
return;
}
if (!failTargetId) return;
setFailSubmitting(true);
try {
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
message.success('已标记为开具失败');
setFailModalOpen(false);
setFailTargetId(null);
setFailReason('');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
setFailSubmitting(false);
};
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const statusTag = (status: string) => {
const config: Record<string, { color: string; label: string }> = {
processing: { color: 'blue', label: '开具中' },
success: { color: 'green', label: '已开具' },
failed: { color: 'red', label: '已失败' },
};
const c = config[status] || { color: 'default', label: status };
return <Tag color={c.color}>{c.label}</Tag>;
};
const columns = [
{
title: '发票编号',
dataIndex: 'invoiceNo',
key: 'invoiceNo',
width: 180,
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
},
{
title: '用户',
dataIndex: 'username',
key: 'username',
width: 100,
render: (_: string, record: InvoiceItem) => (
<div>
<div><Typography.Text strong>{record.username}</Typography.Text></div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
</div>
),
},
{
title: '抬头类型',
dataIndex: 'headerType',
key: 'headerType',
width: 80,
render: (v: string) => (v === 'company' ? '企业' : '个人'),
},
{
title: '抬头名称',
dataIndex: 'headerName',
key: 'headerName',
width: 160,
ellipsis: true,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 160,
ellipsis: true,
},
{
title: '总金额',
dataIndex: 'totalAmount',
key: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
},
{
title: '订单数',
dataIndex: 'orderCount',
key: 'orderCount',
width: 70,
align: 'center' as const,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: string) => statusTag(v),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (v: string) => formatDate(v),
},
{
title: '操作',
key: 'actions',
width: 260,
render: (_: unknown, record: InvoiceItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
{record.status === 'processing' && (
<>
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
</Button>
</>
)}
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type={statusFilter === null ? 'primary' : 'default'}
onClick={() => { setStatusFilter(null); setPage(1); }}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={statusFilter === 'processing' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('processing'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'success' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('success'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'failed' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('failed'); setPage(1); }}
size="small"
>
</Button>
</div>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Input
placeholder="按用户手机号搜索"
value={phoneFilter}
onChange={(e) => setPhoneFilter(e.target.value)}
style={{ width: 200 }}
allowClear
onPressEnter={handleSearch}
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : data.length === 0 ? (
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 1400 }}
/>
)}
</Card>
{/* 详情弹窗 */}
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
footer={null}
width={700}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : currentDetail && (
<div style={{ padding: 8 }}>
{/* 基本信息 */}
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{currentDetail.invoiceNo}
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}>ID</Typography.Text>
<Typography.Text>{currentDetail.userId}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerName}</Typography.Text>
{currentDetail.headerTaxNo && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
</>
)}
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.email}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
{currentDetail.issuedAt && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
</>
)}
{currentDetail.failureReason && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
</>
)}
</div>
</div>
{/* 关联订单 */}
{currentDetail.orders && currentDetail.orders.length > 0 && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
{currentDetail.orders.length}
</Typography.Text>
<Table
dataSource={currentDetail.orders}
columns={[
{
title: '订单号',
dataIndex: 'orderNo',
key: 'orderNo',
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '积分',
dataIndex: 'credits',
key: 'credits',
align: 'right' as const,
},
]}
rowKey="id"
pagination={false}
size="small"
/>
</div>
)}
</div>
)}
</Modal>
{/* 失败原因弹窗 */}
<Modal
title="开具失败"
open={failModalOpen}
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
onOk={handleConfirmFail}
okText="确认"
cancelText="取消"
confirmLoading={failSubmitting}
>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Input.TextArea
value={failReason}
onChange={(e) => setFailReason(e.target.value)}
placeholder="例如:抬头信息有误,请重新提交"
rows={3}
maxLength={500}
showCount
/>
</Modal>
</div>
);
};
export default AdminInvoices;
+3 -3
View File
@@ -233,15 +233,15 @@ const AdminLayout: React.FC = () => {
width: 42,
height: 42,
borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
<img src={siteLogo} alt="logo" style={{ width: 36, height: 36, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
)}
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { getLlmBillingExecutions } from '../api';
import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
charged: { label: '已消费', color: 'blue' },
processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' },
refunded: { label: '已退款', color: 'purple' },
refund_failed: { label: '退款失败', color: 'red' },
};
const AdminLlmBillingExecutions: React.FC = () => {
const [items, setItems] = useState<LlmBillingExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>();
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
useEffect(() => { load(); }, [page, status]);
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
<Timeline items={(record.calls || []).map((call) => ({
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
children: <div>
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)}  {call.callSequence} {call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
<div>{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
<div>{call.modelNameSnapshot || '-'} {call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
{call.tokenUnavailableReason
? <div>Token{call.tokenUnavailableReason}</div>
: <div> Token{call.inputTokens ?? 0}  Token{call.outputTokens ?? 0}  Token{call.totalTokens ?? 0}</div>}
{call.errorMessage && <Typography.Text type="danger">{call.errorMessage}</Typography.Text>}
{call.postprocessStatus === 'succeeded' && <div></div>}
{call.postprocessError && <div>{call.postprocessError}</div>}
</div>,
}))} />
<Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '消费积分', children: record.chargeCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
]} />
</div> }} columns={[
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
{ title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
]} />
</Card>;
};
export default AdminLlmBillingExecutions;
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
import type { LlmBillingPolicy } from '../types';
const SCENES: Array<{ value: string; label: string }> = [
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
];
const AdminLlmBillingPolicies: React.FC = () => {
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
setItems(await getLlmBillingPolicies());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => {
setEditing(item || null);
setOpen(true);
form.resetFields();
form.setFieldsValue(item
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
: { is_active: true, charge_credits: 5 });
};
const save = async () => {
try {
const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values);
else await createLlmBillingPolicy(values);
message.success('已保存');
setOpen(false);
load();
} catch (error: any) {
if (!error?.errorFields) message.error(error?.message || '保存失败');
}
};
return <Card
title={<Space><RobotOutlined />LLM积分配置</Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}
>
<Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> },
]} />
<Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
<Select disabled={!!editing} options={SCENES} />
</Form.Item>
<Form.Item name="charge_credits" label="消费积分" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminLlmBillingPolicies;
+2 -4
View File
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
return <Tag color="blue">{labelMap[v] || v}</Tag>;
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
@@ -1,11 +1,13 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty,
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty, Tabs,
} from 'antd';
import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
} from '@ant-design/icons';
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
import { formatDate } from '../utils/formatDate';
interface NotificationRecord {
@@ -29,6 +31,19 @@ interface ReadUser {
readAt: string;
}
// 富文本编辑器工具栏配置(含颜色选择)
const editorModules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
[{ color: [] }, { background: [] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
],
};
const AdminNotificationManager: React.FC = () => {
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -41,6 +56,11 @@ const AdminNotificationManager: React.FC = () => {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
// Banner state
const [bannerContent, setBannerContent] = useState('');
const [bannerConfigId, setBannerConfigId] = useState<string | null>(null);
const [bannerSaving, setBannerSaving] = useState(false);
const [bannerLoading, setBannerLoading] = useState(false);
const load = async () => {
setLoading(true);
@@ -69,8 +89,29 @@ const AdminNotificationManager: React.FC = () => {
}
};
const loadBanner = async () => {
setBannerLoading(true);
try {
const configs = await getSystemConfigs();
const banner = configs.find((c: any) => c.key === 'site_banner');
if (banner) {
setBannerContent(banner.value || '');
setBannerConfigId(banner.id);
} else {
setBannerContent('');
setBannerConfigId(null);
}
} catch {
// ignore
} finally {
setBannerLoading(false);
}
};
useEffect(() => { load(); }, [page, pageSize]);
useEffect(() => { loadBanner(); }, []);
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
@@ -115,6 +156,39 @@ const AdminNotificationManager: React.FC = () => {
}
};
const handleSaveBanner = async () => {
const content = bannerContent.trim();
if (!content) {
message.error('请输入横幅内容');
return;
}
setBannerSaving(true);
try {
if (bannerConfigId) {
await updateSystemConfig(bannerConfigId, content);
} else {
const res = await createSystemConfig('site_banner', content, '全局活动通知横幅内容');
setBannerConfigId(res.id);
}
// 内容变更后自动递增版本号,让所有用户重新看到横幅
try { await resetActivityBanner(); } catch { /* ignore */ }
message.success('活动横幅已保存,所有用户将重新看到该横幅');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setBannerSaving(false);
}
};
const handleResetBanner = async () => {
try {
const res = await resetActivityBanner();
message.success(`横幅已重新展示(版本 → ${res.siteBannerVersion}`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'system': return 'blue';
@@ -131,6 +205,7 @@ const AdminNotificationManager: React.FC = () => {
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
},
{
title: '类型', dataIndex: 'type', width: 80,
@@ -164,37 +239,148 @@ const AdminNotificationManager: React.FC = () => {
},
];
const tabItems = [
{
key: 'notifications',
label: (
<span><BellOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条消息`,
}}
scroll={{ x: 900 }}
/>
</Card>
),
},
{
key: 'banner',
label: (
<span><NotificationOutlined style={{ marginRight: 6 }} /></span>
),
children: (
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1',
}}>
<NotificationOutlined />
</div>
<div>
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
</Typography.Text>
</div>
</div>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
{bannerLoading ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>...</div>
) : (
<ReactQuill
theme="snow"
value={bannerContent}
onChange={setBannerContent}
modules={editorModules}
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
style={{ height: 200, marginBottom: 48 }}
/>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<Button
icon={<NotificationOutlined />}
onClick={handleResetBanner}
size="large"
style={{ borderRadius: 8, minWidth: 160 }}
>
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveBanner}
loading={bannerSaving}
size="large"
style={{ borderRadius: 8, minWidth: 140 }}
>
</Button>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
💡
</Typography.Text>
{/* 预览区域 */}
{bannerContent && (
<div style={{ marginTop: 24 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{
borderRadius: 12,
overflow: 'hidden',
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
border: '1px solid rgba(139, 92, 246, 0.15)',
padding: '10px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 28, height: 28, borderRadius: 8,
background: 'rgba(139, 92, 246, 0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
</div>
<div style={{
color: '#5b21b6',
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
flex: 1,
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
</div>
</div>
)}
</Card>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条消息`,
}}
scroll={{ x: 900 }}
/>
</Card>
<Tabs items={tabItems} defaultActiveKey="notifications" />
{/* Send Notification Modal */}
<Modal
@@ -210,8 +396,8 @@ const AdminNotificationManager: React.FC = () => {
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
<ReactQuill theme="snow" modules={editorModules} placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
@@ -31,7 +31,7 @@ const AdminOperationLogs: React.FC = () => {
const load = async (p?: number) => {
setLoading(true);
try {
const res = await getOperationLogs(p || page);
const res = await getOperationLogs({ page: p || page });
setLogs(res.items || []);
setTotal(res.total || 0);
} catch {
+61 -295
View File
@@ -1,308 +1,74 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminPaymentOrders, getPaymentStats } from '../api';
const { Option } = Select;
const { RangePicker } = DatePicker;
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(10);
const [orderTotal, setOrderTotal] = useState(0);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const [stats, setStats] = useState<any>(null);
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
const load = async () => {
try {
setLoading(true);
const [statsData, ordersData] = await Promise.all([
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
page: orderPage,
pageSize: orderPageSize,
}),
]);
setStats({ ...statsData, recent: ordersData.items || [] });
setOrderTotal(ordersData.total);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
const load = useCallback(async () => {
setLoading(true);
try {
const [s, o] = await Promise.all([
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
]);
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
} finally { setLoading(false); }
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
];
const handleOrderPageChange = (p: number, ps: number) => {
setOrderPage(p);
setOrderPageSize(ps);
};
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
const today = stats?.today || {};
const month = stats?.month || {};
const bySource = stats?.bySource || {};
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.paymentMethod}
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={12} md={8}>
<span style={{ marginRight: 8 }}></span>
<RangePicker
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4}>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent || []}
rowKey="id"
loading={loading}
pagination={{
current: orderPage,
pageSize: orderPageSize,
total: orderTotal,
onChange: handleOrderPageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
return <ConfigProvider locale={zhCN}><div>
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(today.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(today.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(month.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(month.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} </Typography.Text></Card></Col>
</Row>
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} </div></div></Col>)}</Row></Card>
<Card title="订单列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={reset}></Button>
</Space>
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`${t}`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
</Card>
</div></ConfigProvider>;
};
export default AdminPaymentStats;
@@ -1,253 +1 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
interface PackageItem {
id: string;
name: string;
credits: number;
price: number;
bonusCredits: number;
totalCredits: number;
description: string | null;
packageType: string;
isGift: boolean;
isActive: boolean;
sortOrder: number;
}
const TYPE_COLORS: Record<string, string> = {
normal: 'blue',
gift: 'green',
promo: 'purple',
};
const TYPE_LABELS: Record<string, string> = {
normal: '常规',
gift: '赠送',
promo: '促销',
};
const AdminRechargePackages: React.FC = () => {
const [packages, setPackages] = useState<PackageItem[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getRechargePackages();
setPackages(data.map((item: any) => ({
id: item.id,
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
description: item.description,
packageType: item.package_type ?? item.packageType ?? 'normal',
isGift: item.is_gift ?? item.isGift ?? false,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
})));
} catch {
message.error('加载充值套餐失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
credits: values.credits,
price: values.price,
bonus_credits: values.bonusCredits || 0,
description: values.description || null,
package_type: values.packageType || 'normal',
is_gift: values.isGift || false,
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (modal.item?.id) {
await saveRechargePackage({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveRechargePackage(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
try {
await deleteRechargePackage(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: PackageItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonusCredits,
description: item.description,
packageType: item.packageType,
isGift: item.isGift,
isActive: item.isActive,
sortOrder: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
}
};
const columns = [
{
title: '套餐名称', key: 'name', width: 160,
render: (_: any, r: PackageItem) => (
<div>
<Typography.Text strong>{r.name}</Typography.Text>
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
</div>
),
},
{
title: '基础积分', dataIndex: 'credits', width: 100,
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
},
{
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
render: (v: number) => v > 0
? <Tag color="green">+{v.toLocaleString()}</Tag>
: <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '总积分', key: 'total', width: 100,
render: (_: any, r: PackageItem) => (
<Typography.Text strong style={{ color: '#6366f1' }}>
{(r.credits + r.bonusCredits).toLocaleString()}
</Typography.Text>
),
},
{
title: '价格(元)', dataIndex: 'price', width: 100,
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'packageType', width: 80,
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: PackageItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{packages.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={packages}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
<Input placeholder="例如:进阶包" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 'normal', label: '常规' },
{ value: 'gift', label: '赠送' },
{ value: 'promo', label: '促销' },
]} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input placeholder="套餐描述(可选)" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminRechargePackages;
export { default } from './AdminCreditProducts';
@@ -21,7 +21,7 @@ import {
PlayCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType;
}
const STEP_ORDER = [
const V1_STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
@@ -44,6 +44,12 @@ const STEP_ORDER = [
'video_generate',
];
const V2_STEP_ORDER = [
'material_input',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果',
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false);
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
setLoading(true);
try {
const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId)
: await getAdminShotProjectDetail(projectId);
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
: await getAdminShotProjectDetail(projectId, flowVersion);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [moduleType, projectId]);
}, [flowVersion, moduleType, projectId]);
useEffect(() => {
load();
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue);
const isV2 = detail?.flowVersion === 'v2';
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
const stepItems = useMemo(() => STEP_ORDER.map(code => {
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish';
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
description: step ? <StatusTag status={step.status} /> : '未创建',
status,
};
}), [stepsByCode]);
}), [effectiveStepOrder, stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
},
{
key: 'video_prompt_optimize',
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
@@ -265,7 +275,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
},
{
key: 'video_generate',
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
@@ -300,6 +310,9 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
),
},
];
const visibleCollapseItems = isV2
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
: collapseItems;
return (
<div style={{ padding: 24 }}>
@@ -322,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
@@ -336,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
{!isV2 ? (
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
) : null}
<Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card>
@@ -348,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
<Collapse
items={[
@@ -0,0 +1,300 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
} from 'antd';
import {
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import {
listScheduledTasks,
createScheduledTask,
updateScheduledTask,
deleteScheduledTask,
runScheduledTask,
toggleScheduledTask,
} from '../api';
import type { ScheduledTask } from '../types';
const { TextArea } = Input;
const SCHEDULE_TYPE_OPTIONS = [
{ value: 'internal_method', label: '内部方法执行' },
];
const TASK_TYPE_LABELS: Record<string, string> = {
internal_method: '内部方法',
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
success: { label: '成功', color: 'green' },
error: { label: '失败', color: 'red' },
};
const AdminScheduledTasks: React.FC = () => {
const [data, setData] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState(false);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listScheduledTasks();
setData(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const openModal = (task?: ScheduledTask) => {
if (task) {
setEditing(task);
let configStr = '';
if (task.config) {
try {
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
} catch {
configStr = task.config;
}
}
form.setFieldsValue({
name: task.name,
task_type: task.taskType,
schedule: task.schedule,
config: configStr,
is_active: task.isActive,
});
} else {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_active: true, task_type: 'internal_method' });
}
setActiveTab('basic');
setModal(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
let configVal = values.config;
if (configVal && typeof configVal === 'string') {
try {
configVal = JSON.stringify(JSON.parse(configVal));
} catch {
message.warning('配置 JSON 格式不合法,将按原样保存');
}
}
setSaving(true);
if (editing) {
await updateScheduledTask(editing.id, { ...values, config: configVal });
message.success('更新成功');
} else {
await createScheduledTask({ ...values, config: configVal });
message.success('创建成功');
}
setModal(false);
await fetchData();
} catch (err: any) {
message.error(err?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (taskId: string) => {
try {
await deleteScheduledTask(taskId);
message.success('删除成功');
await fetchData();
} catch (err: any) {
message.error(err?.message || '删除失败');
}
};
const handleRun = async (taskId: string) => {
try {
await runScheduledTask(taskId);
message.success('任务已提交执行');
setTimeout(fetchData, 1500);
} catch (err: any) {
message.error(err?.message || '执行失败');
}
};
const handleToggle = async (task: ScheduledTask) => {
try {
const res = await toggleScheduledTask(task.id);
message.success(res.message);
await fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const columns = [
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
{
title: '类型',
dataIndex: 'taskType',
width: 100,
render: (v: string) => <Tag color="purple">{TASK_TYPE_LABELS[v] || v}</Tag>,
},
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '最后执行',
dataIndex: 'lastRunAt',
width: 160,
render: (v: string, r: ScheduledTask) => {
if (!v) return '-';
const status = r.lastStatus ? STATUS_LABELS[r.lastStatus] : null;
return (
<span>
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
</span>
);
},
},
{
title: '操作',
width: 220,
render: (_: any, record: ScheduledTask) => (
<Space size="small">
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}></Button>
<Button type="link" size="small" icon={record.isActive ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
{record.isActive ? '禁用' : '启用'}
</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}></Button>
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const scheduleHelp = (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
<div> 3600 = </div>
<div> Cron 5 </div>
<div> <code>0 * * * *</code> = | <code>0 2 * * *</code> = 2</div>
</div>
);
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<ClockCircleOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
/>
</Card>
{/* 新增/编辑弹窗 */}
<Modal
title={editing ? '编辑定时任务' : '新增定时任务'}
open={modal}
onOk={handleSave}
onCancel={() => setModal(false)}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Tabs
activeKey={activeTab}
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
items={[
{
key: 'basic',
label: '基本设置',
children: (
<>
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
<Input placeholder="例如:每小时同步银行交易" />
</Form.Item>
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
<Select options={SCHEDULE_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="schedule"
label="调度表达式"
rules={[{ required: true, message: '请输入调度表达式' }]}
extra={scheduleHelp}
>
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</>
),
},
{
key: 'config',
label: '任务配置',
children: (
<Form.Item
name="config"
label="配置 JSON"
extra='module 和 function 指定调用的函数,其余字段作为参数传入。服务会自动翻页拉取全部流水:'
>
<TextArea
rows={10}
placeholder={'{\n "module": "app.services.bank.sync_service",\n "function": "sync_bank_transactions",\n "url": "http://ceshi.web.minzhong.cn/api/api/v1/internal/get-blank-transfer-acct-time",\n "api_key": "jixekCxm8piLFi0AlfA24bDtCKJ82bfu",\n "acct_no": "110972289710001",\n "start_date": "2026-08-01",\n "end_date": "2026-08-14"\n}'}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
),
},
]}
/>
</Form>
</Modal>
</div>
);
};
export default AdminScheduledTasks;
+438 -61
View File
@@ -1,19 +1,25 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Tabs, Typography, Upload, Table, Tag, Popconfirm,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined, BankOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
createSystemConfig,
getGlobalResourceCapacity,
getSystemConfigs,
saveGlobalResourceCapacity,
updateSystemConfig,
uploadLogo,
uploadPdf,
uploadLoginVideo,
listBankAccounts,
createBankAccount,
updateBankAccount,
deleteBankAccount,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
@@ -28,10 +34,116 @@ const AdminSettings: React.FC = () => {
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
// 银行账户管理
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [accountModal, setAccountModal] = useState(false);
const [accountEditing, setAccountEditing] = useState<BankAccount | null>(null);
const [accountForm] = Form.useForm();
const [accountSaving, setAccountSaving] = useState(false);
useEffect(() => {
load();
loadBankAccounts();
}, []);
const loadBankAccounts = async () => {
setLoadingAccounts(true);
try {
const res = await listBankAccounts();
setBankAccounts(res.items || []);
} catch (e: any) {
message.error(e?.message || '加载银行账户失败');
} finally {
setLoadingAccounts(false);
}
};
const openAccountModal = (account?: BankAccount) => {
if (account) {
setAccountEditing(account);
accountForm.setFieldsValue({
account_name: account.accountName,
bank_name: account.bankName,
account_no: account.accountNo,
is_active: account.isActive,
is_default: account.isDefault,
description: account.description,
});
} else {
setAccountEditing(null);
accountForm.resetFields();
accountForm.setFieldsValue({ is_active: true, is_default: false });
}
setAccountModal(true);
};
const handleAccountSave = async () => {
try {
const values = await accountForm.validateFields();
setAccountSaving(true);
if (accountEditing) {
await updateBankAccount(accountEditing.id, values);
message.success('更新成功');
} else {
await createBankAccount(values);
message.success('创建成功');
}
setAccountModal(false);
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setAccountSaving(false);
}
};
const handleAccountDelete = async (accountId: string) => {
try {
await deleteBankAccount(accountId);
message.success('删除成功');
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const accountColumns = [
{ title: '账户名称', dataIndex: 'accountName', width: 150 },
{ title: '开户银行', dataIndex: 'bankName', width: 150 },
{
title: '银行账号',
dataIndex: 'accountNo',
width: 180,
render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code>,
},
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '默认',
dataIndex: 'isDefault',
width: 80,
render: (v: boolean) => v && <Tag color="blue"></Tag>,
},
{ title: '备注', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作',
width: 140,
render: (_: any, record: BankAccount) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openAccountModal(record)}></Button>
<Popconfirm title="确定删除该账户?" onConfirm={() => handleAccountDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const load = async () => {
setLoading(true);
try {
@@ -39,6 +151,14 @@ const AdminSettings: React.FC = () => {
getSystemConfigs(),
getGlobalResourceCapacity(),
]);
// 确保 llm_media_as_base64 配置存在
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
// 确保 single_device_login_enabled 配置存在
if (!data.some(c => c.key === 'single_device_login_enabled')) {
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
}
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
@@ -56,6 +176,7 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
@@ -118,11 +239,81 @@ const AdminSettings: React.FC = () => {
return false;
};
const handleLoginVideoUpload = async (file: File) => {
setUploading('login_bg_video');
try {
const res = await uploadLoginVideo(file);
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
form.setFieldsValue({ login_bg_video: res.url });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('登录背景视频上传成功并已保存');
} catch (e: any) {
message.error(e?.message || '上传失败');
} finally {
setUploading('');
}
return false;
};
const handleRemoveLoginVideo = async () => {
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
form.setFieldsValue({ login_bg_video: '' });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, '');
}
message.success('已移除登录背景视频');
};
const handleToggleSingleDevice = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'single_device_login_enabled');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'single_device_login_enabled');
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}单设备登录限制`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'llm_media_as_base64');
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')),
'站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_banner'),
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'收款银行': [],
'其他配置': configs.filter(c => c.key === 'operation_manual'),
};
@@ -269,7 +460,7 @@ const AdminSettings: React.FC = () => {
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
@@ -278,36 +469,23 @@ const AdminSettings: React.FC = () => {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
}
return (
<div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text>
</div>
</div>
const tabItems = [
{
key: 'basic',
label: '网站基础设置',
children: (
<Form form={form} layout="vertical">
{Object.entries(groupedConfigs).map(([group, items]) => (
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
items.map(config => (
groupedConfigs[group]?.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
items.map(config => (
groupedConfigs[group]?.map(config => (
<Form.Item
key={config.id}
name={config.key}
@@ -320,50 +498,214 @@ const AdminSettings: React.FC = () => {
)}
</div>
))}
<div style={{ marginBottom: 4 }}>
</Form>
),
},
{
key: 'credits',
label: '用户积分配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
<Space align="start" style={{ marginBottom: 16 }}>
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
</div>
</div>
</Space>
{groupedConfigs['用户积分配置']?.map(config => (
<Form.Item
name="resource_capacity_enabled"
label="启用全局容量管控"
valuePropName="checked"
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
{getFieldComponent(config)}
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="resource_capacity_limit_value"
label="容量数值"
extra="最小为1,不能为负数,最多支持3位小数。"
rules={[{ required: true, message: '请输入容量数值' }]}
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="resource_capacity_limit_unit"
label="容量单位"
extra="MB / GB / TB 固定枚举"
rules={[{ required: true, message: '请选择容量单位' }]}
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'integration',
label: '收款银行',
children: (
<div>
{/* 银行账户管理 */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
<Typography.Text strong style={{ fontSize: 14 }}>
<BankOutlined style={{ marginRight: 8, color: '#6366f1' }} />
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => openAccountModal()}>
</Button>
</div>
<Table
rowKey="id"
dataSource={bankAccounts}
loading={loadingAccounts}
pagination={false}
size="middle"
columns={accountColumns}
/>
</div>
</div>
),
},
{
key: 'other',
label: '其他配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['其他配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
{/* 登录背景视频 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong></Typography.Text>
</Space>
<Space>
{form.getFieldValue('login_bg_video') && (
<Button size="small" danger onClick={handleRemoveLoginVideo}>
</Button>
)}
<Upload
accept="video/*,image/gif,image/webp"
showUploadList={false}
beforeUpload={handleLoginVideoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
</Button>
</Upload>
</Space>
</div>
{(() => {
const url = form.getFieldValue('login_bg_video');
if (!url) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使 backimage.png
</Typography.Text>
);
}
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
const isGif = url.toLowerCase().endsWith('.gif');
return isGif ? (
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
) : (
<video
src={fullUrl}
controls
muted
loop
playsInline
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
/>
);
})()}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
MP4WebMMOVGIFWebP 50MB
</Typography.Text>
</div>
</div>
{/* 文字模型媒体编码 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong>/ base64 </Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
base64 URL
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
onChange={handleToggleBase64}
checkedChildren="base64"
unCheckedChildren="链接"
/>
</div>
</div>
</div>
{/* 单设备登录限制 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
onChange={handleToggleSingleDevice}
checkedChildren="已启用"
unCheckedChildren="已禁用"
/>
</div>
</div>
</div>
</Form>
),
},
];
return (
<div style={{ maxWidth: 1080 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Tabs items={tabItems} defaultActiveKey="basic" />
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -372,6 +714,41 @@ const AdminSettings: React.FC = () => {
</Button>
</div>
{/* 银行账户新增/编辑弹窗 */}
<Modal
title={accountEditing ? '编辑银行账户' : '新增银行账户'}
open={accountModal}
onOk={handleAccountSave}
onCancel={() => setAccountModal(false)}
confirmLoading={accountSaving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="account_name" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]}>
<Input placeholder="例如:民众普康科技有限公司" />
</Form.Item>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]}>
<Input placeholder="例如:中国工商银行北京分行" />
</Form.Item>
<Form.Item name="account_no" label="银行账号" rules={[{ required: true, message: '请输入银行账号' }]}>
<Input placeholder="银行账号" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={2} placeholder="可选备注信息" />
</Form.Item>
<div style={{ display: 'flex', gap: 24 }}>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="is_default" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const PAGE_SIZE = 20;
@@ -35,28 +36,14 @@ const SPLIT_STATUS_OPTIONS = [
{ value: 'retry_waiting', label: '等待重试' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' }> = ({ status, kind = 'task' }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
const meta = kind === 'analysis'
? getShotAnalysisStatusMeta(status)
: (kind === 'split' ? getShotSplitStatusMeta(status) : getShotTaskStatusMeta(status));
return <Tag color={meta.color}>{meta.text}</Tag>;
};
@@ -109,6 +96,25 @@ const AdminShotReplications: React.FC = () => {
load();
}, [load, reloadKey]);
const hasActiveTasks = items.some((item) => (
getShotTaskStatusMeta(item.status).active
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|| getShotSplitStatusMeta(item.splitStatus).active
));
useEffect(() => {
if (!hasActiveTasks) return undefined;
const refresh = () => {
if (document.visibilityState === 'visible') void load();
};
const timer = window.setInterval(refresh, 30000);
document.addEventListener('visibilitychange', refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
};
}, [hasActiveTasks, load]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
@@ -134,16 +140,15 @@ const AdminShotReplications: React.FC = () => {
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary">AI </Typography.Text>
</div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space>
<CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
</div>
<Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
@@ -186,9 +191,9 @@ const AdminShotReplications: React.FC = () => {
</Space>
),
},
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} kind="task" /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
{
title: '切片进度',
width: 180,
@@ -211,9 +216,7 @@ const AdminShotReplications: React.FC = () => {
},
]}
/>
</Space>
</Card>
</div>
);
};
@@ -7,7 +7,6 @@ import {
Descriptions,
Drawer,
Empty,
Input,
Select,
Space,
Spin,
@@ -23,6 +22,7 @@ import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDet
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
import { getShotAnalysisStatusMeta, getShotReplicateStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -55,25 +55,6 @@ const REPLICATE_STATUS_OPTIONS = [
{ value: 'failed', label: '复刻失败' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
not_required: { color: 'default', text: '无需分析' },
not_started: { color: 'default', text: '未复刻' },
project_created: { color: 'processing', text: '已创建项目' },
};
const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
@@ -85,9 +66,13 @@ const apiUrl = (url?: string | null): string => {
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' | 'replicate' }> = ({ status, kind = 'task' }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
const meta = kind === 'analysis'
? getShotAnalysisStatusMeta(status)
: (kind === 'split'
? getShotSplitStatusMeta(status)
: (kind === 'replicate' ? getShotReplicateStatusMeta(status) : getShotTaskStatusMeta(status)));
return <Tag color={meta.color}>{meta.text}</Tag>;
};
@@ -177,6 +162,32 @@ const AdminShotTaskSetDetail: React.FC = () => {
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
const hasActiveTasks = Boolean(detail && (
getShotTaskStatusMeta(detail.status).active
|| getShotAnalysisStatusMeta(detail.analysisStatus).active
|| getShotSplitStatusMeta(detail.splitStatus).active
|| segments.some((item) => (
getShotSplitStatusMeta(item.splitStatus).active
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|| getShotReplicateStatusMeta(item.replicateStatus).active
))
));
useEffect(() => {
if (!hasActiveTasks) return undefined;
const refresh = () => {
if (document.visibilityState !== 'visible') return;
void loadDetail();
void loadSegments();
};
const timer = window.setInterval(refresh, 20000);
document.addEventListener('visibilitychange', refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', refresh);
};
}, [hasActiveTasks, loadDetail, loadSegments]);
const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true);
setSegmentDetail(null);
@@ -224,9 +235,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} kind="task" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount} {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
@@ -274,9 +285,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan"></Tag> },
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="replicate" /> },
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{
@@ -284,9 +295,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
width: 220,
render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
<StatusTag status={record.moduleProjectStatus} />
<Space size={4}>
<StatusTag status={record.moduleProjectStatus} kind="replicate" />
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
</Tag>
</Space>
</Space>
) : <Tag></Tag>,
},
@@ -310,10 +326,11 @@ const AdminShotTaskSetDetail: React.FC = () => {
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} kind="split" /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} kind="replicate" /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
+66 -365
View File
@@ -1,378 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
import {
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
} from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const editStatusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
function statusLabel(status?: string): string {
if (status === 'active') return '启用';
if (status === 'disabled') return '禁用';
return status || '-';
}
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
const AdminTeams: React.FC = () => {
const [items, setItems] = useState<AdminTeam[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
useEffect(()=>{void load();},[load]);
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
keyword: keyword || undefined,
status: status || undefined,
}), [page, pageSize, keyword, status]);
const load = async () => {
setLoading(true);
try {
const res = await getAdminTeams(query);
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载团队列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
setModal({ open: true, item: null });
};
const openEdit = (item: AdminTeam) => {
form.setFieldsValue({
name: item.name,
code: item.code || '',
description: item.description || '',
status: item.status || 'active',
sortOrder: item.sortOrder || 0,
});
setModal({ open: true, item });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await saveAdminTeam({
id: modal.item?.id,
name: values.name,
code: values.code || null,
description: values.description || null,
status: values.status || 'active',
sort_order: values.sortOrder || 0,
});
message.success(modal.item ? '团队已更新' : '团队已创建');
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: AdminTeam) => {
try {
await deleteAdminTeam(item.id);
message.success('团队已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
dataIndex: 'name',
width: 220,
render: (v: string, r: AdminTeam) => (
<Space>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
<TeamOutlined />
</div>
<div>
<Typography.Text strong>{v}</Typography.Text>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
</div>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
},
{
title: '成员数',
dataIndex: 'memberCount',
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
onConfirm={() => handleDelete(r)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
const columns=[
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
{title:'排序',dataIndex:'sortOrder',width:80},
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}></Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button size="small" danger icon={<DeleteOutlined/>}></Button></Popconfirm></Space>},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Input
placeholder="搜索团队名称/编码/备注"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
onPressEnter={load}
style={{ width: 260 }}
allowClear
/>
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
const subColumns=[
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
];
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple"></Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}></Button></Popconfirm>}];
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 个团队`,
}}
scroll={{ x: 1100 }}
/>
</Card>
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}></Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}></Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
<Modal
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
open={modal.open}
confirmLoading={saving}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存"
cancelText="取消"
width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="团队名称" rules={[{ required: true, message: '请输入团队名称' }]}>
<Input maxLength={128} placeholder="请输入团队名称" size="large" />
</Form.Item>
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select size="large" options={editStatusOptions} />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div>
);
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
<Tabs items={[
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
{key:'usage',label:'成员月净消耗',children:<Table
loading={detailLoading}
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
dataSource={usage}
pagination={false}
scroll={{x:1400}}
columns={[
{title:'成员',dataIndex:'username',width:120},
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`${r.tierCode}`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`${r.periodSequence}个月`:'历史周期')},
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`},
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
]}
/>},
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green"></Tag>}]}/>},
]}/>
</Modal>
</div>;
};
export default AdminTeams;
+365 -28
View File
@@ -1,17 +1,22 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -20,12 +25,13 @@ import {
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam,
updateSystemConfig,
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -66,6 +72,15 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -75,6 +90,10 @@ const AdminUsers: React.FC = () => {
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false);
@@ -86,6 +105,9 @@ const AdminUsers: React.FC = () => {
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -127,6 +149,9 @@ const AdminUsers: React.FC = () => {
const formValues: Record<string, string> = {};
credit.forEach(c => { formValues[c.key] = c.value; });
configForm.setFieldsValue(formValues);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} catch { /* auth error handled by client */ }
};
loadCreditConfigs();
@@ -164,12 +189,94 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.description);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch { /* validation */ }
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -390,6 +497,20 @@ const AdminUsers: React.FC = () => {
}
};
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
try {
setSingleDeviceSaving(true);
await updateSingleDeviceLoginOverride(user.id, value);
message.success('已更新单设备登录设置');
setSingleDeviceModal({ open: false, user: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setSingleDeviceSaving(false);
}
};
const isAdminTab = activeTab === 'admin';
const columns = [
@@ -411,16 +532,21 @@ const AdminUsers: React.FC = () => {
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
<div style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
</div>
</Space>
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
{Number(r.personalCredits || 0).toLocaleString()} / {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}> {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
@@ -487,15 +613,30 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
title: '操作', key: 'action', width: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -539,6 +680,15 @@ const AdminUsers: React.FC = () => {
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
</Button>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
</Button>
)}
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
@@ -684,32 +834,174 @@ const AdminUsers: React.FC = () => {
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
<Form form={form} layout="vertical" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="description" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 使 0
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong></Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</Modal>
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
@@ -874,7 +1166,7 @@ const AdminUsers: React.FC = () => {
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="credits" label="初始积分" initialValue={0}>
<Form.Item name="credits" label="初始积分(一个自然月有效)" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
@@ -961,6 +1253,51 @@ const AdminUsers: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><SafetyOutlined /> - {singleDeviceModal.user?.username}</Space>}
open={singleDeviceModal.open}
onOk={() => {
if (singleDeviceModal.user) {
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
}
}}
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
okText="保存" cancelText="取消" width={420}
confirmLoading={singleDeviceSaving}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
</Typography.Text>
</div>
<Radio.Group
value={singleDeviceValue}
onChange={e => setSingleDeviceValue(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value={null}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'}
</Typography.Text>
</Radio>
<Radio value={true}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
<Radio value={false}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
</Space>
</Radio.Group>
</Modal>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -25,6 +25,8 @@ interface VideoEngine {
supportsUniversalReference: boolean;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
}
function parseJsonArray(val: unknown): any[] {
@@ -40,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => {
setLoading(true);
@@ -80,6 +83,8 @@ const AdminVideoEngines: React.FC = () => {
supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
};
if (modal.engine) {
await saveVideoEngine({ id: modal.engine.id, ...payload });
@@ -115,7 +120,8 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
maxDuration: 30,
multiGenerationEnabled: false, maxGenerationCount: 1,
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
maxAudioCount: 0,
@@ -180,6 +186,18 @@ const AdminVideoEngines: React.FC = () => {
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: VideoEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -268,7 +286,7 @@ const AdminVideoEngines: React.FC = () => {
</Form.Item>
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}` }))
Array.from({ length: 27 }, (_, i) => ({ value: i + 4, label: `${i + 4}` }))
} />
</Form.Item>
</div>
@@ -288,14 +306,14 @@ const AdminVideoEngines: React.FC = () => {
name="maxAudioCount"
label="最大参考音频数"
style={{ flex: 1 }}
extra="0 表示不支持音频参考,最大 3 段"
extra="0 表示不支持音频参考"
rules={[
{
validator: (_, value) => {
const n = Number(value ?? 0);
if (!Number.isInteger(n) || n < 0 || n > 3) {
return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
}
// if (!Number.isInteger(n) || n < 0 || n > 3) {
// return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
// }
return Promise.resolve();
},
},
@@ -315,7 +333,19 @@ const AdminVideoEngines: React.FC = () => {
<Switch />
</Form.Item>
</div>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
1
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Empty,
Row,
Select,
Space,
Spin,
Switch,
Tag,
Typography,
} from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
import type {
VideoUpscaleConfigData,
VideoUpscaleProcessorKey,
VideoUpscaleResolutionRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpegcrop' },
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
];
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
label: value,
value,
}));
const defaultRule = (): VideoUpscaleResolutionRule => ({
targetResolution: '1080p',
providerGenerationResolution: '720p',
processorKey: 'local_ffmpeg_crop_v1',
enabled: true,
});
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
return {
enabled: !!data.enabled,
version: Number(data.version || 1),
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
};
}
function toSavePayload(data: VideoUpscaleConfigData) {
return {
data: {
enabled: data.enabled,
version: data.version,
delete_source_after_success: data.deleteSourceAfterSuccess,
rules: data.rules.map((rule) => ({
target_resolution: rule.targetResolution,
provider_generation_resolution: rule.providerGenerationResolution,
processor_key: rule.processorKey,
enabled: rule.enabled,
})),
},
};
}
const AdminVideoUpscale: React.FC = () => {
const { message, modal } = App.useApp();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getVideoUpscaleConfig();
setConfig(normalizeConfig(result.data));
} catch (error) {
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
void load();
}, [load]);
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
setConfig((current) => {
if (!current) return current;
return {
...current,
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
};
});
};
const removeRule = (index: number) => {
setConfig((current) => current ? {
...current,
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
} : current);
};
const addRule = () => {
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
};
const save = async () => {
if (!config) return;
const targets = config.rules
.filter((item) => item.enabled)
.map((item) => item.targetResolution.trim().toLowerCase());
if (new Set(targets).size !== targets.length) {
message.error('同一个客户目标分辨率只能存在一条启用规则');
return;
}
setSaving(true);
try {
const result = await saveVideoUpscaleConfig(toSavePayload(config));
setConfig(normalizeConfig(result.data));
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
if (loading || !config) {
return (
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col>
<Title level={3} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
</Paragraph>
</Col>
<Col>
<Space>
<Tag> {config.version}</Tag>
<Button icon={<ReloadOutlined />} onClick={() => void load()}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
</Button>
</Space>
</Col>
</Row>
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
<Space>
<Text strong></Text>
<Switch
checked={config.enabled}
onChange={(enabled) => setConfig({ ...config, enabled })}
/>
<Text type="secondary"></Text>
</Space>
<Space>
<Text strong></Text>
<Switch
checked={config.deleteSourceAfterSuccess}
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
/>
<Text type="secondary">
</Text>
</Space>
</Space>
</Card>
<Card
title="目标分辨率规则"
extra={<Button icon={<PlusOutlined />} onClick={addRule}></Button>}
>
{config.rules.length === 0 ? (
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{config.rules.map((rule, index) => {
const duplicate = rule.enabled && config.rules.filter(
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
).length > 1;
return (
<Card key={`${index}-${rule.targetResolution}`} size="small">
<Row gutter={[12, 12]} align="bottom">
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.targetResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { targetResolution: value })}
status={duplicate ? 'error' : undefined}
/>
</Col>
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.providerGenerationResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
/>
</Col>
<Col xs={24} md={7}>
<Text type="secondary"></Text>
<Select
value={rule.processorKey}
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { processorKey: value })}
/>
</Col>
<Col xs={12} md={2}>
<Text type="secondary"></Text>
<div style={{ marginTop: 8 }}>
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
</div>
</Col>
<Col xs={12} md={2}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => modal.confirm({
title: '删除这条超分规则?',
onOk: () => removeRule(index),
})}
/>
</Col>
<Col span={24}>
<Text type="secondary">
</Text>
</Col>
</Row>
</Card>
);
})}
</Space>
)}
</Card>
</Space>
);
};
export default AdminVideoUpscale;
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
import { copyToClipboard } from '../../../utils/clipboard';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
const copyUrl = async () => {
if (!resolvedUrl) return;
try {
await navigator.clipboard.writeText(resolvedUrl);
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
const ok = await copyToClipboard(resolvedUrl);
message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
};
const tools = resolvedUrl ? (
@@ -21,13 +21,22 @@ const STATUS_LABELS: Record<string, LabelMeta> = {
// 生成任务 pipeline / download stage
creating_provider_task: { text: '创建远端任务', color: 'processing' },
provider_result_staged: { text: '供应商结果已暂存', color: 'processing' },
waiting_remote: { text: '等待远端结果', color: 'processing' },
polling: { text: '轮询远端结果', color: 'processing' },
result_ready: { text: '结果已就绪', color: 'success' },
download_queued: { text: '下载已入队', color: 'processing' },
downloading: { text: '下载中', color: 'processing' },
done: { text: '已完成', color: 'success' },
download_failed: { text: '下载失败', color: 'error' },
retry_waiting: { text: '等待重试', color: 'orange' },
upscale_queued: { text: '超分已入队', color: 'purple' },
upscale_processing: { text: '本地超分处理中', color: 'purple' },
upscale_polling: { text: '轮询远程超分', color: 'purple' },
upscale_downloading: { text: '下载超分结果', color: 'purple' },
upscale_finalizing: { text: '超分结果最终化', color: 'purple' },
upscale_retry_waiting: { text: '超分等待重试', color: 'orange' },
upscale_failed: { text: '超分失败', color: 'error' },
// 拆镜总任务状态
pending_analysis: { text: '等待分析', color: 'default' },
@@ -35,7 +35,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -27,7 +27,7 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
marginX: 24,
marginY: 24,
textWatermark: {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -1,5 +1,5 @@
import React from 'react';
import { Col, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
import { Col, ColorPicker, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import WatermarkPreview from './WatermarkPreview';
@@ -25,7 +25,7 @@ const positionOptions = [
];
const defaultTextWatermark = {
text: '民众普康 AI',
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
@@ -87,7 +87,15 @@ const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, wate
</Col>
<Col span={12}>
<Form.Item label="文字颜色" required>
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
<ColorPicker
value={textWatermark.color}
onChange={(_, hex) => patchText({ color: hex || '#ffffff' })}
showText
presets={[{
label: '推荐',
colors: ['#ffffff', '#000000', '#ff4d4f', '#1677ff', '#52c41a', '#faad14', '#722ed1', '#eb2f96'],
}]}
/>
</Form.Item>
</Col>
<Col span={12}>
+396 -18
View File
@@ -143,12 +143,16 @@ export interface AdminTeam {
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface AdminTeamOption {
@@ -186,6 +190,9 @@ export interface AdminUser {
email: string;
phone?: string;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
@@ -197,6 +204,31 @@ export interface AdminUser {
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number;
singleDeviceLoginOverride?: boolean | null;
}
export interface DailyCredit {
date: string;
module: string;
credits: number;
}
export interface TeamCredit {
teamName: string;
teamId: string | null;
credits: number;
}
export interface ModelUsageOut {
modelName: string;
provider: string;
count: number;
}
export interface VideoParamOut {
model: string;
label: string;
count: number;
}
export interface AdminStats {
@@ -214,29 +246,31 @@ export interface AdminStats {
lastPeriodRecords: number;
lastPeriodRevenue: number;
lastPeriodCreditsConsumed: number;
dailyCreditsByModule: DailyCredit[];
periodCreditsByModule: DailyCredit[];
creditsByTeam: TeamCredit[];
modelUsage: ModelUsageOut[];
videoResolutionUsage: VideoParamOut[];
videoRatioUsage: VideoParamOut[];
videoDurationUsage: VideoParamOut[];
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { label?: string; count: number; amount: number }>;
bySource: Record<string, { label?: string; count: number; amount: number }>;
totalIncome: { label?: string; count: number; amount: number };
today: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
month: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
recent?: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
paymentMethod: string;
status: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
}
export interface ModelConfig {
@@ -281,6 +315,12 @@ export interface GenerationAiImageEngine {
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
isActive?: boolean;
deletedAt?: string | null;
}
export interface GenerationAiVideoEngine {
@@ -299,6 +339,10 @@ export interface GenerationAiVideoEngine {
supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
isActive?: boolean;
deletedAt?: string | null;
}
export interface GenerationAiEnginesResponse {
@@ -326,9 +370,37 @@ export interface GenerationAiEngineOption {
supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean;
priority: number;
multiGenerationEnabled?: boolean;
maxGenerationCount?: number;
multiImageMaxImages?: number;
maxReferenceImageCount?: number;
genType: GenerationAiGenType;
}
export type GenerationPipelineStage =
| 'queued'
| 'preparing'
| 'creating_provider_task'
| 'provider_result_staged'
| 'waiting_remote'
| 'polling'
| 'result_ready'
| 'download_queued'
| 'downloading'
| 'retry_waiting'
| 'upscale_queued'
| 'upscale_processing'
| 'upscale_polling'
| 'upscale_downloading'
| 'upscale_finalizing'
| 'upscale_retry_waiting'
| 'upscale_failed'
| 'done'
| 'failed'
| 'timeout'
| 'download_failed'
| (string & {});
export interface AdminGenerationRecord {
id: string;
userId: string;
@@ -342,8 +414,10 @@ export interface AdminGenerationRecord {
aspectRatio?: string;
resolution?: string;
status: 'optimizing' | 'prompt_optimized' | 'generating' | 'completed' | 'failed' | string;
pipelineStage?: GenerationPipelineStage | null;
videoUrl?: string;
videoCoverUrl?: string;
videoUpscaleEnabled?: boolean;
references?: GenerationAIMediaReference[] | null;
creditsCost: number;
textCreditsCost: number;
@@ -358,6 +432,10 @@ export interface AdminGenerationRecord {
imageTokensUsed?: number;
imageProportion?: string;
imagePx?: string;
engineId?: string;
engineName?: string;
engineSnapshot?: Record<string, unknown> | null;
includeMediaReferences?: boolean;
}
export type GenerationAITaskStatus = 'pending' | 'generating' | 'completed' | 'failed' | string;
@@ -399,7 +477,11 @@ export interface GenerationAITaskOut {
projectId?: string | null;
genType: GenerationAiGenType | string;
generationMode?: string | null;
pipelineStage?: string | null;
parentTaskId?: string | null;
generationCount: number;
generationIndex?: number | null;
displayStatus?: string | null;
pipelineStage?: GenerationPipelineStage | null;
status: GenerationAITaskStatus;
originalPrompt: string;
optimizedPrompt?: string | null;
@@ -428,6 +510,7 @@ export interface GenerationAITaskOut {
errorMessage?: string | null;
createdAt?: string | null;
generatedAt?: string | null;
childItems: GenerationAITaskOut[];
}
export interface GenerationAITaskListOut {
@@ -531,6 +614,9 @@ export interface ReplicationProjectDetailOut {
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
stepIoSchemaVersion?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
@@ -553,6 +639,8 @@ export interface HotOpeningTaskListItemOut {
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
flowVersion?: 'v1' | 'v2' | string | null;
stepCount?: number;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
@@ -666,6 +754,7 @@ export interface ShotSegmentOut {
moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null;
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -782,22 +871,99 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */
totalConsume: number;
/** 总退款:仅真实退款,不含历史 hold_release */
totalRefund: number;
/** 兼容字段:与 totalConsume 同真实消费口径 */
totalCharge: number;
/** 历史兼容字段:hold 占用,不计入 totalConsume */
totalHold: number;
/** 兼容字段:与 totalRefund 同真实退款口径 */
totalRefundReal: number;
/** 历史兼容字段:hold_release,不计入 totalRefund */
totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number;
transactionCount: number;
generationCount: number;
generationAttemptCount: number;
imageGenerationCount: number;
videoGenerationCount: number;
/** 子分类消费(图片)仅真实消费 charge 口径 */
imageConsume: number;
/** 子分类消费(视频)仅真实消费 charge 口径 */
videoConsume: number;
/** 子分类消费(提词)仅真实消费 charge 口径 */
textConsume: number;
/** 子分类消费(分析)仅真实消费 charge 口径 */
analysisConsume: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
}
export interface AdminCreditRecordAllocation {
id: string;
creditBalanceId: string;
sourceAllocationId?: string | null;
allocationAction: string;
allocationActionLabel?: string;
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
creditScope?: string;
creditScopeLabel?: string;
teamId?: string | null;
teamManagerId?: string | null;
subscriptionId?: string | null;
subscriptionNo?: string | null;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
seatId?: string | null;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
validFrom?: string;
expiresAt?: string;
unspentBefore?: number;
unspentAfter?: number;
consumedBefore?: number;
consumedAfter?: number;
}
export interface AdminCreditRecordSubscriptionUsage {
creditScope?: string | null;
creditScopeLabel?: string | null;
subscriptionId?: string | null;
subscriptionNo: string;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
amount: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -815,6 +981,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
balanceDelta?: number;
expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -827,12 +995,24 @@ export interface AdminCreditRecord {
chargeKind?: string;
chargeKindLabel?: string;
chargeAction?: string;
chargeActionLabel?: string;
creditSubject?: string;
creditSubjectLabel?: string;
mediaType?: string;
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sceneNameSnapshot?: string;
requestTime?: string;
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
fundingScope?: string;
fundingScopeLabel?: string;
teamAllocationAmount?: number;
personalAllocationAmount?: number;
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -865,11 +1045,13 @@ export interface AdminCreditRecordQueryParams {
userType?: string;
frontendUserKind?: string;
teamId?: string;
subscriptionNo?: string;
recordType?: string;
type?: string;
creditSubject?: string;
mediaType?: string;
chargeKind?: string;
chargeAction?: string;
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
@@ -1293,3 +1475,199 @@ export interface PrivatePortraitSelectableAssetListOut {
page: number;
pageSize: number;
}
// ── Video Upscale ────────────────────────────────────────
export type VideoUpscaleProcessorKey =
| 'local_ffmpeg_crop_v1'
| 'volc_large_model_v1'
| 'volc_standard_v1'
| 'volc_professional_v1';
export interface VideoUpscaleResolutionRule {
targetResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
providerGenerationResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
processorKey: VideoUpscaleProcessorKey;
enabled: boolean;
}
export interface VideoUpscaleConfigData {
enabled: boolean;
version: number;
deleteSourceAfterSuccess: boolean;
rules: VideoUpscaleResolutionRule[];
}
export interface VideoUpscaleConfigOut {
id?: string | null;
key: string;
description?: string | null;
data: VideoUpscaleConfigData;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface VideoUpscaleConfigSavePayload {
data: {
enabled: boolean;
version: number;
delete_source_after_success: boolean;
rules: Array<{
target_resolution: string;
provider_generation_resolution: string;
processor_key: VideoUpscaleProcessorKey;
enabled: boolean;
}>;
};
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'team_subscription' | 'credit_addon';
export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
export interface CreditProduct {
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
}
export interface LlmBillingPolicy {
id: string;
sceneCode: string;
sceneName: string;
chargeCredits: number;
isActive: boolean;
version: number;
createdBy?: string | null;
updatedBy?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface LlmCallAttempt {
id: string;
callSequence: number;
retrySequence: number;
modelConfigId?: string | null;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
providerRequestId?: string | null;
requestStartedAt: string;
responseReceivedAt?: string | null;
durationMs?: number | null;
status: string;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
httpStatus?: number | null;
errorMessage?: string | null;
tokenUnavailableReason?: string | null;
postprocessStatus?: string | null;
postprocessError?: string | null;
}
export interface LlmBillingExecution {
id: string;
userId: string;
sceneCode: string;
sceneNameSnapshot: string;
ownerType: string;
ownerId: string;
businessAttemptNo: number;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
requestTime: string;
chargeCredits: number;
status: string;
totalCallCount: number;
successfulCallCount: number;
failedCallCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
refundAvailableCredits: number;
refundExpiredCredits: number;
finalErrorMessage?: string | null;
calls: LlmCallAttempt[];
}
// ── Invoice Types ───────────────────────────────────────
export interface InvoiceItem {
id: string;
invoiceNo: string;
userId: string;
username: string;
phone: string;
headerType: string;
headerName: string;
email: string;
totalAmount: number;
totalCredits: number;
orderCount: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
}
export interface InvoiceOrder {
id: string;
orderNo: string;
amount: number;
credits: number;
}
export interface InvoiceDetail {
id: string;
invoiceNo: string;
userId: string;
headerType: string;
headerName: string;
headerTaxNo: string | null;
headerRegisterAddress: string | null;
headerRegisterPhone: string | null;
headerBankName: string | null;
headerBankAccount: string | null;
email: string;
totalAmount: number;
totalCredits: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
orders: InvoiceOrder[];
}
// ── Bank Account ───────────────────────────────────────
export interface BankAccount {
id: string;
accountName: string;
bankName: string;
accountNo: string;
isActive: boolean;
isDefault: boolean;
description?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
// ── Scheduled Task ─────────────────────────────────────
export interface ScheduledTask {
id: string;
name: string;
taskType: 'external_api' | 'internal_method';
schedule: string;
config?: string | null;
isActive: boolean;
lastRunAt?: string | null;
lastStatus?: string | null;
lastError?: string | null;
createdBy?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
+23
View File
@@ -0,0 +1,23 @@
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return true;
}
// 降级方案:使用 textarea + execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
+55 -8
View File
@@ -1,11 +1,58 @@
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '-';
let s = iso.trim();
if (!s.includes('T')) s = s.replace(' ', 'T');
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
const dotIdx = s.indexOf('.');
if (dotIdx > 0) s = s.slice(0, dotIdx);
// Remove any trailing timezone info (backend now sends naive datetimes)
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
return s.replace('T', ' ').slice(0, 16);
const s = iso.trim();
if (!s) return '-';
// Parse the ISO string, handling timezone offset
// Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.slice(0, 16).replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
// Build a Date in UTC
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
if (tz && tz !== 'Z') {
// Has explicit offset like +00:00 or +08:00 — already accounted for in the matched components
// We parsed HH:MM:SS as-is, which are in the given offset.
// Convert to UTC first by subtracting the offset
const sign = tz[0] === '+' ? 1 : -1;
const [oh, om] = tz.slice(1).split(':');
const offsetMin = sign * (+oh * 60 + +om);
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// No tz or Z: if Z it's UTC, if no tz it's naive (assume CST from backend)
const isUTC = tz === 'Z';
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function formatDatePrecise(iso: string | null | undefined): string {
if (!iso) return '-';
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
const s = iso.trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
let target = utcMs;
if (tz === 'Z') target += CST_OFFSET * 60000;
else if (tz) {
const sign = tz[0] === '+' ? 1 : -1;
const compact = tz.slice(1).replace(':', '');
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
}
const d = new Date(target);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
}
@@ -0,0 +1,97 @@
export interface GenerationStatusLike {
status?: string | null;
displayStatus?: string | null;
pipelineStage?: string | null;
shouldPoll?: boolean | null;
}
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
export interface GenerationUiState {
status: string;
displayStatus: string;
pipelineStage: string;
effectiveKey: string;
label: string;
color: GenerationUiColor;
isActive: boolean;
isSuccess: boolean;
isFailure: boolean;
isTerminal: boolean;
}
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
const ACTIVE_PIPELINE_STAGES = new Set([
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
]);
const SUCCESS_KEYS = new Set(['completed', 'done']);
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
const LABELS: Record<string, string> = {
pending: '待处理', optimizing: '提词处理中', prompt_optimized: '待生成', generating: '生成中',
queued: '已入队', preparing: '准备中', creating_provider_task: '创建供应商任务',
provider_result_staged: '供应商结果已暂存', waiting_remote: '等待供应商结果', polling: '轮询供应商结果',
result_ready: '远程结果已就绪', download_queued: '下载已入队', downloading: '下载中',
retry_waiting: '下载等待重试', recovery_inconsistent: '恢复证据异常', upscale_queued: '超分已入队',
upscale_processing: '本地超分处理中', upscale_polling: '轮询远程超分',
upscale_downloading: '下载超分结果', upscale_finalizing: '超分结果最终化',
upscale_retry_waiting: '超分等待重试', completed: '已完成', done: '已完成', timeout: '任务超时',
download_failed: '下载失败', upscale_failed: '超分失败', failed: '失败', deleted: '已删除',
};
const COLOR_MAP: Record<string, GenerationUiColor> = {
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'warning',
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
result_ready: 'processing', download_queued: 'processing', downloading: 'processing', retry_waiting: 'orange',
recovery_inconsistent: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
};
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
const firstMatching = (values: string[], keys: Set<string>): string => values.find((item) => keys.has(item)) || '';
export const getGenerationStageLabel = (key?: string | null): string => {
const normalized = normalize(key);
return LABELS[normalized] || normalized || '未知状态';
};
export const getGenerationStatusColor = (key?: string | null): GenerationUiColor => {
const normalized = normalize(key);
return COLOR_MAP[normalized] || 'default';
};
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
const status = normalize(value.status);
const displayStatus = normalize(value.displayStatus);
const pipelineStage = normalize(value.pipelineStage);
const values = [pipelineStage, displayStatus, status].filter(Boolean);
const failureKey = firstMatching(values, FAILURE_KEYS);
const successKey = firstMatching(values, SUCCESS_KEYS);
const deletedKey = firstMatching(values, new Set(['deleted']));
const effectiveKey = failureKey || deletedKey || successKey || pipelineStage || displayStatus || status || 'pending';
const isFailure = FAILURE_KEYS.has(effectiveKey);
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
const isActive = typeof value.shouldPoll === 'boolean'
? value.shouldPoll
: (!isFailure && !isSuccess && effectiveKey !== 'deleted' && (ACTIVE_PIPELINE_STAGES.has(pipelineStage) || ACTIVE_STATUS_KEYS.has(status) || ACTIVE_STATUS_KEYS.has(displayStatus)));
return {
status, displayStatus, pipelineStage, effectiveKey,
label: getGenerationStageLabel(effectiveKey),
color: getGenerationStatusColor(effectiveKey),
isActive, isSuccess, isFailure,
isTerminal: TERMINAL_KEYS.has(effectiveKey),
};
};
export const isGenerationActive = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isActive;
export const isGenerationSuccess = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isSuccess;
export const isGenerationFailure = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isFailure;
export const isGenerationTerminal = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isTerminal;
@@ -0,0 +1,76 @@
export interface ShotStatusMeta {
key: string;
text: string;
color: string;
active: boolean;
terminal: boolean;
}
type ShotStatusMap = Record<string, Omit<ShotStatusMeta, 'key'>>;
const TASK_STATUS_MAP: ShotStatusMap = {
pending_analysis: { text: '等待分析', color: 'default', active: true, terminal: false },
analyzing: { text: '分析中', color: 'processing', active: true, terminal: false },
analysis_completed: { text: '分析完成', color: 'blue', active: false, terminal: false },
analysis_failed: { text: '分析失败', color: 'error', active: false, terminal: true },
splitting: { text: '拆镜中', color: 'processing', active: true, terminal: false },
split_completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
partial_failed: { text: '部分失败', color: 'warning', active: false, terminal: true },
failed: { text: '失败', color: 'error', active: false, terminal: true },
deleted: { text: '已删除', color: 'default', active: false, terminal: true },
};
const ANALYSIS_STATUS_MAP: ShotStatusMap = {
not_required: { text: '无需分析', color: 'default', active: false, terminal: true },
pending: { text: '等待分析', color: 'default', active: true, terminal: false },
processing: { text: '分析中', color: 'processing', active: true, terminal: false },
completed: { text: '分析完成', color: 'success', active: false, terminal: true },
failed: { text: '分析失败', color: 'error', active: false, terminal: true },
};
const SPLIT_STATUS_MAP: ShotStatusMap = {
none: { text: '未拆镜', color: 'default', active: false, terminal: true },
pending: { text: '等待拆镜', color: 'default', active: true, terminal: false },
processing: { text: '拆镜中', color: 'processing', active: true, terminal: false },
retry_waiting: { text: '等待拆镜重试', color: 'orange', active: true, terminal: false },
completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
failed: { text: '拆镜失败', color: 'error', active: false, terminal: true },
};
const REPLICATE_STATUS_MAP: ShotStatusMap = {
not_started: { text: '未复刻', color: 'default', active: false, terminal: true },
project_created: { text: '已创建项目', color: 'processing', active: true, terminal: false },
pending: { text: '等待复刻', color: 'default', active: true, terminal: false },
waiting_user: { text: '等待用户操作', color: 'processing', active: true, terminal: false },
processing: { text: '复刻中', color: 'processing', active: true, terminal: false },
completed: { text: '复刻完成', color: 'success', active: false, terminal: true },
failed: { text: '复刻失败', color: 'error', active: false, terminal: true },
cancelled: { text: '已取消', color: 'default', active: false, terminal: true },
canceled: { text: '已取消', color: 'default', active: false, terminal: true },
};
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
const resolveMeta = (status: string | null | undefined, map: ShotStatusMap, fallback = '未知状态'): ShotStatusMeta => {
const key = normalize(status);
return {
key,
...(map[key] || { text: key || fallback, color: 'default', active: false, terminal: false }),
};
};
export const getShotTaskStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, TASK_STATUS_MAP);
export const getShotAnalysisStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, ANALYSIS_STATUS_MAP);
export const getShotSplitStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, SPLIT_STATUS_MAP);
export const getShotReplicateStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, REPLICATE_STATUS_MAP);
export const getShotStatusMeta = (status?: string | null): ShotStatusMeta => {
const key = normalize(status);
return getShotTaskStatusMeta(key).text !== key
? getShotTaskStatusMeta(key)
: (ANALYSIS_STATUS_MAP[key]
? getShotAnalysisStatusMeta(key)
: (SPLIT_STATUS_MAP[key]
? getShotSplitStatusMeta(key)
: getShotReplicateStatusMeta(key)));
};
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminbanktransactions.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminscheduledtasks.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+6 -1
View File
@@ -5,8 +5,8 @@ DEBUG=false
SECRET_KEY=local-dev-secret-key-not-for-production
# Database (PostgreSQL)
#DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
#DATABASE_URL=postgresql+asyncpg://postgres:123456@localhost:5432/videogen_cs
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
REDIS_URL=redis://127.0.0.1:6379/0
@@ -43,6 +43,10 @@ CAPTCHA_ENABLED=true
# CORS
CORS_ORIGINS=["*"]
# Base URL (用于 favicon、回调地址等)
#BASE_URL=http://localhost:8000
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
# RESOURCE
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
RESOURCE_SIGN_EXPIRE_SECONDS=86400
@@ -58,6 +62,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png
# VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
+3
View File
@@ -39,3 +39,6 @@ CAPTCHA_ENABLED=true
# CORS
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
# Base URL (用于 favicon、回调地址等)
BASE_URL=https://your-domain.com
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: 1a4d1f095fa1
Revises: 20260811_20260811, 8a6d2f4c9b10
Create Date: 2026-08-11 10:30:10.431672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a4d1f095fa1'
down_revision: Union[str, None] = ('20260811_20260811', '8a6d2f4c9b10')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,654 @@
"""2026073101_add_column_comments
Revision ID: 2026073101
Revises: f7g8h9i0j1k2
Create Date: 2026-07-31 00:00:00.000000
该文件包含 2026-07-31 的数据库迁移内容
给所有表字段添加 COMMENT 注释便于数据库维护与排查
仅使用 COMMENT ON COLUMN/COMMENT ON TABLE 语句不修改列类型与约束
"""
from typing import Sequence, Union
from alembic import op
revision: str = '2026073101'
down_revision: Union[str, None] = 'f7g8h9i0j1k2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
# ============================================================
# users 表
# ============================================================
_comment_table("users", "用户表")
_comment_column("users", "id", "主键ID")
_comment_column("users", "username", "用户名,唯一")
_comment_column("users", "email", "邮箱,唯一")
_comment_column("users", "phone", "手机号,唯一")
_comment_column("users", "hashed_password", "加密后的密码")
_comment_column("users", "avatar", "头像URL")
_comment_column("users", "credits", "账户积分余额")
_comment_column("users", "is_active", "是否启用,True启用")
_comment_column("users", "is_admin", "是否管理员,True管理员")
_comment_column("users", "user_type", "用户类型:frontend前台用户,admin后台管理员")
_comment_column("users", "frontend_user_kind", "前台用户类型:internal内部用户,external外部用户")
_comment_column("users", "team_id", "当前归属团队ID,仅前台用户有意义")
_comment_column("users", "last_login_at", "最后登录时间")
_comment_column("users", "password_set_at", "密码设置时间,NULL表示未设置密码")
_comment_column("users", "allowed_menus", "允许访问的菜单列表(JSON),NULL表示继承默认")
_comment_column("users", "private_portrait_asset_limit", "私域人像素材总量上限,0表示关闭模块")
_comment_column("users", "created_at", "创建时间")
_comment_column("users", "updated_at", "更新时间")
# ============================================================
# projects 表
# ============================================================
_comment_table("projects", "项目表")
_comment_column("projects", "id", "主键ID")
_comment_column("projects", "user_id", "所属用户ID")
_comment_column("projects", "name", "项目名称")
_comment_column("projects", "industry", "所属行业")
_comment_column("projects", "created_at", "创建时间")
_comment_column("projects", "updated_at", "更新时间")
_comment_column("projects", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# credit_ratios 表
# ============================================================
_comment_table("credit_ratios", "积分计费规则表")
_comment_column("credit_ratios", "id", "主键ID")
_comment_column("credit_ratios", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
_comment_column("credit_ratios", "gen_type", "生成类型:image图片,video视频")
_comment_column("credit_ratios", "resolution", "分辨率档位:图片(2K/4K) / 视频(480p/720p/1080p)")
_comment_column("credit_ratios", "ratio", "生成倍率,最终积分 = (基础积分+单位积分×时长/张数) × 倍率")
_comment_column("credit_ratios", "base_credits", "生成基础积分")
_comment_column("credit_ratios", "per_second_credits", "视频每秒积分 / 图片每张积分")
_comment_column("credit_ratios", "input_video_ratio", "传入视频积分倍率")
_comment_column("credit_ratios", "input_video_base_credits", "传入视频基础积分")
_comment_column("credit_ratios", "input_video_per_second_credits", "传入视频每秒积分")
_comment_column("credit_ratios", "input_image_ratio", "传入图片积分倍率")
_comment_column("credit_ratios", "input_image_base_credits", "传入图片基础积分")
_comment_column("credit_ratios", "input_image_per_image_credits", "传入图片每张积分")
_comment_column("credit_ratios", "created_at", "创建时间")
_comment_column("credit_ratios", "updated_at", "更新时间")
# ============================================================
# credit_records 表
# ============================================================
_comment_table("credit_records", "积分流水表")
_comment_column("credit_records", "id", "主键ID")
_comment_column("credit_records", "user_id", "所属用户ID")
_comment_column("credit_records", "type", "流水类型:charge扣费,recharge充值,refund退款,gift赠送")
_comment_column("credit_records", "amount", "流水金额,扣费为负数,充值/退款/赠送为正数")
_comment_column("credit_records", "balance_after", "流水后账户余额")
_comment_column("credit_records", "description", "流水描述")
_comment_column("credit_records", "related_id", "关联业务ID,如生成任务ID/订单ID")
_comment_column("credit_records", "biz_key", "业务幂等键,格式如 owner_type:owner_id:attempt_no:charge_kind:action")
_comment_column("credit_records", "refund_for_biz_key", "退款时,对应的扣费biz_key")
_comment_column("credit_records", "owner_type", "归属类型:chat_generation_task/ generation_record等")
_comment_column("credit_records", "owner_id", "归属业务记录ID")
_comment_column("credit_records", "attempt_no", "计费尝试次数,重试时递增")
_comment_column("credit_records", "charge_kind", "扣费大类:media媒体生成,prompt提示词等")
_comment_column("credit_records", "charge_action", "扣费动作:charge扣费,refund退款")
_comment_column("credit_records", "credit_subject", "计费科目:image/video/text")
_comment_column("credit_records", "media_type", "媒体类型:与credit_subject配合细分")
_comment_column("credit_records", "billing_scene", "计费场景:如chat_creation、project等")
_comment_column("credit_records", "source_module", "来源模块:generation_record/module_generation等")
_comment_column("credit_records", "source_project_id", "来源项目ID")
_comment_column("credit_records", "source_step_id", "来源步骤ID")
_comment_column("credit_records", "source_step_code", "来源步骤编码")
_comment_column("credit_records", "token_usage_id", "关联Token消耗记录ID")
_comment_column("credit_records", "input_tokens", "输入Token数量快照")
_comment_column("credit_records", "output_tokens", "输出Token数量快照")
_comment_column("credit_records", "total_tokens", "总Token数量快照")
_comment_column("credit_records", "engine_type", "引擎类型:image/video/text")
_comment_column("credit_records", "engine_id", "使用的引擎ID")
_comment_column("credit_records", "engine_name", "引擎名称快照")
_comment_column("credit_records", "engine_provider", "引擎供应商快照:ark/其他")
_comment_column("credit_records", "engine_model_name", "引擎模型名快照")
_comment_column("credit_records", "user_type_snapshot", "用户类型快照:frontend/admin")
_comment_column("credit_records", "frontend_user_kind_snapshot", "前台用户类型快照:internal/external")
_comment_column("credit_records", "team_id_snapshot", "团队ID快照,流水发生时的归属团队")
_comment_column("credit_records", "team_name_snapshot", "团队名称快照")
_comment_column("credit_records", "created_at", "创建时间")
_comment_column("credit_records", "updated_at", "更新时间")
# ============================================================
# chat_generation_tasks 表
# ============================================================
_comment_table("chat_generation_tasks", "AI创作任务表(不绑定项目的聊天式生成)")
_comment_column("chat_generation_tasks", "id", "主键ID,顶层/子任务ID")
_comment_column("chat_generation_tasks", "user_id", "所属用户ID")
_comment_column("chat_generation_tasks", "original_prompt", "原始用户提示词")
_comment_column("chat_generation_tasks", "optimized_prompt", "优化后的提示词")
_comment_column("chat_generation_tasks", "gen_type", "生成类型:image图片,video视频")
_comment_column("chat_generation_tasks", "duration", "视频时长(秒)")
_comment_column("chat_generation_tasks", "aspect_ratio", "视频比例:16:9/9:16等")
_comment_column("chat_generation_tasks", "resolution", "用户选择的分辨率")
_comment_column("chat_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
_comment_column("chat_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
_comment_column("chat_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
_comment_column("chat_generation_tasks", "image_size", "图片分辨率档位:2K/4K")
_comment_column("chat_generation_tasks", "image_proportion", "图片比例:1:1/16:9等")
_comment_column("chat_generation_tasks", "image_px", "图片像素,如2048×2048")
_comment_column("chat_generation_tasks", "status", "任务状态:generating/success/failed等")
_comment_column("chat_generation_tasks", "pipeline_stage", "流水线阶段:prompt_optimized/resource_generated等")
_comment_column("chat_generation_tasks", "generation_mode", "生成模式:chatapi_async单份异步/chatapi_main多份主任务")
_comment_column("chat_generation_tasks", "parent_task_id", "父任务ID,多份生成时子任务关联主任务")
_comment_column("chat_generation_tasks", "generation_count", "生成份数,主任务表示总共多少份")
_comment_column("chat_generation_tasks", "generation_index", "第N份子任务,主任务为NULL")
_comment_column("chat_generation_tasks", "generation_attempt_no", "生成尝试次数,重试时递增")
_comment_column("chat_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
_comment_column("chat_generation_tasks", "provider_create_claim_token", "供应商创建任务分布式租约token")
_comment_column("chat_generation_tasks", "provider_create_lease_until", "供应商创建租约过期时间")
_comment_column("chat_generation_tasks", "provider_create_started_at", "供应商创建任务开始时间")
_comment_column("chat_generation_tasks", "media_references", "参考素材JSON数组")
_comment_column("chat_generation_tasks", "provider_task_id", "供应商任务ID")
_comment_column("chat_generation_tasks", "seedance_task_id", "Seedance任务ID(兼容字段)")
_comment_column("chat_generation_tasks", "remote_result_url", "供应商返回的远程资源URL")
_comment_column("chat_generation_tasks", "image_url", "图片结果URL")
_comment_column("chat_generation_tasks", "video_url", "视频结果URL")
_comment_column("chat_generation_tasks", "video_cover_url", "视频封面URL")
_comment_column("chat_generation_tasks", "engine_id", "使用的引擎ID")
_comment_column("chat_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
_comment_column("chat_generation_tasks", "provider_response_json", "供应商完整响应JSON")
_comment_column("chat_generation_tasks", "credits_cost", "媒体生成消耗的总积分")
_comment_column("chat_generation_tasks", "text_credits_cost", "提示词优化消耗积分")
_comment_column("chat_generation_tasks", "text_tokens_used", "提示词优化Token消耗")
_comment_column("chat_generation_tasks", "video_tokens_used", "视频生成Token消耗")
_comment_column("chat_generation_tasks", "image_tokens_used", "图片生成Token消耗")
_comment_column("chat_generation_tasks", "retry_count", "重试次数(兼容旧字段)")
_comment_column("chat_generation_tasks", "manual_retry_count", "用户手动重试次数")
_comment_column("chat_generation_tasks", "poll_error_count", "轮询错误次数")
_comment_column("chat_generation_tasks", "poll_count", "轮询总次数")
_comment_column("chat_generation_tasks", "last_poll_at", "最后一次轮询时间")
_comment_column("chat_generation_tasks", "poll_started_at", "本次轮询开始时间")
_comment_column("chat_generation_tasks", "next_poll_at", "下一次轮询触发时间")
_comment_column("chat_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
_comment_column("chat_generation_tasks", "poll_claim_token", "轮询分布式租约token")
_comment_column("chat_generation_tasks", "poll_lease_until", "轮询租约过期时间")
_comment_column("chat_generation_tasks", "deadline_at", "任务截止时间,超时自动失败")
_comment_column("chat_generation_tasks", "generated_at", "资源生成完成时间")
_comment_column("chat_generation_tasks", "error_message", "错误信息")
_comment_column("chat_generation_tasks", "idempotency_key", "幂等键,防重复创建")
_comment_column("chat_generation_tasks", "download_celery_task_id", "下载步骤Celery任务ID")
_comment_column("chat_generation_tasks", "download_enqueued_at", "下载入队时间")
_comment_column("chat_generation_tasks", "download_started_at", "下载开始时间")
_comment_column("chat_generation_tasks", "download_claim_token", "下载租约token")
_comment_column("chat_generation_tasks", "download_lease_until", "下载租约过期时间")
_comment_column("chat_generation_tasks", "download_next_retry_at", "下载下次重试时间")
_comment_column("chat_generation_tasks", "download_attempt_count", "下载重试次数")
_comment_column("chat_generation_tasks", "download_last_error", "下载最后一次错误信息")
_comment_column("chat_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
_comment_column("chat_generation_tasks", "created_at", "创建时间")
_comment_column("chat_generation_tasks", "updated_at", "更新时间")
_comment_column("chat_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# generation_records 表
# ============================================================
_comment_table("generation_records", "项目生成记录表(绑定项目的旧版生成)")
_comment_column("generation_records", "id", "主键ID")
_comment_column("generation_records", "user_id", "所属用户ID")
_comment_column("generation_records", "project_id", "所属项目ID")
_comment_column("generation_records", "original_prompt", "原始提示词")
_comment_column("generation_records", "optimized_prompt", "优化后的提示词")
_comment_column("generation_records", "prompt_usage_snapshot_json", "提示词消耗快照JSON")
_comment_column("generation_records", "gen_type", "生成类型:image/video")
_comment_column("generation_records", "duration", "视频时长秒数")
_comment_column("generation_records", "aspect_ratio", "视频比例")
_comment_column("generation_records", "resolution", "分辨率档位")
_comment_column("generation_records", "provider_generation_resolution", "供应商实际分辨率")
_comment_column("generation_records", "video_upscale_enabled_snapshot", "是否开启视频超分")
_comment_column("generation_records", "video_upscale_snapshot_json", "视频超分快照JSON")
_comment_column("generation_records", "image_size", "图片分辨率档位")
_comment_column("generation_records", "image_proportion", "图片比例")
_comment_column("generation_records", "image_px", "图片像素尺寸")
_comment_column("generation_records", "status", "任务状态")
_comment_column("generation_records", "pipeline_stage", "流水线阶段")
_comment_column("generation_records", "video_url", "视频结果URL")
_comment_column("generation_records", "video_cover_url", "视频封面URL")
_comment_column("generation_records", "image_url", "图片结果URL")
_comment_column("generation_records", "media_references", "参考素材JSON数组")
_comment_column("generation_records", "include_media_references", "是否包含参考素材")
_comment_column("generation_records", "video_url_expires_at", "视频URL过期时间")
_comment_column("generation_records", "seedance_task_id", "Seedance任务ID")
_comment_column("generation_records", "credits_cost", "媒体生成消耗积分")
_comment_column("generation_records", "text_credits_cost", "提示词消耗积分")
_comment_column("generation_records", "text_tokens_used", "提示词Token数")
_comment_column("generation_records", "video_tokens_used", "视频Token数")
_comment_column("generation_records", "image_tokens_used", "图片Token数")
_comment_column("generation_records", "generated_at", "生成完成时间")
_comment_column("generation_records", "error_message", "错误信息")
_comment_column("generation_records", "idempotency_key", "幂等键")
_comment_column("generation_records", "generation_attempt_no", "生成尝试次数")
_comment_column("generation_records", "resource_generation_started_at", "资源生成开始时间")
_comment_column("generation_records", "deadline_at", "任务截止时间")
_comment_column("generation_records", "engine_id", "使用引擎ID")
_comment_column("generation_records", "engine_snapshot_json", "引擎参数快照JSON")
_comment_column("generation_records", "provider_response_json", "供应商响应JSON")
_comment_column("generation_records", "remote_result_url", "远程资源URL")
_comment_column("generation_records", "provider_create_claim_token", "供应商创建租约token")
_comment_column("generation_records", "provider_create_lease_until", "供应商创建租约过期")
_comment_column("generation_records", "provider_create_started_at", "供应商创建开始时间")
_comment_column("generation_records", "retry_count", "重试次数(兼容)")
_comment_column("generation_records", "manual_retry_count", "手动重试次数")
_comment_column("generation_records", "poll_error_count", "轮询错误次数")
_comment_column("generation_records", "poll_count", "轮询次数")
_comment_column("generation_records", "last_poll_at", "最后轮询时间")
_comment_column("generation_records", "poll_started_at", "轮询开始时间")
_comment_column("generation_records", "next_poll_at", "下次轮询时间")
_comment_column("generation_records", "poll_interval_seconds", "轮询间隔秒")
_comment_column("generation_records", "poll_claim_token", "轮询租约token")
_comment_column("generation_records", "poll_lease_until", "轮询租约过期")
_comment_column("generation_records", "download_celery_task_id", "下载Celery任务ID")
_comment_column("generation_records", "download_enqueued_at", "下载开始入队时间")
_comment_column("generation_records", "download_started_at", "下载开始时间")
_comment_column("generation_records", "download_claim_token", "下载租约token")
_comment_column("generation_records", "download_lease_until", "下载租约过期")
_comment_column("generation_records", "download_next_retry_at", "下载下次重试")
_comment_column("generation_records", "download_attempt_count", "下载重试次数")
_comment_column("generation_records", "download_last_error", "下载最后错误")
_comment_column("generation_records", "download_storage_date_dir", "下载存储日期目录")
_comment_column("generation_records", "created_at", "创建时间")
_comment_column("generation_records", "updated_at", "更新时间")
_comment_column("generation_records", "deleted_at", "软删除时间")
# ============================================================
# generated_resources 表
# ============================================================
_comment_table("generated_resources", "生成资源账本表(统一记录所有生成的图片/视频)")
_comment_column("generated_resources", "id", "主键ID")
_comment_column("generated_resources", "user_id", "所属用户ID")
_comment_column("generated_resources", "resource_type", "资源类型:image/video")
_comment_column("generated_resources", "resource_url", "资源访问URL")
_comment_column("generated_resources", "remote_url", "供应商原始远程URL")
_comment_column("generated_resources", "storage_type", "存储类型:local本地/oss对象存储")
_comment_column("generated_resources", "storage_path", "存储路径")
_comment_column("generated_resources", "file_name", "文件名,平台素材名称")
_comment_column("generated_resources", "file_size_bytes", "文件大小(字节)")
_comment_column("generated_resources", "source_model", "来源模型:chat_generation_task/generation_record")
_comment_column("generated_resources", "source_model_module", "来源模块描述")
_comment_column("generated_resources", "source_id", "来源记录ID")
_comment_column("generated_resources", "engine_id", "使用引擎ID")
_comment_column("generated_resources", "engine_type", "引擎类型:image/video")
_comment_column("generated_resources", "provider", "供应商:ark/其他")
_comment_column("generated_resources", "model_name", "模型名称")
_comment_column("generated_resources", "generated_at", "资源生成完成时间")
_comment_column("generated_resources", "resource_month", "资源归属月份,按月统计")
_comment_column("generated_resources", "extra_json", "扩展字段JSON")
_comment_column("generated_resources", "created_at", "创建时间")
_comment_column("generated_resources", "updated_at", "更新时间")
_comment_column("generated_resources", "deleted_at", "软删除时间")
# ============================================================
# upload_resources 表
# ============================================================
_comment_table("upload_resources", "用户上传资源账本表(用户上传/模块上传/切片文件)")
_comment_column("upload_resources", "id", "主键ID")
_comment_column("upload_resources", "user_id", "所属用户ID")
_comment_column("upload_resources", "module", "所属模块:conversation/generation_record等")
_comment_column("upload_resources", "resource_type", "资源类型:image/video/audio/file")
_comment_column("upload_resources", "resource_url", "资源访问URL")
_comment_column("upload_resources", "storage_path", "存储路径,唯一")
_comment_column("upload_resources", "file_name", "原始文件名")
_comment_column("upload_resources", "file_ext", "文件扩展名")
_comment_column("upload_resources", "mime_type", "MIME类型")
_comment_column("upload_resources", "file_size_bytes", "文件大小(字节)")
_comment_column("upload_resources", "duration_seconds", "音视频时长(秒)")
_comment_column("upload_resources", "duration_source", "时长来源:probe探测/用户设置")
_comment_column("upload_resources", "width", "图片/视频宽度(像素)")
_comment_column("upload_resources", "height", "图片/视频高度(像素)")
_comment_column("upload_resources", "source_model", "关联业务模型")
_comment_column("upload_resources", "source_id", "关联业务记录ID")
_comment_column("upload_resources", "source_module", "关联业务模块")
_comment_column("upload_resources", "bind_status", "绑定状态:pending待绑定/bound已绑定/unbound已解绑")
_comment_column("upload_resources", "delete_policy", "删除策略:user_deletable用户可删/keep_forever永久保留")
_comment_column("upload_resources", "created_by", "创建来源:api用户上传/worker系统生成")
_comment_column("upload_resources", "metadata_json", "媒体元数据JSON")
_comment_column("upload_resources", "capacity_released_at", "容量统计中已释放时间")
_comment_column("upload_resources", "physical_deleted_at", "物理文件删除时间")
_comment_column("upload_resources", "file_delete_status", "文件删除状态:active待删/deleting删除中/deleted已删除/error失败")
_comment_column("upload_resources", "file_delete_error", "文件删除失败信息")
_comment_column("upload_resources", "created_at", "创建时间")
_comment_column("upload_resources", "updated_at", "更新时间")
_comment_column("upload_resources", "deleted_at", "软删除时间")
# ============================================================
# image_engines 表
# ============================================================
_comment_table("image_engines", "图片生成引擎配置表")
_comment_column("image_engines", "id", "主键ID")
_comment_column("image_engines", "name", "引擎显示名称")
_comment_column("image_engines", "provider", "供应商:ark/其他")
_comment_column("image_engines", "api_base", "API基础地址")
_comment_column("image_engines", "api_key", "API密钥")
_comment_column("image_engines", "model_name", "模型名")
_comment_column("image_engines", "supported_models", "支持的模型列表JSON")
_comment_column("image_engines", "supported_sizes", "支持尺寸JSON{分辨率:{比例:像素}}")
_comment_column("image_engines", "default_size", "默认分辨率档位")
_comment_column("image_engines", "max_image_count", "允许生成图片数量上限")
_comment_column("image_engines", "multi_generation_enabled", "是否允许多份生成")
_comment_column("image_engines", "max_generation_count", "多份生成最大份数")
_comment_column("image_engines", "multi_image_max_images", "组图接口参考图+生成图数量上限")
_comment_column("image_engines", "max_reference_image_count", "最多参考图片张数")
_comment_column("image_engines", "output_format", "输出格式,空表示使用默认")
_comment_column("image_engines", "generate_url", "生成接口URL,留空使用SDK默认")
_comment_column("image_engines", "is_active", "是否启用")
_comment_column("image_engines", "priority", "排序优先级,越大越优先")
_comment_column("image_engines", "created_at", "创建时间")
_comment_column("image_engines", "updated_at", "更新时间")
_comment_column("image_engines", "deleted_at", "软删除时间")
# ============================================================
# video_engines 表
# ============================================================
_comment_table("video_engines", "视频生成引擎配置表")
_comment_column("video_engines", "id", "主键ID")
_comment_column("video_engines", "name", "引擎显示名称")
_comment_column("video_engines", "provider", "供应商:ark/其他")
_comment_column("video_engines", "api_base", "API基础地址")
_comment_column("video_engines", "api_key", "API密钥")
_comment_column("video_engines", "model_name", "模型名")
_comment_column("video_engines", "supported_ratios", "支持比例JSON数组")
_comment_column("video_engines", "supported_resolutions", "支持分辨率JSON数组")
_comment_column("video_engines", "supported_durations", "支持时长JSON数组")
_comment_column("video_engines", "max_duration", "最大时长秒数")
_comment_column("video_engines", "max_image_count", "最多参考图片张数,0表示不支持")
_comment_column("video_engines", "max_video_count", "最多参考视频段数,0表示不支持")
_comment_column("video_engines", "max_audio_count", "最多参考音频段数,0表示不支持")
_comment_column("video_engines", "multi_generation_enabled", "是否允许多份生成")
_comment_column("video_engines", "max_generation_count", "多份生成最大份数")
_comment_column("video_engines", "supports_first_last_frame", "是否支持首尾帧参考")
_comment_column("video_engines", "supports_universal_reference", "是否支持通用参考素材")
_comment_column("video_engines", "generate_url", "生成接口URL")
_comment_column("video_engines", "query_url", "查询接口URL")
_comment_column("video_engines", "is_active", "是否启用")
_comment_column("video_engines", "priority", "排序优先级")
_comment_column("video_engines", "created_at", "创建时间")
_comment_column("video_engines", "updated_at", "更新时间")
_comment_column("video_engines", "deleted_at", "软删除时间")
# ============================================================
# model_configs 表
# ============================================================
_comment_table("model_configs", "文本模型配置表(提示词优化等文本模型)")
_comment_column("model_configs", "id", "主键ID")
_comment_column("model_configs", "name", "模型显示名称")
_comment_column("model_configs", "provider", "供应商")
_comment_column("model_configs", "api_base", "API基础地址")
_comment_column("model_configs", "api_key", "API密钥")
_comment_column("model_configs", "model_name", "模型名")
_comment_column("model_configs", "weight", "权重,权重选择时使用")
_comment_column("model_configs", "max_tokens", "最大输出Token数")
_comment_column("model_configs", "temperature", "采样温度")
_comment_column("model_configs", "is_active", "是否启用")
_comment_column("model_configs", "priority", "排序优先级")
_comment_column("model_configs", "created_at", "创建时间")
_comment_column("model_configs", "updated_at", "更新时间")
_comment_column("model_configs", "deleted_at", "软删除时间")
# ============================================================
# system_configs 表
# ============================================================
_comment_table("system_configs", "系统配置表")
_comment_column("system_configs", "id", "主键ID")
_comment_column("system_configs", "key", "配置键名,唯一")
_comment_column("system_configs", "value", "配置值")
_comment_column("system_configs", "description", "配置说明")
_comment_column("system_configs", "created_at", "创建时间")
_comment_column("system_configs", "updated_at", "更新时间")
# ============================================================
# operation_logs 表
# ============================================================
_comment_table("operation_logs", "操作日志表")
_comment_column("operation_logs", "id", "主键ID")
_comment_column("operation_logs", "user_id", "操作用户ID")
_comment_column("operation_logs", "username", "操作用户名")
_comment_column("operation_logs", "action", "操作动作:CREATE/UPDATE/DELETE等")
_comment_column("operation_logs", "method", "HTTP方法:GET/POST/PUT/DELETE")
_comment_column("operation_logs", "path", "请求路径")
_comment_column("operation_logs", "detail", "操作详情JSON")
_comment_column("operation_logs", "ip", "客户端IP")
_comment_column("operation_logs", "created_at", "创建时间")
_comment_column("operation_logs", "updated_at", "更新时间")
# ============================================================
# notifications 表
# ============================================================
_comment_table("notifications", "通知消息表")
_comment_column("notifications", "id", "主键ID")
_comment_column("notifications", "user_id", "接收用户IDNULL表示全体广播")
_comment_column("notifications", "title", "通知标题")
_comment_column("notifications", "content", "通知内容")
_comment_column("notifications", "type", "通知类型:system系统公告/billing账单通知等")
_comment_column("notifications", "is_read", "是否已读")
_comment_column("notifications", "related_id", "关联业务ID")
_comment_column("notifications", "created_at", "创建时间")
_comment_column("notifications", "updated_at", "更新时间")
# ============================================================
# recharge_packages 表
# ============================================================
_comment_table("recharge_packages", "积分充值套餐表")
_comment_column("recharge_packages", "id", "主键ID")
_comment_column("recharge_packages", "name", "套餐名称")
_comment_column("recharge_packages", "credits", "套餐包含积分")
_comment_column("recharge_packages", "price", "套餐价格(元)")
_comment_column("recharge_packages", "bonus_credits", "赠送积分")
_comment_column("recharge_packages", "description", "套餐描述")
_comment_column("recharge_packages", "package_type", "套餐类型:normal普通/gift赠送首充等")
_comment_column("recharge_packages", "is_gift", "是否赠送套餐")
_comment_column("recharge_packages", "is_active", "是否启用")
_comment_column("recharge_packages", "sort_order", "排序值,越小越靠前")
_comment_column("recharge_packages", "created_at", "创建时间")
_comment_column("recharge_packages", "updated_at", "更新时间")
# ============================================================
# payment_orders 表
# ============================================================
_comment_table("payment_orders", "支付订单表")
_comment_column("payment_orders", "id", "主键ID")
_comment_column("payment_orders", "user_id", "下单用户ID")
_comment_column("payment_orders", "order_no", "订单号,唯一")
_comment_column("payment_orders", "amount", "支付金额(元)")
_comment_column("payment_orders", "credits", "获得积分总数(含赠送)")
_comment_column("payment_orders", "payment_method", "支付方式:wxpay/alipay等")
_comment_column("payment_orders", "status", "订单状态:pending待支付/paid已支付/refunded已退款/failed失败")
_comment_column("payment_orders", "paid_at", "支付成功时间")
_comment_column("payment_orders", "trade_no", "第三方支付流水号")
_comment_column("payment_orders", "refund_trade_no", "退款流水号")
_comment_column("payment_orders", "refunded_at", "退款完成时间")
_comment_column("payment_orders", "refund_amount", "退款金额")
_comment_column("payment_orders", "created_at", "创建时间")
_comment_column("payment_orders", "updated_at", "更新时间")
# ============================================================
# video_upscale_tasks 表
# ============================================================
_comment_table("video_upscale_tasks", "视频超分任务表")
_comment_column("video_upscale_tasks", "id", "主键ID")
_comment_column("video_upscale_tasks", "chat_generation_task_id", "关联AI创作任务ID,与generation_record_id二选一")
_comment_column("video_upscale_tasks", "generation_record_id", "关联项目生成记录ID,与chat_generation_task_id二选一")
_comment_column("video_upscale_tasks", "api_generation_task_id", "关联API生成任务ID")
_comment_column("video_upscale_tasks", "status", "任务状态:pending/processing/success/failed")
_comment_column("video_upscale_tasks", "stage", "阶段:upscale_queued/upscale_processing等")
_comment_column("video_upscale_tasks", "processor_key", "处理节点标识")
_comment_column("video_upscale_tasks", "attempt_count", "执行尝试次数")
_comment_column("video_upscale_tasks", "failure_count", "失败次数")
_comment_column("video_upscale_tasks", "manual_retry_count", "手动重试次数")
_comment_column("video_upscale_tasks", "next_retry_at", "下次重试时间")
_comment_column("video_upscale_tasks", "last_error", "最后错误信息")
_comment_column("video_upscale_tasks", "source_local_path", "源视频本地路径")
_comment_column("video_upscale_tasks", "source_file_size_bytes", "源文件大小(字节)")
_comment_column("video_upscale_tasks", "source_width", "源视频宽度")
_comment_column("video_upscale_tasks", "source_height", "源视频高度")
_comment_column("video_upscale_tasks", "source_duration_seconds", "源视频时长秒数")
_comment_column("video_upscale_tasks", "source_deleted_at", "源文件删除时间")
_comment_column("video_upscale_tasks", "source_delete_error", "源文件删除错误")
_comment_column("video_upscale_tasks", "source_remote_url", "源文件远程URL")
_comment_column("video_upscale_tasks", "source_remote_url_signed_at", "远程URL签名时间")
_comment_column("video_upscale_tasks", "source_remote_url_expires_at", "远程URL过期时间")
_comment_column("video_upscale_tasks", "source_remote_url_last_probe_at", "远程URL最后探测时间")
_comment_column("video_upscale_tasks", "source_remote_url_probe_status", "远程URL探测状态")
_comment_column("video_upscale_tasks", "input_source_type", "输入源类型:local/remote")
_comment_column("video_upscale_tasks", "input_source_fallback_count", "输入源回退次数")
_comment_column("video_upscale_tasks", "target_width", "目标宽度像素")
_comment_column("video_upscale_tasks", "target_height", "目标高度像素")
_comment_column("video_upscale_tasks", "effective_target_width", "实际生效目标宽度")
_comment_column("video_upscale_tasks", "effective_target_height", "实际生效目标高度")
_comment_column("video_upscale_tasks", "provider_task_id", "供应商超分任务ID")
_comment_column("video_upscale_tasks", "provider_request_json", "供应商请求JSON")
_comment_column("video_upscale_tasks", "provider_response_json", "供应商响应JSON")
_comment_column("video_upscale_tasks", "provider_output_url", "供应商输出URL")
_comment_column("video_upscale_tasks", "provider_output_url_expires_at", "供应商输出URL过期")
_comment_column("video_upscale_tasks", "provider_submitted_at", "提交供应商时间")
_comment_column("video_upscale_tasks", "final_local_path", "最终本地文件路径")
_comment_column("video_upscale_tasks", "final_resource_url", "最终资源访问URL")
_comment_column("video_upscale_tasks", "final_file_size_bytes", "最终文件大小(字节)")
_comment_column("video_upscale_tasks", "celery_task_id", "Celery任务ID")
_comment_column("video_upscale_tasks", "lease_token", "分布式租约token")
_comment_column("video_upscale_tasks", "lease_until", "租约过期时间")
_comment_column("video_upscale_tasks", "started_at", "开始处理时间")
_comment_column("video_upscale_tasks", "completed_at", "完成时间")
_comment_column("video_upscale_tasks", "failed_at", "失败时间")
_comment_column("video_upscale_tasks", "created_at", "创建时间")
_comment_column("video_upscale_tasks", "updated_at", "更新时间")
# ============================================================
# shot_replicate_task_sets 表
# ============================================================
_comment_table("shot_replicate_task_sets", "拆镜复刻总任务集")
_comment_column("shot_replicate_task_sets", "id", "主键ID")
_comment_column("shot_replicate_task_sets", "user_id", "所属用户ID")
_comment_column("shot_replicate_task_sets", "title", "任务集标题")
_comment_column("shot_replicate_task_sets", "video_url", "上传视频访问URL")
_comment_column("shot_replicate_task_sets", "video_path", "上传视频存储路径")
_comment_column("shot_replicate_task_sets", "video_duration_seconds", "上传视频总时长秒数")
_comment_column("shot_replicate_task_sets", "status", "总任务状态:pending_analysis/analyzing/analysis_done等")
_comment_column("shot_replicate_task_sets", "analysis_status", "AI分析状态:pending/processing/success/failed")
_comment_column("shot_replicate_task_sets", "split_status", "切片状态:none/slicing/sliced")
_comment_column("shot_replicate_task_sets", "original_video_content", "原视频内容描述")
_comment_column("shot_replicate_task_sets", "original_video_category", "原视频行业分类")
_comment_column("shot_replicate_task_sets", "original_video_audience", "原视频目标受众")
_comment_column("shot_replicate_task_sets", "ai_suggestion_json", "AI复刻建议JSON")
_comment_column("shot_replicate_task_sets", "analysis_raw_json", "AI分析原始JSON")
_comment_column("shot_replicate_task_sets", "analysis_result_json", "AI分析结果JSON")
_comment_column("shot_replicate_task_sets", "segment_count", "总拆镜头数")
_comment_column("shot_replicate_task_sets", "completed_segment_count", "已完成镜头数")
_comment_column("shot_replicate_task_sets", "failed_segment_count", "失败镜头数")
_comment_column("shot_replicate_task_sets", "analysis_attempt_no", "AI分析尝试次数")
_comment_column("shot_replicate_task_sets", "analysis_claim_token", "AI分析租约token")
_comment_column("shot_replicate_task_sets", "analysis_started_at", "AI分析开始时间")
_comment_column("shot_replicate_task_sets", "analysis_lease_until", "AI分析租约过期")
_comment_column("shot_replicate_task_sets", "analysis_error_message", "AI分析错误信息")
_comment_column("shot_replicate_task_sets", "split_error_message", "切片错误信息")
_comment_column("shot_replicate_task_sets", "idempotency_key", "幂等键")
_comment_column("shot_replicate_task_sets", "created_at", "创建时间")
_comment_column("shot_replicate_task_sets", "updated_at", "更新时间")
_comment_column("shot_replicate_task_sets", "deleted_at", "软删除时间")
# ============================================================
# teams 表(已经有部分comment,补齐未加的)
# ============================================================
_comment_table("teams", "团队表")
_comment_column("teams", "id", "主键ID")
_comment_column("teams", "name", "团队名称")
_comment_column("teams", "code", "团队编码")
_comment_column("teams", "description", "团队备注")
_comment_column("teams", "status", "团队状态:active启用,disabled禁用")
_comment_column("teams", "sort_order", "排序值,越小越靠前")
_comment_column("teams", "manager_id", "团队管理人ID")
_comment_column("teams", "created_at", "创建时间")
_comment_column("teams", "updated_at", "更新时间")
_comment_column("teams", "deleted_at", "软删除时间")
# ============================================================
# user_resource_capacity_configs 表(已部分有comment
# ============================================================
_comment_table("user_resource_capacity_configs", "用户个人容量配置表")
_comment_column("user_resource_capacity_configs", "id", "主键ID")
_comment_column("user_resource_capacity_configs", "user_id", "用户ID")
_comment_column("user_resource_capacity_configs", "enabled", "是否启用该用户个人容量限制")
_comment_column("user_resource_capacity_configs", "limit_value", "容量数值,最小1,最多3位小数")
_comment_column("user_resource_capacity_configs", "limit_unit", "容量单位:MB/GB/TB")
_comment_column("user_resource_capacity_configs", "limit_bytes", "换算后的容量字节数")
_comment_column("user_resource_capacity_configs", "created_at", "创建时间")
_comment_column("user_resource_capacity_configs", "updated_at", "更新时间")
# ============================================================
# resources_material 表(已部分有comment
# ============================================================
_comment_table("resources_material", "资源素材对接表(第三方平台素材同步)")
_comment_column("resources_material", "id", "主键")
_comment_column("resources_material", "oauth_id", "授权表user_oauth自增id")
_comment_column("resources_material", "advertiser_id", "广告主id")
_comment_column("resources_material", "target_table", "资源表名称")
_comment_column("resources_material", "target_id", "资源表id")
_comment_column("resources_material", "material_id", "素材id")
_comment_column("resources_material", "upload_id", "上传资源平台id,图片id,视频id")
_comment_column("resources_material", "resource_type", "资源类型,image或者video")
_comment_column("resources_material", "user_id", "用户登录id")
_comment_column("resources_material", "task_id", "前测任务id")
_comment_column("resources_material", "note", "前测失败备注或者其他备注")
_comment_column("resources_material", "status", "前测状态(FAILED/PENDING/SUCCESS")
_comment_column("resources_material", "pre_result", "前测结果,JSON数组对象")
_comment_column("resources_material", "pre_test_template_id", "前测模板id")
_comment_column("resources_material", "created_at", "创建时间")
_comment_column("resources_material", "updated_at", "更新时间")
_comment_column("resources_material", "deleted_at", "软删除时间")
def downgrade() -> None:
# 注释回滚时选择清空所有注释即可,不影响功能
op.execute("""
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name IN (
'users', 'projects', 'credit_ratios', 'credit_records',
'chat_generation_tasks', 'generation_records',
'generated_resources', 'upload_resources',
'image_engines', 'video_engines', 'model_configs',
'system_configs', 'operation_logs', 'notifications',
'recharge_packages', 'payment_orders',
'video_upscale_tasks', 'shot_replicate_task_sets',
'teams', 'user_resource_capacity_configs',
'resources_material'
)
LOOP
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
END LOOP;
END $$;
""")
# 清空表注释
for t in [
"users", "projects", "credit_ratios", "credit_records",
"chat_generation_tasks", "generation_records",
"generated_resources", "upload_resources",
"image_engines", "video_engines", "model_configs",
"system_configs", "operation_logs", "notifications",
"recharge_packages", "payment_orders",
"video_upscale_tasks", "shot_replicate_task_sets",
"teams", "user_resource_capacity_configs",
"resources_material",
]:
op.execute(f"COMMENT ON TABLE {t} IS NULL")
@@ -0,0 +1,193 @@
"""2026080401_add_vp_v3_virtual_portrait_tables_and_quota
Revision ID: 2026080401
Revises: 2026073101
Create Date: 2026-08-04 00:00:00.000000
API V3 虚拟素材库中转表 + 密钥配额表
1. vp_v3_api_key_quotas 每个 API Key 的虚拟素材配额项目数/素材数/存储 MB
2. vp_v3_projects V3 虚拟素材项目=火山一个 AssetGroup
3. vp_v3_assets V3 虚拟素材图片/视频
备注
* 数据与前台用户私域素材库private_portrait_* 完全隔离
* 归属按 api_keys.idV3 调用方而非 users.id
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2026080401'
down_revision: Union[str, None] = '2026073101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ==============================================================
# 1. vp_v3_api_key_quotasAPI Key 虚拟素材配额
# ==============================================================
op.create_table(
'vp_v3_api_key_quotas',
sa.Column('id', sa.String(length=32), nullable=False, comment='主键ID'),
sa.Column('api_key_id', sa.String(length=32), nullable=False,
comment='所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额'),
# 配额上限(默认 0=不可用)
sa.Column('project_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='虚拟项目上限,默认 0 不可创建'),
sa.Column('asset_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='虚拟素材总数上限(图片+视频),默认 0 不可上传'),
sa.Column('storage_mb_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='上传存储上限 MB,默认 0 不可上传文件'),
# 已使用量(冗余,每次增删同步,和 COUNT 不一致时以 COUNT 为准)
sa.Column('project_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='已创建项目数(未删除)'),
sa.Column('asset_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='已上传素材数(未删除,图片+视频)'),
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='已占用存储 MB(未删除文件大小合计,1MB=1024*1024'),
sa.Column('remark', sa.Text(), nullable=True, comment='后台备注'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
comment='最后更新时间'),
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_unique_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
op.create_index('idx_vp_v3_api_key_quotas_api_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
# ==============================================================
# 2. vp_v3_projectsV3 虚拟素材项目
# ==============================================================
op.create_table(
'vp_v3_projects',
sa.Column('id', sa.String(length=32), nullable=False, comment='项目ID'),
sa.Column('api_key_id', sa.String(length=32), nullable=False,
comment='所属 API KeyV3 调用方)'),
sa.Column('name', sa.String(length=128), nullable=False, comment='项目展示名称'),
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='名称安全 slug(构建远端 GroupName 用)'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
comment='火山 ProjectName(快照)'),
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
comment='火山 AssetGroup Id'),
sa.Column('remote_group_name', sa.String(length=256), nullable=True,
comment='火山 AssetGroup Name 快照'),
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'active'"),
index=True,
comment='项目状态:active/creating_remote_group/create_group_failed/deleting'),
# 计数
sa.Column('asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('active_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('active_image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('active_video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
comment='项目占用存储 MB(未删除素材文件大小合计)'),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
# 远端删除状态
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True, comment='创建失败等错误信息'),
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应'),
# 软删除 + 时间
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
comment='最后更新时间'),
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('idx_vp_v3_projects_key_status_created', 'vp_v3_projects',
['api_key_id', 'status', 'created_at'])
op.create_index('idx_vp_v3_projects_remote_project_name', 'vp_v3_projects', ['remote_project_name'])
op.create_index('idx_vp_v3_projects_remote_group_id', 'vp_v3_projects', ['remote_group_id'])
op.execute(
"CREATE INDEX idx_vp_v3_projects_key_deleted ON vp_v3_projects (api_key_id, deleted_at)"
" WHERE deleted_at IS NULL;"
)
# ==============================================================
# 3. vp_v3_assetsV3 虚拟素材
# ==============================================================
op.create_table(
'vp_v3_assets',
sa.Column('id', sa.String(length=32), nullable=False, comment='素材ID'),
sa.Column('api_key_id', sa.String(length=32), nullable=False,
comment='所属 API KeyV3 调用方)'),
sa.Column('project_id', sa.String(length=32), nullable=False, comment='所属项目ID'),
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
comment='火山 ProjectName'),
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
comment='火山 AssetGroup Id'),
sa.Column('remote_asset_id', sa.String(length=128), nullable=True, comment='火山素材 Id'),
sa.Column('asset_type', sa.String(length=16), nullable=False, server_default=sa.text("'Image'"),
comment='素材类型:Image=图片 / Video=视频', index=True),
sa.Column('name', sa.String(length=128), nullable=True, comment='素材展示名称', index=True),
sa.Column('source_url', sa.Text(), nullable=False, comment='本地上传后的访问 URL'),
sa.Column('preview_url', sa.Text(), nullable=True, comment='给前端预览/显示用的 URL'),
sa.Column('remote_url', sa.Text(), nullable=True, comment='火山返回的资源访问 URL(可能带签名)'),
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True,
comment='remote_url 过期时间'),
sa.Column('upload_resource_id', sa.String(length=32), nullable=True, index=True,
comment='本地上传 resource_id,供容量释放用'),
sa.Column('video_duration', sa.Float(), nullable=True, comment='视频时长,秒'),
sa.Column('video_cover_url', sa.Text(), nullable=True, comment='视频封面预览'),
sa.Column('file_size_bytes', sa.Integer(), nullable=True, comment='素材文件大小,字节'),
sa.Column('mime_type', sa.String(length=128), nullable=True),
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'creating'"),
index=True,
comment='素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中'),
sa.Column('moderation_json', sa.Text(), nullable=True, comment='火山审核结果 JSON'),
sa.Column('error_message', sa.Text(), nullable=True, comment='失败原因'),
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应 JSON'),
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True, index=True,
comment='下次轮询时间(创建中状态自动轮询)'),
sa.Column('poll_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
# 软删除 + 时间
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
comment='最后更新时间'),
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['vp_v3_projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_unique_constraint('uq_vp_v3_assets_remote_asset_id', 'vp_v3_assets', ['remote_asset_id'])
op.create_index('idx_vp_v3_assets_key_status_created', 'vp_v3_assets',
['api_key_id', 'status', 'created_at'])
op.create_index('idx_vp_v3_assets_project_status_created', 'vp_v3_assets',
['project_id', 'status', 'created_at'])
op.create_index('idx_vp_v3_assets_asset_type', 'vp_v3_assets', ['asset_type'])
op.create_index('idx_vp_v3_assets_remote_delete_status', 'vp_v3_assets', ['remote_delete_status'])
op.execute(
"CREATE INDEX idx_vp_v3_assets_next_poll_status ON vp_v3_assets (next_poll_at, status)"
" WHERE deleted_at IS NULL AND next_poll_at IS NOT NULL;"
)
def downgrade() -> None:
op.drop_table('vp_v3_assets')
op.drop_table('vp_v3_projects')
op.drop_index('idx_vp_v3_api_key_quotas_api_key_id', table_name='vp_v3_api_key_quotas')
op.drop_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', type_='unique')
op.drop_table('vp_v3_api_key_quotas')
@@ -0,0 +1,984 @@
"""2026080601_add_missing_table_and_column_comments
Revision ID: 2026080601
Revises: 2026080401
Create Date: 2026-08-06 00:00:00.000000
给前端迁移遗漏的 models 表添加表注释和字段注释
"""
from typing import Sequence, Union
from alembic import op
revision: str = '2026080601'
down_revision: Union[str, None] = '2026080401'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
# ============================================================
# notification_reads 表
# ============================================================
_comment_table("notification_reads", "通知已读记录表")
_comment_column("notification_reads", "id", "主键ID")
_comment_column("notification_reads", "notification_id", "通知ID")
_comment_column("notification_reads", "user_id", "已读用户ID")
_comment_column("notification_reads", "created_at", "创建时间")
_comment_column("notification_reads", "updated_at", "更新时间")
# ============================================================
# menu_configs 表
# ============================================================
_comment_table("menu_configs", "菜单配置表")
_comment_column("menu_configs", "id", "主键ID")
_comment_column("menu_configs", "label", "菜单显示名称")
_comment_column("menu_configs", "path", "菜单路由路径")
_comment_column("menu_configs", "icon", "菜单图标名称")
_comment_column("menu_configs", "sort_order", "排序值,越小越靠前")
_comment_column("menu_configs", "is_active", "是否启用")
_comment_column("menu_configs", "parent_id", "父菜单ID")
_comment_column("menu_configs", "menu_type", "菜单类型:page页面/directory目录/link链接")
_comment_column("menu_configs", "menu_target", "菜单目标:frontend前台/admin后台")
_comment_column("menu_configs", "is_default", "是否默认菜单,新用户自动分配")
_comment_column("menu_configs", "created_at", "创建时间")
_comment_column("menu_configs", "updated_at", "更新时间")
# ============================================================
# team_join_requests 表
# ============================================================
_comment_table("team_join_requests", "团队加入申请记录表")
_comment_column("team_join_requests", "id", "主键ID")
_comment_column("team_join_requests", "team_id", "目标团队ID")
_comment_column("team_join_requests", "user_id", "申请人用户ID")
_comment_column("team_join_requests", "invitation_id", "关联邀请ID(通过邀请链接申请时记录)")
_comment_column("team_join_requests", "status", "申请状态:pending待处理/approved已通过/rejected已拒绝")
_comment_column("team_join_requests", "note", "申请备注")
_comment_column("team_join_requests", "handled_by", "处理人用户ID")
_comment_column("team_join_requests", "created_at", "创建时间")
_comment_column("team_join_requests", "updated_at", "更新时间")
# ============================================================
# team_invitations 表
# ============================================================
_comment_table("team_invitations", "团队邀请记录表")
_comment_column("team_invitations", "id", "主键ID")
_comment_column("team_invitations", "team_id", "所属团队ID")
_comment_column("team_invitations", "code", "邀请码,唯一")
_comment_column("team_invitations", "created_by", "创建人用户ID")
_comment_column("team_invitations", "status", "邀请状态:active启用/disabled禁用")
_comment_column("team_invitations", "max_uses", "最大使用次数,NULL表示不限")
_comment_column("team_invitations", "use_count", "已使用次数")
_comment_column("team_invitations", "expires_at", "过期时间,NULL表示永不过期")
_comment_column("team_invitations", "created_at", "创建时间")
_comment_column("team_invitations", "updated_at", "更新时间")
_comment_column("team_invitations", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# contact_requests 表
# ============================================================
_comment_table("contact_requests", "用户联系/咨询申请表")
_comment_column("contact_requests", "id", "主键ID")
_comment_column("contact_requests", "user_id", "提交用户ID")
_comment_column("contact_requests", "phone", "联系电话")
_comment_column("contact_requests", "company_name", "公司名称")
_comment_column("contact_requests", "industry", "所属行业")
_comment_column("contact_requests", "name", "联系人姓名")
_comment_column("contact_requests", "message", "留言内容")
_comment_column("contact_requests", "is_handled", "是否已处理")
_comment_column("contact_requests", "submit_date", "提交日期(YYYY-MM-DD),用于每日限1次控制")
_comment_column("contact_requests", "created_at", "创建时间")
_comment_column("contact_requests", "updated_at", "更新时间")
# ============================================================
# token_usage 表
# ============================================================
_comment_table("token_usage", "Token消耗记录表")
_comment_column("token_usage", "id", "主键ID")
_comment_column("token_usage", "model_config_id", "模型配置ID")
_comment_column("token_usage", "user_id", "所属用户ID")
_comment_column("token_usage", "input_tokens", "输入Token数")
_comment_column("token_usage", "output_tokens", "输出Token数")
_comment_column("token_usage", "total_tokens", "总Token数")
_comment_column("token_usage", "owner_type", "归属类型:chat_generation_task/module_generation_step等")
_comment_column("token_usage", "owner_id", "归属记录ID")
_comment_column("token_usage", "biz_key", "业务幂等键")
_comment_column("token_usage", "source_module", "来源模块")
_comment_column("token_usage", "source_step_code", "来源步骤编码")
_comment_column("token_usage", "created_at", "创建时间")
_comment_column("token_usage", "updated_at", "更新时间")
# ============================================================
# industry_configs 表
# ============================================================
_comment_table("industry_configs", "行业配置表")
_comment_column("industry_configs", "id", "主键ID")
_comment_column("industry_configs", "key", "行业唯一标识键")
_comment_column("industry_configs", "label", "行业显示名称")
_comment_column("industry_configs", "icon", "图标名称")
_comment_column("industry_configs", "description", "行业描述")
_comment_column("industry_configs", "skills", "行业技能列表JSON数组")
_comment_column("industry_configs", "is_active", "是否启用")
_comment_column("industry_configs", "sort_order", "排序值,越小越靠前")
_comment_column("industry_configs", "created_at", "创建时间")
_comment_column("industry_configs", "updated_at", "更新时间")
# ============================================================
# chat_generation_task_events 表
# ============================================================
_comment_table("chat_generation_task_events", "AI创作任务事件日志表(追加写入)")
_comment_column("chat_generation_task_events", "id", "主键ID")
_comment_column("chat_generation_task_events", "owner_type", "归属类型:chat_generation_task/generation_record")
_comment_column("chat_generation_task_events", "task_id", "关联AI创作任务ID")
_comment_column("chat_generation_task_events", "generation_record_id", "关联项目生成记录ID")
_comment_column("chat_generation_task_events", "generation_attempt_no", "生成尝试次数")
_comment_column("chat_generation_task_events", "generation_mode", "生成模式")
_comment_column("chat_generation_task_events", "event_type", "事件类型")
_comment_column("chat_generation_task_events", "from_status", "变更前状态")
_comment_column("chat_generation_task_events", "to_status", "变更后状态")
_comment_column("chat_generation_task_events", "from_stage", "变更前阶段")
_comment_column("chat_generation_task_events", "to_stage", "变更后阶段")
_comment_column("chat_generation_task_events", "message", "事件描述信息")
_comment_column("chat_generation_task_events", "detail_json", "事件详情JSON")
_comment_column("chat_generation_task_events", "created_at", "创建时间")
# ============================================================
# chat_provider_call_logs 表
# ============================================================
_comment_table("chat_provider_call_logs", "供应商调用审计日志表")
_comment_column("chat_provider_call_logs", "id", "主键ID")
_comment_column("chat_provider_call_logs", "owner_type", "归属类型:chat_generation_task/generation_record")
_comment_column("chat_provider_call_logs", "task_id", "关联AI创作任务ID")
_comment_column("chat_provider_call_logs", "generation_record_id", "关联项目生成记录ID")
_comment_column("chat_provider_call_logs", "generation_attempt_no", "生成尝试次数")
_comment_column("chat_provider_call_logs", "generation_mode", "生成模式")
_comment_column("chat_provider_call_logs", "provider", "供应商:ark/seedance等")
_comment_column("chat_provider_call_logs", "api_type", "API类型:image_generate/video_create等")
_comment_column("chat_provider_call_logs", "model", "模型名称")
_comment_column("chat_provider_call_logs", "engine_id", "引擎ID")
_comment_column("chat_provider_call_logs", "status", "调用状态:success/failed")
_comment_column("chat_provider_call_logs", "latency_ms", "调用耗时(毫秒)")
_comment_column("chat_provider_call_logs", "http_status", "HTTP状态码")
_comment_column("chat_provider_call_logs", "provider_task_id", "供应商任务ID")
_comment_column("chat_provider_call_logs", "request_hash", "请求内容哈希")
_comment_column("chat_provider_call_logs", "response_hash", "响应内容哈希")
_comment_column("chat_provider_call_logs", "request_excerpt", "请求内容摘录")
_comment_column("chat_provider_call_logs", "response_excerpt", "响应内容摘录")
_comment_column("chat_provider_call_logs", "prompt_tokens", "提示词Token数")
_comment_column("chat_provider_call_logs", "completion_tokens", "补全Token数")
_comment_column("chat_provider_call_logs", "total_tokens", "总Token数")
_comment_column("chat_provider_call_logs", "error_code", "错误码")
_comment_column("chat_provider_call_logs", "error_message", "错误信息")
_comment_column("chat_provider_call_logs", "created_at", "创建时间")
# ============================================================
# open_type 表
# ============================================================
_comment_table("open_type", "开户方式管理表")
_comment_column("open_type", "id", "主键")
_comment_column("open_type", "type_name", "标题名称")
_comment_column("open_type", "open_type", "开户方式id")
_comment_column("open_type", "description", "开户方式描述")
_comment_column("open_type", "thumb", "缩略图")
_comment_column("open_type", "created_at", "创建时间")
_comment_column("open_type", "updated_at", "更新时间")
_comment_column("open_type", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# pre_test_template 表
# ============================================================
_comment_table("pre_test_template", "素材前测模板表")
_comment_column("pre_test_template", "id", "主键")
_comment_column("pre_test_template", "name", "模板名称")
_comment_column("pre_test_template", "user_id", "用户id")
_comment_column("pre_test_template", "note", "模板备注")
_comment_column("pre_test_template", "platform", "投放平台(AD/QIANCHUAN/LOCAL")
_comment_column("pre_test_template", "external_action", "转化目标")
_comment_column("pre_test_template", "cpa_bid", "目标转化成本:[1, 10000]")
_comment_column("pre_test_template", "audience_gender", "性别(ALL/MALE/FEMALE")
_comment_column("pre_test_template", "audience_age", "受众年龄,JSON数组")
_comment_column("pre_test_template", "audience_region", "受众地区,JSON数组(二级行政区域code)")
_comment_column("pre_test_template", "audience_network", "网络类型,JSON数组")
_comment_column("pre_test_template", "cus_name", "客户主体名称")
_comment_column("pre_test_template", "pricing_type", "出价类型(OCPC/CPA/OCPM")
_comment_column("pre_test_template", "cost_cap", "是否最优成本出价(仅AD支持)")
_comment_column("pre_test_template", "target_cost", "是否稳定成本出价(仅AD支持)")
_comment_column("pre_test_template", "nobid", "是否最大转化出价(仅AD支持)")
_comment_column("pre_test_template", "cpc_bid", "目标点击成本:[1, 10000]")
_comment_column("pre_test_template", "budget", "预算金额:[1, 10000]")
_comment_column("pre_test_template", "is_default", "是否默认模板")
_comment_column("pre_test_template", "created_at", "创建时间")
_comment_column("pre_test_template", "updated_at", "更新时间")
_comment_column("pre_test_template", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# material_cost 表
# ============================================================
_comment_table("material_cost", "素材消耗数据表(广告投放消耗统计)")
_comment_column("material_cost", "id", "主键")
_comment_column("material_cost", "oauth_id", "授权表user_oauth自增id")
_comment_column("material_cost", "advertiser_id", "广告主id")
_comment_column("material_cost", "material_id", "素材id")
_comment_column("material_cost", "consume_date", "消耗日期")
_comment_column("material_cost", "stat_cost", "消耗金额")
_comment_column("material_cost", "show_cnt", "展示数")
_comment_column("material_cost", "cpm_platform", "平均千次展现费用(元)")
_comment_column("material_cost", "click_cnt", "点击数")
_comment_column("material_cost", "ctr", "点击率")
_comment_column("material_cost", "cpc_platform", "平均点击单价(元)")
_comment_column("material_cost", "convert_cnt", "转化数")
_comment_column("material_cost", "conversion_cost", "平均转化成本(元)")
_comment_column("material_cost", "conversion_rate", "转化率")
_comment_column("material_cost", "deep_convert_cnt", "深度转化数")
_comment_column("material_cost", "deep_convert_cost", "深度转化成本(元)")
_comment_column("material_cost", "deep_convert_rate", "深度转化率")
_comment_column("material_cost", "active", "激活数")
_comment_column("material_cost", "active_cost", "激活成本(元)")
_comment_column("material_cost", "active_rate", "激活率")
_comment_column("material_cost", "active_register", "注册数")
_comment_column("material_cost", "active_register_cost", "注册成本(元)")
_comment_column("material_cost", "active_register_rate", "注册率")
_comment_column("material_cost", "attribution_next_day_open_cnt", "次留数")
_comment_column("material_cost", "attribution_next_day_open_cost", "次留成本")
_comment_column("material_cost", "attribution_next_day_open_rate", "次留率")
_comment_column("material_cost", "active_pay", "首次付费数")
_comment_column("material_cost", "active_pay_cost", "首次付费成本(元)")
_comment_column("material_cost", "active_pay_rate", "首次付费率")
_comment_column("material_cost", "phone", "点击电话按钮")
_comment_column("material_cost", "form", "用户在门店落地页多线沟通提交表单的次数")
_comment_column("material_cost", "download_start", "用户点击下载开始的次数")
_comment_column("material_cost", "form_submit", "用户查看附加创意后,提交表单的次数")
_comment_column("material_cost", "button", "用户点击按钮button的次数")
_comment_column("material_cost", "view", "用户在关键页面的浏览次数")
_comment_column("material_cost", "message", "用户点击短信咨询的次数")
_comment_column("material_cost", "consult", "用户点击在线咨询按钮的次数")
_comment_column("material_cost", "consult_effective", "用户在门店落地页多线沟通的在线咨询中有效咨询的次数")
_comment_column("material_cost", "shopping", "用户购买商品的次数")
_comment_column("material_cost", "customer_effective", "有效获客")
_comment_column("material_cost", "attribution_game_in_app_ltv_1day", "当日付费金额")
_comment_column("material_cost", "attribution_game_in_app_roi_1day", "当日付费ROI")
_comment_column("material_cost", "loan_completion", "完件数")
_comment_column("material_cost", "loan_completion_cost", "完件成本(元)")
_comment_column("material_cost", "loan_completion_rate", "完件率")
_comment_column("material_cost", "loan_credit", "授信数")
_comment_column("material_cost", "loan_credit_cost", "授信成本(元)")
_comment_column("material_cost", "loan_credit_rate", "授信率")
_comment_column("material_cost", "in_app_order_gmv", "引流电商订单GMV")
_comment_column("material_cost", "in_app_order_roi", "引流电商订单ROI")
_comment_column("material_cost", "in_app_pay_gmv", "引流电商支付GMV")
_comment_column("material_cost", "in_app_pay_roi", "引流电商支付ROI")
_comment_column("material_cost", "total_play", "播放量")
_comment_column("material_cost", "valid_play", "有效播放数")
_comment_column("material_cost", "valid_play_cost", "有效播放成本(元)")
_comment_column("material_cost", "valid_play_rate", "有效播放率")
_comment_column("material_cost", "valid_play_of_mille", "千次有效播放数")
_comment_column("material_cost", "valid_play_cost_of_mille", "千次有效播放成本(元)")
_comment_column("material_cost", "average_play_time_per_play", "平均单次播放时长")
_comment_column("material_cost", "play_over_rate", "完播率")
_comment_column("material_cost", "dy_like", "点赞数")
_comment_column("material_cost", "dy_comment", "评论量")
_comment_column("material_cost", "dy_share", "分享量")
_comment_column("material_cost", "report_cnt", "举报数")
_comment_column("material_cost", "created_at", "创建时间")
_comment_column("material_cost", "updated_at", "更新时间")
_comment_column("material_cost", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# user_oauth 表
# ============================================================
_comment_table("user_oauth", "用户授权账户表(第三方平台授权)")
_comment_column("user_oauth", "id", "主键")
_comment_column("user_oauth", "account_id", "授权账户id")
_comment_column("user_oauth", "account_name", "授权账户name")
_comment_column("user_oauth", "account_role", "授权账户角色")
_comment_column("user_oauth", "account_username", "授权账户登录账号")
_comment_column("user_oauth", "account_userid", "授权账户登录userid")
_comment_column("user_oauth", "user_id", "用户id")
_comment_column("user_oauth", "open_type", "开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)")
_comment_column("user_oauth", "port_type", "平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)")
_comment_column("user_oauth", "appid", "授权应用id")
_comment_column("user_oauth", "access_token", "授权token")
_comment_column("user_oauth", "access_token_expired", "token过期时间")
_comment_column("user_oauth", "refresh_token", "授权刷新token")
_comment_column("user_oauth", "refresh_token_expired", "刷新token过期时间")
_comment_column("user_oauth", "material_auth_status", "是否敏感物料授权(true=是,false=否)")
_comment_column("user_oauth", "created_at", "创建时间")
_comment_column("user_oauth", "updated_at", "更新时间")
_comment_column("user_oauth", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# user_oauth_account 表
# ============================================================
_comment_table("user_oauth_account", "授权账户详情表(广告账户映射)")
_comment_column("user_oauth_account", "id", "主键")
_comment_column("user_oauth_account", "oauth_id", "授权表中的id")
_comment_column("user_oauth_account", "advertiser_id", "广告主账户id")
_comment_column("user_oauth_account", "advertiser_name", "广告账户名")
_comment_column("user_oauth_account", "advertiser_role", "广告账户类型")
_comment_column("user_oauth_account", "created_at", "创建时间")
_comment_column("user_oauth_account", "updated_at", "更新时间")
_comment_column("user_oauth_account", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# user_oauth_app 表
# ============================================================
_comment_table("user_oauth_app", "授权应用管理表(应用密钥配置)")
_comment_column("user_oauth_app", "id", "主键")
_comment_column("user_oauth_app", "app_id", "应用id")
_comment_column("user_oauth_app", "secret", "应用密钥")
_comment_column("user_oauth_app", "status", "状态,1=正常,2=禁用")
_comment_column("user_oauth_app", "max_count", "应用最大可以授权多少个用户")
_comment_column("user_oauth_app", "auth_url", "应用授权链接")
_comment_column("user_oauth_app", "company", "应用归属公司名称")
_comment_column("user_oauth_app", "open_type", "开户方式")
_comment_column("user_oauth_app", "create_by", "创建者")
_comment_column("user_oauth_app", "created_at", "创建时间")
_comment_column("user_oauth_app", "updated_at", "更新时间")
_comment_column("user_oauth_app", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# upload_task 表
# ============================================================
_comment_table("upload_task", "上传任务表(素材上传记录)")
_comment_column("upload_task", "id", "主键")
_comment_column("upload_task", "user_id", "用户登录id")
_comment_column("upload_task", "advertiser_id", "广告主id")
_comment_column("upload_task", "resource_id", "资源id")
_comment_column("upload_task", "status", "上传状态:1待上传,2上传中,3上传成功,4上传失败")
_comment_column("upload_task", "note", "上传备注")
_comment_column("upload_task", "oauth_id", "授权表id")
_comment_column("upload_task", "other_info", "其他信息")
_comment_column("upload_task", "created_at", "创建时间")
_comment_column("upload_task", "updated_at", "更新时间")
_comment_column("upload_task", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# user_resource_month_stats 表
# ============================================================
_comment_table("user_resource_month_stats", "用户月份资源空间聚合表")
_comment_column("user_resource_month_stats", "id", "主键ID")
_comment_column("user_resource_month_stats", "user_id", "所属用户ID")
_comment_column("user_resource_month_stats", "stat_month", "统计月份")
_comment_column("user_resource_month_stats", "active_size_bytes", "活跃资源大小(字节)")
_comment_column("user_resource_month_stats", "deleted_size_bytes", "已删除资源大小(字节)")
_comment_column("user_resource_month_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
_comment_column("user_resource_month_stats", "upload_size_bytes", "上传资源大小(字节)")
_comment_column("user_resource_month_stats", "image_size_bytes", "图片资源大小(字节)")
_comment_column("user_resource_month_stats", "video_size_bytes", "视频资源大小(字节)")
_comment_column("user_resource_month_stats", "audio_size_bytes", "音频资源大小(字节)")
_comment_column("user_resource_month_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
_comment_column("user_resource_month_stats", "active_count", "活跃资源数量")
_comment_column("user_resource_month_stats", "deleted_count", "已删除资源数量")
_comment_column("user_resource_month_stats", "image_count", "图片资源数量")
_comment_column("user_resource_month_stats", "video_count", "视频资源数量")
_comment_column("user_resource_month_stats", "upload_count", "上传资源数量")
_comment_column("user_resource_month_stats", "audio_count", "音频资源数量")
_comment_column("user_resource_month_stats", "shot_segment_count", "拆镜切片数量")
_comment_column("user_resource_month_stats", "last_recalculated_at", "最后重新计算时间")
_comment_column("user_resource_month_stats", "created_at", "创建时间")
_comment_column("user_resource_month_stats", "updated_at", "更新时间")
# ============================================================
# user_resource_total_stats 表
# ============================================================
_comment_table("user_resource_total_stats", "用户全局资源空间聚合表")
_comment_column("user_resource_total_stats", "id", "主键ID")
_comment_column("user_resource_total_stats", "user_id", "所属用户ID")
_comment_column("user_resource_total_stats", "active_size_bytes", "活跃资源大小(字节)")
_comment_column("user_resource_total_stats", "deleted_size_bytes", "已删除资源大小(字节)")
_comment_column("user_resource_total_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
_comment_column("user_resource_total_stats", "upload_size_bytes", "上传资源大小(字节)")
_comment_column("user_resource_total_stats", "image_size_bytes", "图片资源大小(字节)")
_comment_column("user_resource_total_stats", "video_size_bytes", "视频资源大小(字节)")
_comment_column("user_resource_total_stats", "audio_size_bytes", "音频资源大小(字节)")
_comment_column("user_resource_total_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
_comment_column("user_resource_total_stats", "active_count", "活跃资源数量")
_comment_column("user_resource_total_stats", "deleted_count", "已删除资源数量")
_comment_column("user_resource_total_stats", "image_count", "图片资源数量")
_comment_column("user_resource_total_stats", "video_count", "视频资源数量")
_comment_column("user_resource_total_stats", "upload_count", "上传资源数量")
_comment_column("user_resource_total_stats", "audio_count", "音频资源数量")
_comment_column("user_resource_total_stats", "shot_segment_count", "拆镜切片数量")
_comment_column("user_resource_total_stats", "last_recalculated_at", "最后重新计算时间")
_comment_column("user_resource_total_stats", "created_at", "创建时间")
_comment_column("user_resource_total_stats", "updated_at", "更新时间")
# ============================================================
# home_material_assets 表
# ============================================================
_comment_table("home_material_assets", "首页素材资产表")
_comment_column("home_material_assets", "id", "主键ID")
_comment_column("home_material_assets", "category_id", "行业类别ID")
_comment_column("home_material_assets", "title", "素材标题")
_comment_column("home_material_assets", "media_type", "素材类型:image图片,video视频")
_comment_column("home_material_assets", "original_url", "原始素材URL")
_comment_column("home_material_assets", "original_storage_path", "原始素材本地路径")
_comment_column("home_material_assets", "watermarked_url", "水印素材URL")
_comment_column("home_material_assets", "watermarked_storage_path", "水印素材本地路径")
_comment_column("home_material_assets", "cover_url", "视频封面URL")
_comment_column("home_material_assets", "cover_storage_path", "视频封面本地路径")
_comment_column("home_material_assets", "watermark_id", "水印图片ID")
_comment_column("home_material_assets", "watermark_config_json", "水印配置快照JSON")
_comment_column("home_material_assets", "generation_prompt", "生成提词")
_comment_column("home_material_assets", "media_references_json", "附件/参考素材JSON字符串")
_comment_column("home_material_assets", "status", "处理状态:draft/processing/success/failed")
_comment_column("home_material_assets", "error_message", "处理失败原因")
_comment_column("home_material_assets", "width", "素材宽度")
_comment_column("home_material_assets", "height", "素材高度")
_comment_column("home_material_assets", "duration_seconds", "视频时长,图片为空")
_comment_column("home_material_assets", "file_size_bytes", "原始文件大小")
_comment_column("home_material_assets", "watermarked_file_size_bytes", "水印后文件大小")
_comment_column("home_material_assets", "is_active", "是否前台展示")
_comment_column("home_material_assets", "sort_order", "排序,越小越靠前")
_comment_column("home_material_assets", "processed_at", "处理完成时间")
_comment_column("home_material_assets", "created_by", "创建管理员ID")
_comment_column("home_material_assets", "updated_by", "更新管理员ID")
_comment_column("home_material_assets", "created_at", "创建时间")
_comment_column("home_material_assets", "updated_at", "更新时间")
_comment_column("home_material_assets", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# home_material_categories 表
# ============================================================
_comment_table("home_material_categories", "首页素材行业类别表")
_comment_column("home_material_categories", "id", "主键ID")
_comment_column("home_material_categories", "name", "行业名称")
_comment_column("home_material_categories", "key", "行业唯一标识,前台可按key查询")
_comment_column("home_material_categories", "description", "行业描述")
_comment_column("home_material_categories", "icon", "前端图标名称")
_comment_column("home_material_categories", "is_active", "是否启用")
_comment_column("home_material_categories", "sort_order", "排序,越小越靠前")
_comment_column("home_material_categories", "created_by", "创建管理员ID")
_comment_column("home_material_categories", "updated_by", "更新管理员ID")
_comment_column("home_material_categories", "created_at", "创建时间")
_comment_column("home_material_categories", "updated_at", "更新时间")
_comment_column("home_material_categories", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# home_material_watermarks 表
# ============================================================
_comment_table("home_material_watermarks", "首页素材水印图片库")
_comment_column("home_material_watermarks", "id", "主键ID")
_comment_column("home_material_watermarks", "name", "水印名称")
_comment_column("home_material_watermarks", "file_url", "水印图片URL")
_comment_column("home_material_watermarks", "storage_path", "水印图片本地路径")
_comment_column("home_material_watermarks", "file_name", "原始文件名")
_comment_column("home_material_watermarks", "file_size_bytes", "文件大小")
_comment_column("home_material_watermarks", "width", "水印图片宽度")
_comment_column("home_material_watermarks", "height", "水印图片高度")
_comment_column("home_material_watermarks", "is_default", "是否默认水印")
_comment_column("home_material_watermarks", "is_active", "是否启用")
_comment_column("home_material_watermarks", "created_by", "创建管理员ID")
_comment_column("home_material_watermarks", "updated_by", "更新管理员ID")
_comment_column("home_material_watermarks", "created_at", "创建时间")
_comment_column("home_material_watermarks", "updated_at", "更新时间")
_comment_column("home_material_watermarks", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# module_generation_projects 表
# ============================================================
_comment_table("module_generation_projects", "通用模块生成项目/总任务表")
_comment_column("module_generation_projects", "id", "主键ID")
_comment_column("module_generation_projects", "user_id", "所属用户ID")
_comment_column("module_generation_projects", "module", "业务模块标识")
_comment_column("module_generation_projects", "flow_version", "项目流程版本号")
_comment_column("module_generation_projects", "title", "项目标题")
_comment_column("module_generation_projects", "status", "项目状态:pending/processing/success/failed")
_comment_column("module_generation_projects", "current_step_code", "当前执行步骤编码")
_comment_column("module_generation_projects", "final_image_url", "最终生成图片URL")
_comment_column("module_generation_projects", "final_video_url", "最终生成视频URL")
_comment_column("module_generation_projects", "final_video_cover_url", "最终生成视频封面URL")
_comment_column("module_generation_projects", "error_message", "错误信息")
_comment_column("module_generation_projects", "idempotency_key", "幂等键")
_comment_column("module_generation_projects", "completed_at", "项目完成时间")
_comment_column("module_generation_projects", "created_at", "创建时间")
_comment_column("module_generation_projects", "updated_at", "更新时间")
_comment_column("module_generation_projects", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# module_generation_steps 表
# ============================================================
_comment_table("module_generation_steps", "通用模块生成步骤表")
_comment_column("module_generation_steps", "id", "主键ID")
_comment_column("module_generation_steps", "project_id", "所属项目ID")
_comment_column("module_generation_steps", "user_id", "所属用户ID")
_comment_column("module_generation_steps", "module", "业务模块标识")
_comment_column("module_generation_steps", "step_index", "步骤序号")
_comment_column("module_generation_steps", "step_code", "步骤编码")
_comment_column("module_generation_steps", "status", "步骤状态:pending/processing/success/failed")
_comment_column("module_generation_steps", "version", "步骤重建版本号")
_comment_column("module_generation_steps", "is_current", "是否为当前版本")
_comment_column("module_generation_steps", "parent_step_id", "父步骤ID")
_comment_column("module_generation_steps", "source_step_id", "源步骤ID(复制来源)")
_comment_column("module_generation_steps", "chat_task_id", "关联AI创作任务ID")
_comment_column("module_generation_steps", "input_json", "步骤输入JSON")
_comment_column("module_generation_steps", "output_json", "步骤输出JSON")
_comment_column("module_generation_steps", "error_message", "错误信息")
_comment_column("module_generation_steps", "started_at", "步骤开始时间")
_comment_column("module_generation_steps", "completed_at", "步骤完成时间")
_comment_column("module_generation_steps", "token_usage_id", "关联Token消耗记录ID")
_comment_column("module_generation_steps", "model_config_id", "模型配置ID")
_comment_column("module_generation_steps", "input_tokens", "输入Token数")
_comment_column("module_generation_steps", "output_tokens", "输出Token数")
_comment_column("module_generation_steps", "total_tokens", "总Token数")
_comment_column("module_generation_steps", "text_credits_cost", "提示词优化消耗积分")
_comment_column("module_generation_steps", "created_at", "创建时间")
_comment_column("module_generation_steps", "updated_at", "更新时间")
_comment_column("module_generation_steps", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# shot_replicate_segments 表
# ============================================================
_comment_table("shot_replicate_segments", "拆镜复刻片段表")
_comment_column("shot_replicate_segments", "id", "主键ID")
_comment_column("shot_replicate_segments", "task_set_id", "所属任务集ID")
_comment_column("shot_replicate_segments", "user_id", "所属用户ID")
_comment_column("shot_replicate_segments", "segment_index", "镜头序号")
_comment_column("shot_replicate_segments", "source_mode", "来源模式:auto自动拆镜/manual手动")
_comment_column("shot_replicate_segments", "start_second", "片段开始时间(秒)")
_comment_column("shot_replicate_segments", "end_second", "片段结束时间(秒)")
_comment_column("shot_replicate_segments", "duration_seconds", "片段时长(秒)")
_comment_column("shot_replicate_segments", "time_node", "时间节点显示字符串")
_comment_column("shot_replicate_segments", "split_status", "切片状态:pending/slicing/sliced/failed")
_comment_column("shot_replicate_segments", "analysis_status", "AI分析状态:pending/processing/success/failed")
_comment_column("shot_replicate_segments", "replicate_status", "复刻状态:not_started/processing/completed/failed")
_comment_column("shot_replicate_segments", "segment_video_url", "片段视频访问URL")
_comment_column("shot_replicate_segments", "segment_video_path", "片段视频存储路径")
_comment_column("shot_replicate_segments", "original_video_content", "原视频内容描述")
_comment_column("shot_replicate_segments", "original_video_category", "原视频行业分类")
_comment_column("shot_replicate_segments", "original_video_audience", "原视频目标受众")
_comment_column("shot_replicate_segments", "segment_content", "片段内容描述")
_comment_column("shot_replicate_segments", "segment_category", "片段行业分类")
_comment_column("shot_replicate_segments", "segment_audience", "片段目标受众")
_comment_column("shot_replicate_segments", "analysis_json", "AI分析结果JSON")
_comment_column("shot_replicate_segments", "ai_suggestion_json", "AI复刻建议JSON")
_comment_column("shot_replicate_segments", "module_project_id", "关联模块生成项目ID")
_comment_column("shot_replicate_segments", "split_claim_token", "切片租约token")
_comment_column("shot_replicate_segments", "split_celery_task_id", "切片Celery任务ID")
_comment_column("shot_replicate_segments", "split_enqueued_at", "切片入队时间")
_comment_column("shot_replicate_segments", "split_started_at", "切片开始时间")
_comment_column("shot_replicate_segments", "split_lease_until", "切片租约过期时间")
_comment_column("shot_replicate_segments", "split_next_retry_at", "切片下次重试时间")
_comment_column("shot_replicate_segments", "split_retry_count", "切片重试次数")
_comment_column("shot_replicate_segments", "split_last_error", "切片最后错误信息")
_comment_column("shot_replicate_segments", "split_completed_at", "切片完成时间")
_comment_column("shot_replicate_segments", "analysis_attempt_no", "AI分析尝试次数")
_comment_column("shot_replicate_segments", "analysis_claim_token", "AI分析租约token")
_comment_column("shot_replicate_segments", "analysis_started_at", "AI分析开始时间")
_comment_column("shot_replicate_segments", "analysis_lease_until", "AI分析租约过期")
_comment_column("shot_replicate_segments", "analysis_error_message", "AI分析错误信息")
_comment_column("shot_replicate_segments", "created_at", "创建时间")
_comment_column("shot_replicate_segments", "updated_at", "更新时间")
_comment_column("shot_replicate_segments", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# private_portrait_projects 表
# ============================================================
_comment_table("private_portrait_projects", "用户私域人像素材项目表")
_comment_column("private_portrait_projects", "id", "主键ID")
_comment_column("private_portrait_projects", "user_id", "所属用户ID")
_comment_column("private_portrait_projects", "library_type", "素材库类型:real_person真人认证/aigc_virtual虚拟人像")
_comment_column("private_portrait_projects", "name", "用户展示项目名")
_comment_column("private_portrait_projects", "name_slug", "项目名安全slug")
_comment_column("private_portrait_projects", "remote_project_name", "火山ProjectName快照")
_comment_column("private_portrait_projects", "description", "项目描述")
_comment_column("private_portrait_projects", "status", "项目状态:active/creating/create_failed/deleting")
_comment_column("private_portrait_projects", "asset_group_count", "素材分组数量")
_comment_column("private_portrait_projects", "asset_count", "素材总数")
_comment_column("private_portrait_projects", "image_asset_count", "图片素材数")
_comment_column("private_portrait_projects", "video_asset_count", "视频素材数")
_comment_column("private_portrait_projects", "active_asset_count", "有效素材数")
_comment_column("private_portrait_projects", "active_image_asset_count", "有效图片素材数")
_comment_column("private_portrait_projects", "active_video_asset_count", "有效视频素材数")
_comment_column("private_portrait_projects", "last_used_at", "最后使用时间")
_comment_column("private_portrait_projects", "created_at", "创建时间")
_comment_column("private_portrait_projects", "updated_at", "更新时间")
_comment_column("private_portrait_projects", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# private_portrait_asset_groups 表
# ============================================================
_comment_table("private_portrait_asset_groups", "本地项目组与火山Asset Group映射表")
_comment_column("private_portrait_asset_groups", "id", "主键ID")
_comment_column("private_portrait_asset_groups", "user_id", "所属用户ID")
_comment_column("private_portrait_asset_groups", "project_id", "所属项目ID")
_comment_column("private_portrait_asset_groups", "library_type", "素材库类型:real_person/aigc_virtual")
_comment_column("private_portrait_asset_groups", "remote_group_id", "火山远端AssetGroup ID")
_comment_column("private_portrait_asset_groups", "remote_group_name", "火山远端AssetGroup名称")
_comment_column("private_portrait_asset_groups", "remote_project_name", "火山ProjectName快照")
_comment_column("private_portrait_asset_groups", "group_type", "分组类型")
_comment_column("private_portrait_asset_groups", "status", "分组状态:active/creating/create_failed/deleting")
_comment_column("private_portrait_asset_groups", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
_comment_column("private_portrait_asset_groups", "remote_deleted_at", "远端删除时间")
_comment_column("private_portrait_asset_groups", "remote_delete_error", "远端删除错误")
_comment_column("private_portrait_asset_groups", "raw_response_json", "火山原始响应JSON")
_comment_column("private_portrait_asset_groups", "created_at", "创建时间")
_comment_column("private_portrait_asset_groups", "updated_at", "更新时间")
_comment_column("private_portrait_asset_groups", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# private_portrait_assets 表
# ============================================================
_comment_table("private_portrait_assets", "火山Asset本地映射表(素材文件记录)")
_comment_column("private_portrait_assets", "id", "主键ID")
_comment_column("private_portrait_assets", "user_id", "所属用户ID")
_comment_column("private_portrait_assets", "project_id", "所属项目ID")
_comment_column("private_portrait_assets", "group_id", "所属分组ID")
_comment_column("private_portrait_assets", "library_type", "素材库类型:real_person/aigc_virtual")
_comment_column("private_portrait_assets", "remote_group_id", "火山远端AssetGroup ID")
_comment_column("private_portrait_assets", "remote_asset_id", "火山远端Asset ID")
_comment_column("private_portrait_assets", "remote_project_name", "火山ProjectName快照")
_comment_column("private_portrait_assets", "asset_type", "素材类型:Image图片/Video视频")
_comment_column("private_portrait_assets", "name", "素材名称")
_comment_column("private_portrait_assets", "source_url", "本地上传后的访问URL")
_comment_column("private_portrait_assets", "preview_url", "前端预览URL")
_comment_column("private_portrait_assets", "remote_url", "火山返回的资源访问URL")
_comment_column("private_portrait_assets", "remote_url_expired_at", "火山URL过期时间")
_comment_column("private_portrait_assets", "video_duration", "视频素材时长,秒")
_comment_column("private_portrait_assets", "video_cover_url", "视频素材封面预览地址")
_comment_column("private_portrait_assets", "file_size", "素材文件大小,字节")
_comment_column("private_portrait_assets", "mime_type", "MIME类型")
_comment_column("private_portrait_assets", "status", "素材状态:creating/active/failed/deleting")
_comment_column("private_portrait_assets", "moderation_json", "火山审核结果JSON")
_comment_column("private_portrait_assets", "last_poll_at", "最后轮询时间")
_comment_column("private_portrait_assets", "next_poll_at", "下次轮询时间")
_comment_column("private_portrait_assets", "poll_count", "轮询次数")
_comment_column("private_portrait_assets", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
_comment_column("private_portrait_assets", "remote_deleted_at", "远端删除时间")
_comment_column("private_portrait_assets", "remote_delete_error", "远端删除错误")
_comment_column("private_portrait_assets", "error_message", "错误信息")
_comment_column("private_portrait_assets", "raw_response_json", "火山原始响应JSON")
_comment_column("private_portrait_assets", "created_at", "创建时间")
_comment_column("private_portrait_assets", "updated_at", "更新时间")
_comment_column("private_portrait_assets", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# private_portrait_validate_sessions 表
# ============================================================
_comment_table("private_portrait_validate_sessions", "火山真人认证H5会话表")
_comment_column("private_portrait_validate_sessions", "id", "主键ID")
_comment_column("private_portrait_validate_sessions", "user_id", "所属用户ID")
_comment_column("private_portrait_validate_sessions", "project_id", "关联项目ID")
_comment_column("private_portrait_validate_sessions", "byted_token", "火山byted_token")
_comment_column("private_portrait_validate_sessions", "h5_link", "认证H5链接")
_comment_column("private_portrait_validate_sessions", "callback_url", "火山回调URL")
_comment_column("private_portrait_validate_sessions", "result_code", "认证结果码")
_comment_column("private_portrait_validate_sessions", "algorithm_base_resp_code", "算法基础响应码")
_comment_column("private_portrait_validate_sessions", "verify_type", "认证类型")
_comment_column("private_portrait_validate_sessions", "status", "会话状态:created/group_active/expired/failed")
_comment_column("private_portrait_validate_sessions", "remote_group_id", "火山远端AssetGroup ID")
_comment_column("private_portrait_validate_sessions", "remote_project_name", "火山ProjectName快照")
_comment_column("private_portrait_validate_sessions", "expired_at", "会话过期时间")
_comment_column("private_portrait_validate_sessions", "error_message", "错误信息")
_comment_column("private_portrait_validate_sessions", "raw_callback_json", "火山回调原始JSON")
_comment_column("private_portrait_validate_sessions", "raw_response_json", "火山原始响应JSON")
_comment_column("private_portrait_validate_sessions", "created_at", "创建时间")
_comment_column("private_portrait_validate_sessions", "updated_at", "更新时间")
# ============================================================
# vp_v3_projects 表
# ============================================================
_comment_table("vp_v3_projects", "API V3虚拟素材项目表(按API Key隔离)")
_comment_column("vp_v3_projects", "id", "主键ID")
_comment_column("vp_v3_projects", "api_key_id", "所属API KeyV3调用方)")
_comment_column("vp_v3_projects", "name", "项目展示名称")
_comment_column("vp_v3_projects", "name_slug", "名称安全slug(构建远端GroupName用)")
_comment_column("vp_v3_projects", "description", "项目描述")
_comment_column("vp_v3_projects", "remote_project_name", "火山ProjectName快照")
_comment_column("vp_v3_projects", "remote_group_id", "火山AssetGroup Id")
_comment_column("vp_v3_projects", "remote_group_name", "火山AssetGroup Name快照")
_comment_column("vp_v3_projects", "status", "项目状态:active/creating_remote_group/create_group_failed/deleting")
_comment_column("vp_v3_projects", "asset_count", "素材总数")
_comment_column("vp_v3_projects", "active_asset_count", "有效素材数")
_comment_column("vp_v3_projects", "image_asset_count", "图片素材数")
_comment_column("vp_v3_projects", "video_asset_count", "视频素材数")
_comment_column("vp_v3_projects", "active_image_asset_count", "有效图片素材数")
_comment_column("vp_v3_projects", "active_video_asset_count", "有效视频素材数")
_comment_column("vp_v3_projects", "storage_mb_used", "项目占用存储MB")
_comment_column("vp_v3_projects", "last_used_at", "最后使用时间")
_comment_column("vp_v3_projects", "remote_delete_status", "远端删除状态")
_comment_column("vp_v3_projects", "remote_deleted_at", "远端删除时间")
_comment_column("vp_v3_projects", "remote_delete_error", "远端删除错误")
_comment_column("vp_v3_projects", "error_message", "创建失败等错误信息")
_comment_column("vp_v3_projects", "raw_response_json", "火山原始响应")
_comment_column("vp_v3_projects", "created_at", "创建时间")
_comment_column("vp_v3_projects", "updated_at", "更新时间")
_comment_column("vp_v3_projects", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# vp_v3_assets 表
# ============================================================
_comment_table("vp_v3_assets", "API V3虚拟素材表(图片/视频)")
_comment_column("vp_v3_assets", "id", "主键ID")
_comment_column("vp_v3_assets", "api_key_id", "所属API Key")
_comment_column("vp_v3_assets", "project_id", "所属项目ID")
_comment_column("vp_v3_assets", "remote_project_name", "火山ProjectName快照")
_comment_column("vp_v3_assets", "remote_group_id", "火山AssetGroup ID")
_comment_column("vp_v3_assets", "remote_asset_id", "火山远端Asset ID")
_comment_column("vp_v3_assets", "asset_type", "素材类型:Image=图片/Video=视频")
_comment_column("vp_v3_assets", "name", "素材名称")
_comment_column("vp_v3_assets", "source_url", "本地上传后的访问URLUploadResource返回的)")
_comment_column("vp_v3_assets", "preview_url", "给前端预览/显示用的URL")
_comment_column("vp_v3_assets", "remote_url", "火山返回的资源访问URL")
_comment_column("vp_v3_assets", "remote_url_expired_at", "火山URL过期时间")
_comment_column("vp_v3_assets", "upload_resource_id", "本地UploadResource账本resource_id")
_comment_column("vp_v3_assets", "video_duration", "视频时长,秒")
_comment_column("vp_v3_assets", "video_cover_url", "视频封面预览")
_comment_column("vp_v3_assets", "file_size_bytes", "素材文件大小,字节")
_comment_column("vp_v3_assets", "mime_type", "MIME类型")
_comment_column("vp_v3_assets", "status", "素材状态:creating/审核中active/可用failed/失败deleting/删除中")
_comment_column("vp_v3_assets", "moderation_json", "火山审核结果JSON")
_comment_column("vp_v3_assets", "error_message", "失败原因")
_comment_column("vp_v3_assets", "raw_response_json", "火山原始响应JSON")
_comment_column("vp_v3_assets", "last_poll_at", "最后轮询时间")
_comment_column("vp_v3_assets", "next_poll_at", "下次轮询时间")
_comment_column("vp_v3_assets", "poll_count", "轮询次数")
_comment_column("vp_v3_assets", "remote_delete_status", "远端删除状态")
_comment_column("vp_v3_assets", "remote_deleted_at", "远端删除时间")
_comment_column("vp_v3_assets", "remote_delete_error", "远端删除错误")
_comment_column("vp_v3_assets", "created_at", "创建时间")
_comment_column("vp_v3_assets", "updated_at", "更新时间")
_comment_column("vp_v3_assets", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# vp_v3_api_key_quotas 表
# ============================================================
_comment_table("vp_v3_api_key_quotas", "API V3虚拟素材库配额表(每个ApiKey一份)")
_comment_column("vp_v3_api_key_quotas", "id", "主键ID")
_comment_column("vp_v3_api_key_quotas", "api_key_id", "所属API Key,唯一:一个API Key只有一份虚拟素材配额")
_comment_column("vp_v3_api_key_quotas", "project_limit", "虚拟项目上限,默认0不可创建")
_comment_column("vp_v3_api_key_quotas", "asset_limit", "虚拟素材总数上限(图片+视频),默认0不可上传")
_comment_column("vp_v3_api_key_quotas", "storage_mb_limit", "上传存储上限MB,默认0不可上传文件")
_comment_column("vp_v3_api_key_quotas", "project_used", "已创建项目数(未删除)")
_comment_column("vp_v3_api_key_quotas", "asset_used", "已上传素材数(未删除,图片+视频)")
_comment_column("vp_v3_api_key_quotas", "storage_mb_used", "已占用存储MB(未删除文件大小合计)")
_comment_column("vp_v3_api_key_quotas", "remark", "后台备注")
_comment_column("vp_v3_api_key_quotas", "created_at", "创建时间")
_comment_column("vp_v3_api_key_quotas", "updated_at", "更新时间")
# ============================================================
# api_keys 表
# ============================================================
_comment_table("api_keys", "对外开放API密钥管理表")
_comment_column("api_keys", "id", "主键ID")
_comment_column("api_keys", "company_name", "公司/组织名称")
_comment_column("api_keys", "api_key_hash", "API Key哈希值,唯一")
_comment_column("api_keys", "api_key_prefix", "API Key前缀")
_comment_column("api_keys", "api_key_encrypted", "AES-256-GCM加密的完整API Key")
_comment_column("api_keys", "description", "描述信息")
_comment_column("api_keys", "callable_models", "可调用模型配置JSON数组")
_comment_column("api_keys", "quota_limit", "配额总量,NULL=无限")
_comment_column("api_keys", "quota_cycle", "配额周期:daily/monthly/one_time/NULL=无限")
_comment_column("api_keys", "quota_used", "当前周期已使用量")
_comment_column("api_keys", "valid_from", "有效期开始时间")
_comment_column("api_keys", "valid_until", "有效期结束时间")
_comment_column("api_keys", "max_concurrent_video_tasks", "最大并发视频任务数,NULL=无限")
_comment_column("api_keys", "is_active", "是否启用")
_comment_column("api_keys", "last_used_at", "最后使用时间")
_comment_column("api_keys", "created_at", "创建时间")
_comment_column("api_keys", "updated_at", "更新时间")
_comment_column("api_keys", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# api_generation_tasks 表
# ============================================================
_comment_table("api_generation_tasks", "对外开放API生成任务表")
_comment_column("api_generation_tasks", "id", "主键ID")
_comment_column("api_generation_tasks", "api_key_id", "所属API Key")
_comment_column("api_generation_tasks", "external_idempotency_key", "外部幂等键")
_comment_column("api_generation_tasks", "original_prompt", "原始提示词")
_comment_column("api_generation_tasks", "optimized_prompt", "优化后的提示词")
_comment_column("api_generation_tasks", "gen_type", "生成类型:image/video")
_comment_column("api_generation_tasks", "duration", "视频时长(秒)")
_comment_column("api_generation_tasks", "aspect_ratio", "视频比例")
_comment_column("api_generation_tasks", "resolution", "分辨率档位")
_comment_column("api_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
_comment_column("api_generation_tasks", "image_size", "图片分辨率档位")
_comment_column("api_generation_tasks", "image_proportion", "图片比例")
_comment_column("api_generation_tasks", "image_px", "图片像素")
_comment_column("api_generation_tasks", "generation_count", "生成份数")
_comment_column("api_generation_tasks", "engine_id", "引擎ID")
_comment_column("api_generation_tasks", "model_name", "模型名称")
_comment_column("api_generation_tasks", "media_references", "用户原始上传的媒体URL")
_comment_column("api_generation_tasks", "local_media_json", "下载到本地的媒体文件路径JSON")
_comment_column("api_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
_comment_column("api_generation_tasks", "request_params_json", "完整原始请求参数")
_comment_column("api_generation_tasks", "status", "任务状态")
_comment_column("api_generation_tasks", "pipeline_stage", "流水线阶段")
_comment_column("api_generation_tasks", "generation_attempt_no", "生成尝试次数")
_comment_column("api_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
_comment_column("api_generation_tasks", "deadline_at", "任务截止时间")
_comment_column("api_generation_tasks", "provider_task_id", "供应商任务ID")
_comment_column("api_generation_tasks", "remote_result_url", "供应商远程资源URL")
_comment_column("api_generation_tasks", "provider_response_json", "供应商响应JSON")
_comment_column("api_generation_tasks", "image_url", "图片结果URL")
_comment_column("api_generation_tasks", "video_url", "视频结果URL")
_comment_column("api_generation_tasks", "video_cover_url", "视频封面URL")
_comment_column("api_generation_tasks", "error_message", "错误信息")
_comment_column("api_generation_tasks", "generated_at", "生成完成时间")
_comment_column("api_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
_comment_column("api_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
_comment_column("api_generation_tasks", "credits_cost", "消耗积分")
_comment_column("api_generation_tasks", "video_tokens_used", "视频Token消耗")
_comment_column("api_generation_tasks", "image_tokens_used", "图片Token消耗")
_comment_column("api_generation_tasks", "next_poll_at", "下次轮询时间")
_comment_column("api_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
_comment_column("api_generation_tasks", "poll_count", "轮询次数")
_comment_column("api_generation_tasks", "last_poll_at", "最后轮询时间")
_comment_column("api_generation_tasks", "provider_create_claim_token", "供应商创建任务租约token")
_comment_column("api_generation_tasks", "provider_create_lease_until", "供应商创建租约过期")
_comment_column("api_generation_tasks", "provider_create_started_at", "供应商创建开始时间")
_comment_column("api_generation_tasks", "poll_started_at", "轮询开始时间")
_comment_column("api_generation_tasks", "poll_claim_token", "轮询租约token")
_comment_column("api_generation_tasks", "poll_lease_until", "轮询租约过期")
_comment_column("api_generation_tasks", "poll_error_count", "轮询错误次数")
_comment_column("api_generation_tasks", "download_celery_task_id", "下载Celery任务ID")
_comment_column("api_generation_tasks", "download_enqueued_at", "下载入队时间")
_comment_column("api_generation_tasks", "download_started_at", "下载开始时间")
_comment_column("api_generation_tasks", "download_claim_token", "下载租约token")
_comment_column("api_generation_tasks", "download_lease_until", "下载租约过期")
_comment_column("api_generation_tasks", "download_next_retry_at", "下载下次重试时间")
_comment_column("api_generation_tasks", "download_attempt_count", "下载重试次数")
_comment_column("api_generation_tasks", "download_last_error", "下载最后错误信息")
_comment_column("api_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
_comment_column("api_generation_tasks", "local_path", "本地存储路径")
_comment_column("api_generation_tasks", "created_at", "创建时间")
_comment_column("api_generation_tasks", "updated_at", "更新时间")
_comment_column("api_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
# ============================================================
# api_usage_logs 表
# ============================================================
_comment_table("api_usage_logs", "API调用详细消耗记录表")
_comment_column("api_usage_logs", "id", "主键ID")
_comment_column("api_usage_logs", "api_key_id", "所属API Key")
_comment_column("api_usage_logs", "api_generation_task_id", "关联生成任务ID")
_comment_column("api_usage_logs", "price_action", "操作类型:deduct=扣除, refund=退回")
_comment_column("api_usage_logs", "request_type", "请求类型:video_create/image_generate")
_comment_column("api_usage_logs", "model_name", "模型名称")
_comment_column("api_usage_logs", "gen_type", "生成类型:image/video")
_comment_column("api_usage_logs", "resolution", "分辨率")
_comment_column("api_usage_logs", "duration", "视频时长(秒)")
_comment_column("api_usage_logs", "credits_cost", "实际扣除金额")
_comment_column("api_usage_logs", "refund_amount", "退回金额")
_comment_column("api_usage_logs", "quota_before", "操作前配额余额")
_comment_column("api_usage_logs", "quota_after", "操作后配额余额")
_comment_column("api_usage_logs", "tokens_used", "Token用量")
_comment_column("api_usage_logs", "request_duration_ms", "端到端耗时")
_comment_column("api_usage_logs", "price_detail_json", "价格计算明细JSON")
_comment_column("api_usage_logs", "status", "调用状态:success/failed")
_comment_column("api_usage_logs", "error_message", "错误信息")
_comment_column("api_usage_logs", "error_code", "错误码")
_comment_column("api_usage_logs", "request_payload_json", "原始请求快照")
_comment_column("api_usage_logs", "created_at", "创建时间")
_comment_column("api_usage_logs", "updated_at", "更新时间")
# ============================================================
# api_model_pricings 表
# ============================================================
_comment_table("api_model_pricings", "API模型价格表(全局统一配置)")
_comment_column("api_model_pricings", "id", "主键ID")
_comment_column("api_model_pricings", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
_comment_column("api_model_pricings", "gen_type", "生成类型:image/video")
_comment_column("api_model_pricings", "resolution", "分辨率档位")
_comment_column("api_model_pricings", "price_ratio", "价格系数(乘数)")
_comment_column("api_model_pricings", "base_price", "基础价格(元)")
_comment_column("api_model_pricings", "per_second_price", "每秒价格(视频,元)")
_comment_column("api_model_pricings", "input_video_ratio", "传入视频系数")
_comment_column("api_model_pricings", "input_video_base_price", "传入视频基础价(元)")
_comment_column("api_model_pricings", "input_video_per_second_price", "传入视频每秒价(元)")
_comment_column("api_model_pricings", "input_image_ratio", "传入图片系数")
_comment_column("api_model_pricings", "input_image_base_price", "传入图片基础价(元)")
_comment_column("api_model_pricings", "input_image_per_image_price", "传入图片每张价(元)")
_comment_column("api_model_pricings", "created_at", "创建时间")
_comment_column("api_model_pricings", "updated_at", "更新时间")
# ============================================================
# api_upscale_links 表
# ============================================================
_comment_table("api_upscale_links", "API任务与超分任务关联表")
_comment_column("api_upscale_links", "id", "主键ID")
_comment_column("api_upscale_links", "api_generation_task_id", "关联API生成任务ID")
_comment_column("api_upscale_links", "video_upscale_task_id", "关联视频超分任务ID")
_comment_column("api_upscale_links", "created_at", "创建时间")
_comment_column("api_upscale_links", "updated_at", "更新时间")
# ============================================================
# api_key_upscale_configs 表
# ============================================================
_comment_table("api_key_upscale_configs", "API Key级别超分配置表")
_comment_column("api_key_upscale_configs", "id", "主键ID")
_comment_column("api_key_upscale_configs", "api_key_id", "所属API Key")
_comment_column("api_key_upscale_configs", "enabled", "是否启用超分")
_comment_column("api_key_upscale_configs", "delete_source_after_success", "超分成功后是否删除源文件")
_comment_column("api_key_upscale_configs", "rules_json", "超分规则JSON数组")
_comment_column("api_key_upscale_configs", "created_at", "创建时间")
_comment_column("api_key_upscale_configs", "updated_at", "更新时间")
def downgrade() -> None:
op.execute("""
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name IN (
'notification_reads', 'menu_configs',
'team_join_requests', 'team_invitations',
'contact_requests', 'token_usage',
'industry_configs', 'chat_generation_task_events',
'chat_provider_call_logs', 'open_type',
'pre_test_template', 'material_cost',
'user_oauth', 'user_oauth_account', 'user_oauth_app',
'upload_task', 'user_resource_month_stats',
'user_resource_total_stats', 'home_material_assets',
'home_material_categories', 'home_material_watermarks',
'module_generation_projects', 'module_generation_steps',
'shot_replicate_segments',
'private_portrait_projects', 'private_portrait_asset_groups',
'private_portrait_assets', 'private_portrait_validate_sessions',
'vp_v3_projects', 'vp_v3_assets', 'vp_v3_api_key_quotas',
'api_keys', 'api_generation_tasks', 'api_usage_logs',
'api_model_pricings', 'api_upscale_links',
'api_key_upscale_configs'
)
LOOP
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
END LOOP;
END $$;
""")
for t in [
"notification_reads", "menu_configs",
"team_join_requests", "team_invitations",
"contact_requests", "token_usage",
"industry_configs", "chat_generation_task_events",
"chat_provider_call_logs", "open_type",
"pre_test_template", "material_cost",
"user_oauth", "user_oauth_account", "user_oauth_app",
"upload_task", "user_resource_month_stats",
"user_resource_total_stats", "home_material_assets",
"home_material_categories", "home_material_watermarks",
"module_generation_projects", "module_generation_steps",
"shot_replicate_segments",
"private_portrait_projects", "private_portrait_asset_groups",
"private_portrait_assets", "private_portrait_validate_sessions",
"vp_v3_projects", "vp_v3_assets", "vp_v3_api_key_quotas",
"api_keys", "api_generation_tasks", "api_usage_logs",
"api_model_pricings", "api_upscale_links",
"api_key_upscale_configs",
]:
op.execute(f"COMMENT ON TABLE {t} IS NULL")
@@ -0,0 +1,109 @@
"""发票管理表迁移
创建 invoices发票主表 invoice_orders发票-订单关联表
Revision ID: 20260810_20260810
Revises: 2026080601
Create Date: 2026-08-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260810_20260810'
down_revision: Union[str, None] = '2026080601'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
# ============================================================
# 1. 创建 invoices 表
# ============================================================
op.create_table(
'invoices',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
sa.Column('header_type', sa.String(16), nullable=False),
sa.Column('header_name', sa.String(128), nullable=False),
sa.Column('header_tax_no', sa.String(32), nullable=True),
sa.Column('header_register_address', sa.String(256), nullable=True),
sa.Column('header_register_phone', sa.String(32), nullable=True),
sa.Column('header_bank_name', sa.String(128), nullable=True),
sa.Column('header_bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=False),
sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
sa.Column('failure_reason', sa.Text, nullable=True),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
# ============================================================
# 2. 创建 invoice_orders 表
# ============================================================
op.create_table(
'invoice_orders',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_no', sa.String(64), nullable=False),
sa.Column('amount', sa.Float, nullable=False, server_default='0'),
sa.Column('credits', sa.Float, nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
# ============================================================
# 3. 表注释和字段注释
# ============================================================
_comment_table('invoices', '发票主表')
_comment_column('invoices', 'id', '主键')
_comment_column('invoices', 'user_id', '申请用户ID')
_comment_column('invoices', 'invoice_no', '发票编号')
_comment_column('invoices', 'header_type', '抬头类型: personal/company')
_comment_column('invoices', 'header_name', '抬头名称')
_comment_column('invoices', 'header_tax_no', '税号')
_comment_column('invoices', 'header_register_address', '注册地址')
_comment_column('invoices', 'header_register_phone', '注册电话')
_comment_column('invoices', 'header_bank_name', '开户行')
_comment_column('invoices', 'header_bank_account', '银行账号')
_comment_column('invoices', 'email', '电子邮箱(必填)')
_comment_column('invoices', 'total_amount', '开票总金额')
_comment_column('invoices', 'total_credits', '总积分')
_comment_column('invoices', 'status', '状态: processing/success/failed')
_comment_column('invoices', 'failure_reason', '失败原因')
_comment_column('invoices', 'issued_at', '开票成功时间')
_comment_table('invoice_orders', '发票-订单关联表')
_comment_column('invoice_orders', 'id', '主键')
_comment_column('invoice_orders', 'invoice_id', '发票ID')
_comment_column('invoice_orders', 'order_id', '订单ID')
_comment_column('invoice_orders', 'order_no', '订单号(冗余)')
_comment_column('invoice_orders', 'amount', '订单金额(冗余)')
_comment_column('invoice_orders', 'credits', '订单积分(冗余)')
def downgrade() -> None:
op.drop_table('invoice_orders')
op.drop_table('invoices')
@@ -0,0 +1,65 @@
"""发票抬头表迁移
创建 invoice_headers发票抬头表用于用户管理常用发票抬头
Revision ID: 20260811_20260811
Revises: 20260810_20260810
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260811_20260811'
down_revision: Union[str, None] = '20260810_20260810'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
op.create_table(
'invoice_headers',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.String(16), nullable=False),
sa.Column('name', sa.String(128), nullable=False),
sa.Column('tax_no', sa.String(32), nullable=True),
sa.Column('register_address', sa.String(256), nullable=True),
sa.Column('register_phone', sa.String(32), nullable=True),
sa.Column('bank_name', sa.String(128), nullable=True),
sa.Column('bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=True),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoice_headers_user', 'invoice_headers', ['user_id'])
# 表注释和字段注释
_comment_table('invoice_headers', '发票抬头表')
_comment_column('invoice_headers', 'id', '主键')
_comment_column('invoice_headers', 'user_id', '用户ID')
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
_comment_column('invoice_headers', 'name', '抬头名称')
_comment_column('invoice_headers', 'tax_no', '税号')
_comment_column('invoice_headers', 'register_address', '注册地址')
_comment_column('invoice_headers', 'register_phone', '注册电话')
_comment_column('invoice_headers', 'bank_name', '开户行')
_comment_column('invoice_headers', 'bank_account', '银行账号')
_comment_column('invoice_headers', 'email', '接收邮箱')
_comment_column('invoice_headers', 'is_default', '是否默认')
def downgrade() -> None:
op.drop_table('invoice_headers')
@@ -0,0 +1,30 @@
"""单设备登录拆分设备类型 + 用户级覆盖
Revision ID: 20260813_split_device_type
Revises: 20260813_token_version
Create Date: 2026-08-13 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_split_device_type'
down_revision = '20260813_token_version'
branch_labels = None
depends_on = None
def upgrade():
# 拆分为按设备类型管理
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'token_version')
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
def downgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'single_device_login_override')
op.drop_column('users', 'mobile_token_version')
op.drop_column('users', 'pc_token_version')
@@ -0,0 +1,22 @@
"""增加用户token_version字段 — 单设备登录
Revision ID: 20260813_token_version
Revises:b7e2c4d91a63
Create Date: 2026-08-13 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_token_version'
down_revision = 'b7e2c4d91a63' # 设置为上一个迁移的 revision
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
def downgrade():
op.drop_column('users', 'token_version')
@@ -0,0 +1,53 @@
"""银行账户与定时任务
Revision ID: 20260813_bank_scheduled
Revises: 20260813_split_device_type
Create Date: 2026-08-13 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_bank_scheduled'
down_revision = '20260813_split_device_type'
branch_labels = None
depends_on = None
def upgrade():
# 创建银行账户表
op.create_table(
'bank_accounts',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_name', sa.String(128), nullable=False, comment='账户名称'),
sa.Column('bank_name', sa.String(128), nullable=False, comment='开户银行'),
sa.Column('account_no', sa.String(64), unique=True, nullable=False, comment='银行账号'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('is_default', sa.Boolean, default=False, comment='是否默认账户'),
sa.Column('description', sa.String(256), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
# 创建定时任务表
op.create_table(
'scheduled_tasks',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('name', sa.String(128), nullable=False, comment='任务名称'),
sa.Column('task_type', sa.String(32), nullable=False, comment='类型: external_api / internal_method'),
sa.Column('schedule', sa.String(128), nullable=False, comment='Cron 表达式或间隔秒数'),
sa.Column('config', sa.Text, nullable=True, comment='任务配置 JSON'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('last_run_at', sa.String(64), nullable=True, comment='最后执行时间 ISO'),
sa.Column('last_status', sa.String(16), nullable=True, comment='最后执行状态'),
sa.Column('last_error', sa.Text, nullable=True, comment='最后执行错误信息'),
sa.Column('created_by', sa.String(32), nullable=True, comment='创建者管理员 ID'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
def downgrade():
# 删除新表
op.drop_table('scheduled_tasks')
op.drop_table('bank_accounts')
@@ -0,0 +1,51 @@
"""银行交易流水表
Revision ID: 20260814_bank_tx
Revises: 20260813_bank_scheduled
Create Date: 2026-08-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260814_bank_tx'
down_revision = '20260813_bank_scheduled'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'bank_transactions',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_id', sa.String(32), nullable=False, comment='银行账户ID'),
sa.Column('account_no', sa.String(64), nullable=False, comment='银行账号'),
sa.Column('transaction_no', sa.String(128), unique=True, nullable=True, comment='交易流水号(唯一)'),
sa.Column('transaction_time', sa.DateTime(timezone=True), nullable=True, comment='交易时间'),
sa.Column('transaction_amount', sa.String(32), nullable=False, comment='交易金额'),
sa.Column('balance_direction', sa.String(8), nullable=True, comment='借贷方向: DR/CR'),
sa.Column('balance_after', sa.String(32), nullable=True, comment='交易后余额'),
sa.Column('counterparty_name', sa.String(128), nullable=True, comment='对方户名'),
sa.Column('counterparty_account', sa.String(64), nullable=True, comment='对方账号'),
sa.Column('counterparty_bank', sa.String(128), nullable=True, comment='对方开户行'),
sa.Column('remark', sa.Text, nullable=True, comment='摘要/备注'),
sa.Column('digest_code', sa.String(64), nullable=True, comment='摘要码'),
sa.Column('purpose', sa.String(256), nullable=True, comment='用途'),
sa.Column('raw_data', sa.Text, nullable=True, comment='接口返回原始JSON'),
sa.Column('sync_batch', sa.String(32), nullable=True, comment='同步批次号'),
sa.Column('is_synced', sa.Boolean, default=True, comment='是否同步成功'),
)
op.create_index('ix_bank_transactions_account_id', 'bank_transactions', ['account_id'])
op.create_index('ix_bank_transactions_account_no', 'bank_transactions', ['account_no'])
op.create_index('ix_bank_transactions_transaction_time', 'bank_transactions', ['transaction_time'])
op.create_index('ix_bank_transactions_sync_batch', 'bank_transactions', ['sync_batch'])
op.create_index('ix_bank_transactions_account_time', 'bank_transactions', ['account_no', 'transaction_time'])
def downgrade():
op.drop_index('ix_bank_transactions_account_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_sync_batch', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_transaction_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_no', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_id', table_name='bank_transactions')
op.drop_table('bank_transactions')
@@ -0,0 +1,27 @@
"""银行流水表加 created_at 字段
Revision ID: 20260814_bank_tx_ca
Revises: 20260814_bank_tx
Create Date: 2026-08-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, use by Alembic.
revision = '20260814_bank_tx_ca'
down_revision = 'e7f2527691bb'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'bank_transactions',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('ix_bank_transactions_created_at', 'bank_transactions', ['created_at'])
def downgrade():
op.drop_index('ix_bank_transactions_created_at', table_name='bank_transactions')
op.drop_column('bank_transactions', 'created_at')
@@ -0,0 +1,444 @@
"""个人/团队订阅多实例、团队席位与统一线下订单
Revision ID: 20260814_multi_sub_team
Revises: 20260813_split_device_type
Create Date: 2026-08-14 11:31:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260814_multi_sub_team"
down_revision = "20260813_bank_scheduled"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def _constraints(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
names = {item.get("name") for item in inspector.get_check_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)}
return {name for name in names if name}
def _add_column(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def _drop_column(table: str, name: str) -> None:
if name in _columns(table):
op.drop_column(table, name)
def _drop_index(table: str, name: str) -> None:
if name in _indexes(table):
op.drop_index(name, table_name=table)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
# Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。
_add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
if "uq_credit_products_code" not in _indexes("credit_products"):
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
if "ix_credit_products_deleted_at" not in _indexes("credit_products"):
op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False)
# 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"])
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type IN ('subscription', 'team_subscription') "
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
# Subscription:个人/团队共表,多实例独立,不再保留升级链。
_add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1"))
_add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0"))
if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT"
)
op.execute(
"UPDATE user_credit_subscriptions s SET "
"product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), "
"product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), "
"monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), "
"quantity_snapshot = COALESCE(quantity_snapshot, 1), "
"first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), "
"regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), "
"activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), "
"actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)"
)
for col in (
"product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot",
"quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot",
):
if col in _columns("user_credit_subscriptions"):
op.alter_column("user_credit_subscriptions", col, nullable=False)
_drop_column("user_credit_subscriptions", "source_subscription_id")
_drop_column("user_credit_subscriptions", "upgrade_order_id")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current")
if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"])
# Period:删除升级预留字段,补充窗口索引。
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade")
_drop_column("user_credit_subscription_periods", "upgrade_order_id")
_drop_column("user_credit_subscription_periods", "reserved_at")
_drop_column("user_credit_subscription_periods", "revoked_at")
if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"):
op.create_index(
"ix_user_credit_subscription_periods_window",
"user_credit_subscription_periods",
["subscription_id", "valid_from", "expires_at"],
)
# Balance:增加个人/团队资金域。
_add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal"))
_add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True))
op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL")
op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal")
if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"):
op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT")
if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"):
op.create_check_constraint(
"ck_user_credit_balances_scope_fields",
"user_credit_balances",
"(credit_scope='personal' AND team_id IS NULL) OR "
"(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)",
)
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"):
op.create_index(
"ix_user_credit_balances_team_spendable",
"user_credit_balances",
["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"],
postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
)
# Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。
allocation_columns = [
sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True),
sa.Column("seat_id_snapshot", sa.String(32), nullable=True),
]
for column in allocation_columns:
_add_column("credit_record_allocations", column)
op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL")
op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal")
for name, cols in (
("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]),
):
if name not in _indexes("credit_record_allocations"):
op.create_index(name, "credit_record_allocations", cols)
# PaymentOrder:线上/线下统一主表,删除升级价格字段。
payment_columns = [
sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"),
sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"),
sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("operator_admin_id", sa.String(32), nullable=True),
sa.Column("offline_trade_no", sa.String(128), nullable=True),
sa.Column("offline_payment_detail", sa.String(128), nullable=True),
sa.Column("remark", sa.String(512), nullable=True),
sa.Column("refund_entitlement_status", sa.String(32), nullable=True),
]
for column in payment_columns:
_add_column("payment_orders", column)
op.execute(
"UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), "
"quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), "
"quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), "
"actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)"
)
op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment")
op.alter_column("payment_orders", "quantity", nullable=False, server_default="1")
for col in (
"source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot",
"deduction_amount_snapshot", "payable_amount_snapshot",
):
_drop_column("payment_orders", col)
if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT")
if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL")
if "ix_payorder_source_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"])
if "ix_payorder_team_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"])
_add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True))
if "ix_teams_first_subscription_paid_at" not in _indexes("teams"):
op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"])
if bind.dialect.name == "postgresql":
op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1")
# 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。
op.execute(
"SELECT setval('team_auto_name_seq', "
"GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) "
"FROM teams WHERE name ~ '^团队[0-9]+$'"
)
if "team_manager_history" not in tables:
op.create_table(
"team_manager_history",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"])
op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"])
op.create_index(
"uq_team_manager_history_current",
"team_manager_history",
["team_id"],
unique=True,
postgresql_where=sa.text("ended_at IS NULL"),
)
op.execute(
"INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) "
"SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() "
"FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL"
)
if "team_subscription_seats" not in tables:
op.create_table(
"team_subscription_seats",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False),
sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
)
op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"])
op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"])
op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"])
op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"])
op.create_index(
"uq_team_subscription_seats_active_user",
"team_subscription_seats",
["subscription_id", "user_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"),
)
if "team_subscription_seat_usages" not in tables:
op.create_table(
"team_subscription_seat_usages",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
)
op.create_index(
"uq_team_subscription_seat_usage_period",
"team_subscription_seat_usages",
["seat_id", "subscription_period_id"],
unique=True,
)
op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "team_subscription_seat_usages" in tables:
op.drop_table("team_subscription_seat_usages")
if "team_subscription_seats" in tables:
op.drop_table("team_subscription_seats")
if "team_manager_history" in tables:
op.drop_table("team_manager_history")
if bind.dialect.name == "postgresql":
op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq")
_drop_index("teams", "ix_teams_first_subscription_paid_at")
_drop_column("teams", "first_subscription_paid_at")
for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"):
_drop_index("payment_orders", name)
for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"):
if fk in _constraints("payment_orders"):
op.drop_constraint(fk, "payment_orders", type_="foreignkey")
for col in (
"refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no",
"operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot",
"quoted_unit_price_snapshot", "quantity", "order_source",
):
_drop_column("payment_orders", col)
_add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True))
if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"):
op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"])
_add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
_add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True))
for name in (
"ix_credit_record_allocations_team_period_user",
"ix_credit_record_allocations_team_manager_time",
"ix_credit_record_allocations_team_time",
):
_drop_index("credit_record_allocations", name)
for col in (
"seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot",
"team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot",
):
_drop_column("credit_record_allocations", col)
_drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable")
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"):
op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check")
if "fk_user_credit_balances_team" in _constraints("user_credit_balances"):
op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey")
_drop_column("user_credit_balances", "team_id")
_drop_column("user_credit_balances", "credit_scope")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window")
_add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"):
op.create_foreign_key(
"fk_user_credit_subscription_periods_upgrade_order",
"user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
_add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True))
_add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"])
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active")
if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"):
op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey")
for col in (
"actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot",
"first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot",
"product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id",
):
_drop_column("user_credit_subscriptions", col)
_add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_source_subscription",
"user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL"
)
if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_upgrade_order",
"user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
# 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code
# 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。
op.execute(
"UPDATE credit_products "
"SET product_type='subscription', is_active=false "
"WHERE product_type='team_subscription'"
)
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"])
_drop_index("credit_products", "ix_credit_products_deleted_at")
_drop_column("credit_products", "deleted_at")
@@ -0,0 +1,50 @@
"""兼容恢复积分套餐续费开关
Revision ID: 20260814_restore_renewal
Revises: 20260814_multi_sub_team
Create Date: 2026-08-14 13:25:00
说明
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled新环境执行到本迁移时为 no-op
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移本迁移负责安全补回字段
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复补回时订阅套餐默认 true积分增值包统一 false
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_restore_renewal"
down_revision = "20260814_multi_sub_team"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def upgrade() -> None:
if "renewal_enabled" not in _columns("credit_products"):
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
op.execute(
"UPDATE credit_products "
"SET renewal_enabled=false "
"WHERE product_type='credit_addon'"
)
def downgrade() -> None:
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
pass
@@ -0,0 +1,92 @@
"""订阅实例业务流水号
Revision ID: 20260814_subscription_no
Revises: 20260814_restore_renewal
Create Date: 2026-08-14 14:23:00
说明
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no
- 个人订阅前缀 PS团队订阅前缀 TS
- 全局共用 PostgreSQL Sequence避免 COUNT/MAX 并发冲突
- 历史记录按原 created_at 日期生成业务编号编号只用于展示/客服定位不参与业务排序和结算
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_subscription_no"
down_revision = "20260814_restore_renewal"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
if "subscription_no" not in _columns("user_credit_subscriptions"):
op.add_column(
"user_credit_subscriptions",
sa.Column(
"subscription_no",
sa.String(length=32),
nullable=True,
comment="订阅业务实例编号,供用户/客服/开发定位",
),
)
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
op.execute(
"""
UPDATE user_credit_subscriptions
SET subscription_no =
CASE
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
ELSE 'PS'
END
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
"""
)
op.alter_column(
"user_credit_subscriptions",
"subscription_no",
existing_type=sa.String(length=32),
nullable=False,
)
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
op.create_index(
"uq_user_credit_subscriptions_no",
"user_credit_subscriptions",
["subscription_no"],
unique=True,
)
def downgrade() -> None:
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
if "subscription_no" in _columns("user_credit_subscriptions"):
op.drop_column("user_credit_subscriptions", "subscription_no")
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
@@ -0,0 +1,364 @@
"""repair llm billing optimize idempotency
Revision ID: 20da1d353914
Revises: 6a3ea8d0b4c8
Create Date: 2026-07-24 13:35:48.513710
"""
from __future__ import annotations
import re
from typing import Any, Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine import Connection
# revision identifiers, used by Alembic.
revision: str = "20da1d353914"
down_revision: Union[str, None] = "6a3ea8d0b4c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_PROMPT_USAGE_COLUMN = "prompt_usage_snapshot_json"
_INDEX_SPECS: tuple[dict[str, Any], ...] = (
{
"name": "uq_credit_records_user_refund_target",
"table": "credit_records",
"columns": ("user_id", "refund_for_biz_key"),
"where_sql": "type = 'refund' AND refund_for_biz_key IS NOT NULL",
"definition_fragments": (
"(user_id, refund_for_biz_key)",
"type",
"refund",
"refund_for_biz_key is not null",
),
"duplicate_sql": """
SELECT
user_id,
refund_for_biz_key,
COUNT(*) AS duplicate_count
FROM credit_records
WHERE type = 'refund'
AND refund_for_biz_key IS NOT NULL
GROUP BY user_id, refund_for_biz_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, refund_for_biz_key
LIMIT 20
""",
},
{
"name": "uq_genrec_user_idempotency_active",
"table": "generation_records",
"columns": ("user_id", "idempotency_key"),
"where_sql": "idempotency_key IS NOT NULL AND deleted_at IS NULL",
"definition_fragments": (
"(user_id, idempotency_key)",
"idempotency_key is not null",
"deleted_at is null",
),
"duplicate_sql": """
SELECT
user_id,
idempotency_key,
COUNT(*) AS duplicate_count
FROM generation_records
WHERE idempotency_key IS NOT NULL
AND deleted_at IS NULL
GROUP BY user_id, idempotency_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, idempotency_key
LIMIT 20
""",
},
{
"name": "uq_token_usage_user_biz_key",
"table": "token_usage",
"columns": ("user_id", "biz_key"),
"where_sql": "biz_key IS NOT NULL",
"definition_fragments": (
"(user_id, biz_key)",
"biz_key is not null",
),
"duplicate_sql": """
SELECT
user_id,
biz_key,
COUNT(*) AS duplicate_count
FROM token_usage
WHERE user_id IS NOT NULL
AND biz_key IS NOT NULL
GROUP BY user_id, biz_key
HAVING COUNT(*) > 1
ORDER BY duplicate_count DESC, user_id, biz_key
LIMIT 20
""",
},
)
def _bind() -> Connection:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError(
"Migration 20da1d353914 requires PostgreSQL; "
f"current dialect is {bind.dialect.name!r}."
)
return bind
def _current_schema(bind: Connection) -> str:
schema = bind.execute(sa.text("SELECT current_schema()")).scalar_one_or_none()
if not schema:
raise RuntimeError("Unable to resolve PostgreSQL current_schema().")
return str(schema)
def _require_table(bind: Connection, schema: str, table_name: str) -> None:
exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = :schema
AND table_name = :table_name
)
"""
),
{"schema": schema, "table_name": table_name},
).scalar_one()
if not bool(exists):
raise RuntimeError(
f"Required table {schema}.{table_name} does not exist; "
"refusing to apply migration on an unexpected schema baseline."
)
def _column_exists(
bind: Connection,
schema: str,
table_name: str,
column_name: str,
) -> bool:
return bool(
bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table_name
AND column_name = :column_name
)
"""
),
{
"schema": schema,
"table_name": table_name,
"column_name": column_name,
},
).scalar_one()
)
def _index_info(
bind: Connection,
schema: str,
index_name: str,
) -> dict[str, Any] | None:
row = bind.execute(
sa.text(
"""
SELECT
table_rel.relname AS table_name,
index_meta.indisvalid AS is_valid,
index_meta.indisunique AS is_unique,
pg_get_indexdef(index_rel.oid) AS index_definition
FROM pg_class AS index_rel
JOIN pg_namespace AS namespace_rel
ON namespace_rel.oid = index_rel.relnamespace
JOIN pg_index AS index_meta
ON index_meta.indexrelid = index_rel.oid
JOIN pg_class AS table_rel
ON table_rel.oid = index_meta.indrelid
WHERE namespace_rel.nspname = :schema
AND index_rel.relname = :index_name
"""
),
{"schema": schema, "index_name": index_name},
).mappings().one_or_none()
return dict(row) if row is not None else None
def _normalize_index_definition(value: str) -> str:
normalized = value.lower().replace('"', "")
normalized = re.sub(r"::[a-z_ ]+(?:\[\])?", "", normalized)
normalized = re.sub(r"[()]", lambda match: match.group(0), normalized)
normalized = re.sub(r"\s+", " ", normalized)
return normalized.strip()
def _assert_existing_index_matches(spec: dict[str, Any], info: dict[str, Any]) -> None:
if str(info["table_name"]) != str(spec["table"]):
raise RuntimeError(
f"Index {spec['name']} already exists on table {info['table_name']}, "
f"expected table {spec['table']}."
)
if not bool(info["is_unique"]):
raise RuntimeError(
f"Index {spec['name']} already exists but is not UNIQUE."
)
definition = _normalize_index_definition(str(info["index_definition"] or ""))
missing = [
fragment
for fragment in spec["definition_fragments"]
if fragment not in definition
]
if missing:
raise RuntimeError(
f"Index {spec['name']} already exists with an unexpected definition; "
f"missing expected fragments: {missing}. Actual definition: "
f"{info['index_definition']}"
)
def _assert_no_duplicates(bind: Connection, spec: dict[str, Any]) -> None:
rows = bind.execute(sa.text(spec["duplicate_sql"])).mappings().all()
if not rows:
return
samples = "; ".join(
", ".join(f"{key}={value!r}" for key, value in row.items())
for row in rows
)
raise RuntimeError(
f"Cannot create unique index {spec['name']}: duplicate historical data "
f"exists. Resolve the conflicts first. Samples: {samples}"
)
def _quote_identifier(bind: Connection, value: str) -> str:
return bind.dialect.identifier_preparer.quote(value)
def _create_missing_indexes_concurrently(
bind: Connection,
schema: str,
) -> None:
missing_specs: list[dict[str, Any]] = []
for spec in _INDEX_SPECS:
_require_table(bind, schema, str(spec["table"]))
info = _index_info(bind, schema, str(spec["name"]))
if info is None:
_assert_no_duplicates(bind, spec)
missing_specs.append(spec)
continue
if bool(info["is_valid"]):
_assert_existing_index_matches(spec, info)
continue
# A failed CREATE INDEX CONCURRENTLY may leave an invalid index behind.
# Remove only the invalid index with this migration-owned name, then retry.
missing_specs.append(spec)
if not missing_specs:
return
context = op.get_context()
with context.autocommit_block():
for spec in missing_specs:
current = _index_info(bind, schema, str(spec["name"]))
quoted_index = _quote_identifier(bind, str(spec["name"]))
quoted_schema = _quote_identifier(bind, schema)
if current is not None:
if bool(current["is_valid"]):
_assert_existing_index_matches(spec, current)
continue
op.execute(
sa.text(
f"DROP INDEX CONCURRENTLY IF EXISTS "
f"{quoted_schema}.{quoted_index}"
)
)
quoted_table = _quote_identifier(bind, str(spec["table"]))
quoted_columns = ", ".join(
_quote_identifier(bind, str(column))
for column in spec["columns"]
)
op.execute(
sa.text(
f"CREATE UNIQUE INDEX CONCURRENTLY {quoted_index} "
f"ON {quoted_schema}.{quoted_table} ({quoted_columns}) "
f"WHERE {spec['where_sql']}"
)
)
def _drop_indexes_concurrently(bind: Connection, schema: str) -> None:
existing_names = [
str(spec["name"])
for spec in _INDEX_SPECS
if _index_info(bind, schema, str(spec["name"])) is not None
]
if not existing_names:
return
quoted_schema = _quote_identifier(bind, schema)
context = op.get_context()
with context.autocommit_block():
for index_name in existing_names:
quoted_index = _quote_identifier(bind, index_name)
op.execute(
sa.text(
f"DROP INDEX CONCURRENTLY IF EXISTS "
f"{quoted_schema}.{quoted_index}"
)
)
def upgrade() -> None:
bind = _bind()
schema = _current_schema(bind)
_require_table(bind, schema, "generation_records")
if not _column_exists(
bind,
schema,
"generation_records",
_PROMPT_USAGE_COLUMN,
):
op.add_column(
"generation_records",
sa.Column(_PROMPT_USAGE_COLUMN, sa.Text(), nullable=True),
schema=schema,
)
_create_missing_indexes_concurrently(bind, schema)
def downgrade() -> None:
bind = _bind()
schema = _current_schema(bind)
_drop_indexes_concurrently(bind, schema)
_require_table(bind, schema, "generation_records")
if _column_exists(
bind,
schema,
"generation_records",
_PROMPT_USAGE_COLUMN,
):
op.drop_column(
"generation_records",
_PROMPT_USAGE_COLUMN,
schema=schema,
)
@@ -0,0 +1,180 @@
"""add video upscale pipeline
Revision ID: 3f47680a71d0
Revises: abae3e1c70f7
Create Date: 2026-07-16 11:08:16.449960
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3f47680a71d0"
down_revision: Union[str, None] = "abae3e1c70f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"chat_generation_tasks",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"chat_generation_tasks",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"generation_records",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"generation_records",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("pipeline_stage", sa.String(length=48), nullable=True),
)
op.create_index(
"ix_generation_records_pipeline_stage",
"generation_records",
["pipeline_stage"],
unique=False,
)
op.create_table(
"video_upscale_tasks",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("chat_generation_task_id", sa.String(length=32), nullable=True),
sa.Column("generation_record_id", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
sa.Column("stage", sa.String(length=48), server_default="upscale_queued", nullable=False),
sa.Column("processor_key", sa.String(length=64), nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("failure_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("manual_retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("source_local_path", sa.Text(), nullable=True),
sa.Column("source_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("source_width", sa.Integer(), nullable=True),
sa.Column("source_height", sa.Integer(), nullable=True),
sa.Column("source_duration_seconds", sa.Float(), nullable=True),
sa.Column("source_deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_delete_error", sa.Text(), nullable=True),
sa.Column("source_remote_url", sa.Text(), nullable=True),
sa.Column("source_remote_url_signed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_last_probe_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_probe_status", sa.String(length=32), nullable=True),
sa.Column("input_source_type", sa.String(length=32), nullable=True),
sa.Column("input_source_fallback_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("target_width", sa.Integer(), nullable=False),
sa.Column("target_height", sa.Integer(), nullable=False),
sa.Column("effective_target_width", sa.Integer(), nullable=True),
sa.Column("effective_target_height", sa.Integer(), nullable=True),
sa.Column("provider_task_id", sa.String(length=160), nullable=True),
sa.Column("provider_request_json", sa.Text(), nullable=True),
sa.Column("provider_response_json", sa.Text(), nullable=True),
sa.Column("provider_output_url", sa.Text(), nullable=True),
sa.Column("provider_output_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("final_local_path", sa.Text(), nullable=True),
sa.Column("final_resource_url", sa.Text(), nullable=True),
sa.Column("final_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("celery_task_id", sa.String(length=160), nullable=True),
sa.Column("lease_token", sa.String(length=64), nullable=True),
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
sa.ForeignKeyConstraint(
["chat_generation_task_id"],
["chat_generation_tasks.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["generation_record_id"],
["generation_records.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_video_upscale_tasks_provider_task_id",
"video_upscale_tasks",
["provider_task_id"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_lease",
"video_upscale_tasks",
["status", "lease_until"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_next_retry",
"video_upscale_tasks",
["status", "next_retry_at"],
unique=False,
)
op.create_index(
"uq_video_upscale_tasks_chat_task",
"video_upscale_tasks",
["chat_generation_task_id"],
unique=True,
)
op.create_index(
"uq_video_upscale_tasks_generation_record",
"video_upscale_tasks",
["generation_record_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_video_upscale_tasks_generation_record", table_name="video_upscale_tasks")
op.drop_index("uq_video_upscale_tasks_chat_task", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_next_retry", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_lease", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_provider_task_id", table_name="video_upscale_tasks")
op.drop_table("video_upscale_tasks")
op.drop_index("ix_generation_records_pipeline_stage", table_name="generation_records")
op.drop_column("generation_records", "pipeline_stage")
op.drop_column("generation_records", "video_upscale_snapshot_json")
op.drop_column("generation_records", "video_upscale_enabled_snapshot")
op.drop_column("generation_records", "provider_generation_resolution")
op.drop_column("chat_generation_tasks", "video_upscale_snapshot_json")
op.drop_column("chat_generation_tasks", "video_upscale_enabled_snapshot")
op.drop_column("chat_generation_tasks", "provider_generation_resolution")
@@ -0,0 +1,773 @@
"""repair generation record frozen config
Revision ID: 6a3ea8d0b4c8
Revises: 7cf645f7c418
Create Date: 2026-07-23 14:01:54.109369
修复GenerationRecord相关数据并备份修改数据
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6a3ea8d0b4c8"
down_revision: Union[str, None] = "7cf645f7c418"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_KEY = "repair_generation_record_frozen_config_20260723"
BACKUP_TABLE = "generation_records_repair_backup_20260723"
_TARGET_CONDITION = """
gr.deleted_at IS NULL
AND p.deleted_at IS NULL
AND gr.status = 'prompt_optimized'
AND gr.gen_type IN ('video', 'image')
AND (
gr.engine_id IS NULL
OR gr.engine_id = ''
OR gr.engine_snapshot_json IS NULL
OR gr.engine_snapshot_json = ''
OR gr.engine_snapshot_json IS NOT JSON
OR (
gr.gen_type = 'video'
AND (
gr.duration IS NULL
OR gr.aspect_ratio IS NULL
OR gr.aspect_ratio = ''
OR gr.resolution IS NULL
OR gr.resolution = ''
)
)
OR (
gr.gen_type = 'image'
AND (
gr.image_size IS NULL
OR gr.image_size = ''
OR gr.image_proportion IS NULL
OR gr.image_proportion = ''
OR gr.image_px IS NULL
OR gr.image_px = ''
)
)
)
"""
_CREATE_BACKUP_TABLE_SQL = f"""
CREATE TABLE IF NOT EXISTS {BACKUP_TABLE} (
repair_key VARCHAR(96) NOT NULL,
record_id VARCHAR(32) NOT NULL,
gen_type VARCHAR(16),
old_engine_id VARCHAR(32),
old_engine_snapshot_json TEXT,
old_duration INTEGER,
old_aspect_ratio VARCHAR(8),
old_resolution VARCHAR(8),
old_provider_generation_resolution VARCHAR(16),
old_video_upscale_enabled_snapshot BOOLEAN,
old_video_upscale_snapshot_json TEXT,
old_image_size VARCHAR(8),
old_image_proportion VARCHAR(8),
old_image_px VARCHAR(10),
old_include_media_references BOOLEAN,
old_updated_at TIMESTAMP WITH TIME ZONE,
backed_up_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (repair_key, record_id)
)
"""
_INSERT_BACKUP_SQL = f"""
INSERT INTO {BACKUP_TABLE} (
repair_key,
record_id,
gen_type,
old_engine_id,
old_engine_snapshot_json,
old_duration,
old_aspect_ratio,
old_resolution,
old_provider_generation_resolution,
old_video_upscale_enabled_snapshot,
old_video_upscale_snapshot_json,
old_image_size,
old_image_proportion,
old_image_px,
old_include_media_references,
old_updated_at
)
SELECT
:repair_key,
gr.id,
gr.gen_type,
gr.engine_id,
gr.engine_snapshot_json,
gr.duration,
gr.aspect_ratio,
gr.resolution,
gr.provider_generation_resolution,
gr.video_upscale_enabled_snapshot,
gr.video_upscale_snapshot_json,
gr.image_size,
gr.image_proportion,
gr.image_px,
gr.include_media_references,
gr.updated_at
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
ON CONFLICT (repair_key, record_id) DO NOTHING
"""
# PostgreSQL JSON helper snippets used inside SQL expressions. These avoid calling
# jsonb_array_elements_text/jsonb_object_keys on malformed JSON or the wrong JSON type.
_VIDEO_RATIOS_JSON = """
CASE
WHEN ve.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_ratios::jsonb) = 'array'
THEN ve.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_RESOLUTIONS_JSON = """
CASE
WHEN ve.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_resolutions::jsonb) = 'array'
THEN ve.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_VIDEO_DURATIONS_JSON = """
CASE
WHEN ve.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(ve.supported_durations::jsonb) = 'array'
THEN ve.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_MODELS_JSON = """
CASE
WHEN px_resolved.supported_models IS JSON THEN
CASE
WHEN jsonb_typeof(px_resolved.supported_models::jsonb) = 'array'
THEN px_resolved.supported_models::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_IE = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_IMAGE_SUPPORTED_SIZES_JSON_ES = """
CASE
WHEN ie.supported_sizes IS JSON THEN
CASE
WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{}'::jsonb
END
ELSE '{}'::jsonb
END
"""
_REPAIR_VIDEO_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
),
resolved AS (
SELECT
tr.id AS record_id,
ve.id AS selected_engine_id,
ve.name AS engine_name,
ve.provider,
ve.api_base,
ve.api_key,
ve.model_name,
ve.generate_url,
ve.query_url,
ve.supported_ratios,
ve.supported_resolutions,
ve.supported_durations,
ve.max_duration,
ve.max_image_count,
ve.max_video_count,
ve.max_audio_count,
ve.supports_universal_reference,
ve.supports_first_last_frame,
ve.multi_generation_enabled,
ve.max_generation_count,
COALESCE(
CASE
WHEN tr.duration IS NOT NULL
AND tr.duration > 0
AND (
jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR tr.duration <= ve.max_duration
)
THEN tr.duration
END,
(
SELECT d.value::int
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) WITH ORDINALITY d(value, ord)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int > 0
AND (
ve.max_duration IS NULL
OR ve.max_duration <= 0
OR d.value::int <= ve.max_duration
)
ORDER BY d.ord
LIMIT 1
),
CASE
WHEN ve.max_duration IS NOT NULL AND ve.max_duration > 0 THEN LEAST(4, ve.max_duration)
ELSE 4
END
) AS final_duration,
COALESCE(
CASE
WHEN tr.aspect_ratio IS NOT NULL
AND tr.aspect_ratio <> ''
AND (
jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
THEN tr.aspect_ratio
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = '16:9'
)
THEN '16:9'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'16:9'
) AS final_aspect_ratio,
COALESCE(
CASE
WHEN tr.resolution IS NOT NULL
AND tr.resolution <> ''
AND (
jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
THEN tr.resolution
END,
CASE
WHEN EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = '480p'
)
THEN '480p'
END,
(
SELECT r.value
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) WITH ORDINALITY r(value, ord)
WHERE r.value <> ''
ORDER BY r.ord
LIMIT 1
),
'480p'
) AS final_resolution
FROM target_records tr
JOIN LATERAL (
SELECT ve.*
FROM video_engines ve
WHERE ve.is_active IS TRUE
AND ve.deleted_at IS NULL
ORDER BY
CASE
WHEN (
(
tr.duration IS NULL
OR tr.duration <= 0
OR jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value)
WHERE d.value ~ '^[0-9]+$'
AND d.value::int = tr.duration
)
)
AND (
tr.aspect_ratio IS NULL
OR tr.aspect_ratio = ''
OR jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value)
WHERE r.value = tr.aspect_ratio
)
)
AND (
tr.resolution IS NULL
OR tr.resolution = ''
OR jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0
OR EXISTS (
SELECT 1
FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value)
WHERE r.value = tr.resolution
)
)
)
THEN 0
ELSE 1
END,
ve.priority DESC,
ve.created_at ASC,
ve.id ASC
LIMIT 1
) ve ON TRUE
)
UPDATE generation_records gr
SET
engine_id = resolved.selected_engine_id,
duration = resolved.final_duration,
aspect_ratio = resolved.final_aspect_ratio,
resolution = resolved.final_resolution,
provider_generation_resolution = resolved.final_resolution,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'video',
'id', resolved.selected_engine_id,
'name', resolved.engine_name,
'provider', resolved.provider,
'api_base', resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', resolved.model_name,
'generate_url', resolved.generate_url,
'query_url', resolved.query_url,
'supported_ratios', CASE
WHEN resolved.supported_ratios IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_ratios::jsonb) = 'array'
THEN resolved.supported_ratios::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_resolutions', CASE
WHEN resolved.supported_resolutions IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_resolutions::jsonb) = 'array'
THEN resolved.supported_resolutions::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'supported_durations', CASE
WHEN resolved.supported_durations IS JSON THEN
CASE
WHEN jsonb_typeof(resolved.supported_durations::jsonb) = 'array'
THEN resolved.supported_durations::jsonb
ELSE '[]'::jsonb
END
ELSE '[]'::jsonb
END,
'max_duration', resolved.max_duration,
'max_image_count', COALESCE(resolved.max_image_count, 0),
'max_video_count', COALESCE(resolved.max_video_count, 0),
'max_audio_count', COALESCE(resolved.max_audio_count, 0),
'supports_universal_reference', COALESCE(resolved.supports_universal_reference, FALSE),
'supports_first_last_frame', COALESCE(resolved.supports_first_last_frame, FALSE),
'multi_generation_enabled', COALESCE(resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(resolved.max_generation_count, 1))),
'selected_ratio', resolved.final_aspect_ratio,
'selected_resolution', resolved.final_resolution,
'selected_duration', resolved.final_duration
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM resolved
WHERE gr.id = resolved.record_id
"""
_REPAIR_IMAGE_SQL = f"""
WITH target_records AS (
SELECT gr.*
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
),
engine_selected AS (
SELECT
tr.*,
ie.id AS selected_engine_id,
ie.name AS engine_name,
ie.provider,
ie.api_base,
ie.api_key,
ie.model_name,
ie.generate_url,
ie.supported_models,
ie.supported_sizes,
ie.default_size,
ie.multi_generation_enabled,
ie.max_generation_count,
ie.multi_image_max_images,
ie.max_reference_image_count,
ie.output_format,
CASE
WHEN ie.supported_sizes IS JSON AND jsonb_typeof(ie.supported_sizes::jsonb) = 'object'
THEN ie.supported_sizes::jsonb
ELSE '{{}}'::jsonb
END AS sizes_json
FROM target_records tr
JOIN LATERAL (
SELECT ie.*
FROM image_engines ie
WHERE ie.is_active IS TRUE
AND ie.deleted_at IS NULL
ORDER BY
CASE
WHEN (
ie.supported_sizes IS NULL
OR ie.supported_sizes = ''
OR ie.supported_sizes IS NOT JSON
OR jsonb_typeof(ie.supported_sizes::jsonb) <> 'object'
OR ie.supported_sizes::jsonb = '{{}}'::jsonb
OR (
(
tr.image_size IS NULL
OR tr.image_size = ''
OR ie.supported_sizes::jsonb ? tr.image_size
)
AND (
tr.image_size IS NULL
OR tr.image_size = ''
OR tr.image_proportion IS NULL
OR tr.image_proportion = ''
OR (
jsonb_typeof(ie.supported_sizes::jsonb -> tr.image_size) = 'object'
AND (ie.supported_sizes::jsonb -> tr.image_size) ? tr.image_proportion
)
)
)
)
THEN 0
ELSE 1
END,
ie.priority DESC,
ie.created_at ASC,
ie.id ASC
LIMIT 1
) ie ON TRUE
),
size_selected AS (
SELECT
es.*,
COALESCE(
CASE
WHEN es.image_size IS NOT NULL
AND es.image_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.image_size)
THEN es.image_size
END,
CASE
WHEN es.default_size IS NOT NULL
AND es.default_size <> ''
AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.default_size)
THEN es.default_size
END,
CASE WHEN es.sizes_json ? '2K' THEN '2K' END,
(
SELECT key
FROM jsonb_object_keys(es.sizes_json) AS key
ORDER BY key
LIMIT 1
),
'2K'
) AS final_image_size
FROM engine_selected es
),
ratio_selected AS (
SELECT
ss.*,
CASE
WHEN jsonb_typeof(ss.sizes_json -> ss.final_image_size) = 'object'
THEN ss.sizes_json -> ss.final_image_size
ELSE '{{}}'::jsonb
END AS ratio_json
FROM size_selected ss
),
final_resolved AS (
SELECT
rs.*,
COALESCE(
CASE
WHEN rs.image_proportion IS NOT NULL
AND rs.image_proportion <> ''
AND (rs.ratio_json = '{{}}'::jsonb OR rs.ratio_json ? rs.image_proportion)
THEN rs.image_proportion
END,
CASE WHEN rs.ratio_json ? '1:1' THEN '1:1' END,
(
SELECT key
FROM jsonb_object_keys(rs.ratio_json) AS key
ORDER BY key
LIMIT 1
),
'1:1'
) AS final_image_proportion
FROM ratio_selected rs
),
px_resolved AS (
SELECT
fr.*,
regexp_replace(
lower(
replace(
COALESCE(
NULLIF(fr.ratio_json ->> fr.final_image_proportion, ''),
NULLIF(fr.image_px, ''),
'2048x2048'
),
'×',
'x'
)
),
'x+',
'x',
'g'
) AS final_image_px
FROM final_resolved fr
)
UPDATE generation_records gr
SET
engine_id = px_resolved.selected_engine_id,
image_size = px_resolved.final_image_size,
image_proportion = px_resolved.final_image_proportion,
image_px = LEFT(px_resolved.final_image_px, 10),
provider_generation_resolution = NULL,
video_upscale_enabled_snapshot = FALSE,
video_upscale_snapshot_json = NULL,
engine_snapshot_json = jsonb_build_object(
'engine_type', 'image',
'id', px_resolved.selected_engine_id,
'name', px_resolved.engine_name,
'provider', px_resolved.provider,
'api_base', px_resolved.api_base,
'api_key_masked', CASE WHEN COALESCE(px_resolved.api_key, '') <> '' THEN '****' ELSE '' END,
'model_name', px_resolved.model_name,
'generate_url', px_resolved.generate_url,
'supported_models', {_IMAGE_SUPPORTED_MODELS_JSON},
'default_size', px_resolved.default_size,
'multi_generation_enabled', COALESCE(px_resolved.multi_generation_enabled, FALSE),
'max_generation_count', LEAST(5, GREATEST(1, COALESCE(px_resolved.max_generation_count, 1))),
'multi_image_max_images', COALESCE(px_resolved.multi_image_max_images, 15),
'max_reference_image_count', COALESCE(px_resolved.max_reference_image_count, 0),
'output_format', lower(trim(COALESCE(px_resolved.output_format, ''))),
'selected_size', px_resolved.final_image_size,
'selected_proportion', px_resolved.final_image_proportion,
'selected_px', LEFT(px_resolved.final_image_px, 10)
)::text,
include_media_references = COALESCE(gr.include_media_references, FALSE),
updated_at = NOW()
FROM px_resolved
WHERE gr.id = px_resolved.id
"""
_DOWNGRADE_SQL = f"""
UPDATE generation_records gr
SET
engine_id = b.old_engine_id,
engine_snapshot_json = b.old_engine_snapshot_json,
duration = b.old_duration,
aspect_ratio = b.old_aspect_ratio,
resolution = b.old_resolution,
provider_generation_resolution = b.old_provider_generation_resolution,
video_upscale_enabled_snapshot = COALESCE(b.old_video_upscale_enabled_snapshot, FALSE),
video_upscale_snapshot_json = b.old_video_upscale_snapshot_json,
image_size = b.old_image_size,
image_proportion = b.old_image_proportion,
image_px = b.old_image_px,
include_media_references = COALESCE(b.old_include_media_references, FALSE),
updated_at = b.old_updated_at
FROM {BACKUP_TABLE} b
WHERE b.repair_key = :repair_key
AND b.record_id = gr.id
AND gr.status = 'prompt_optimized'
AND gr.deleted_at IS NULL
"""
_DROP_BACKUP_TABLE_SQL = f"DROP TABLE IF EXISTS {BACKUP_TABLE}"
def _scalar_int(sql: str, **params: object) -> int:
bind = op.get_bind()
value = bind.execute(sa.text(sql), params).scalar()
return int(value or 0)
def _ensure_required_engines() -> None:
video_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'video'
"""
)
image_targets = _scalar_int(
f"""
SELECT COUNT(*)
FROM generation_records gr
JOIN projects p ON p.id = gr.project_id
WHERE {_TARGET_CONDITION}
AND gr.gen_type = 'image'
"""
)
video_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM video_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
image_engines = _scalar_int(
"""
SELECT COUNT(*)
FROM image_engines
WHERE is_active IS TRUE
AND deleted_at IS NULL
"""
)
if video_targets > 0 and video_engines <= 0:
raise RuntimeError("存在待修复的视频生成记录,但没有可用的视频引擎")
if image_targets > 0 and image_engines <= 0:
raise RuntimeError("存在待修复的图片生成记录,但没有可用的图片引擎")
def _ensure_postgresql() -> None:
bind = op.get_bind()
dialect_name = getattr(bind.dialect, "name", "")
if dialect_name != "postgresql":
raise RuntimeError("本迁移只支持 PostgreSQL,当前数据库类型不支持此数据修复")
def _execute(sql: str, **params: object) -> None:
bind = op.get_bind()
bind.execute(sa.text(sql), params)
def _backup_table_exists() -> bool:
return (
_scalar_int(
"""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table_name
""",
table_name=BACKUP_TABLE,
)
> 0
)
def upgrade() -> None:
"""Repair historical prompt_optimized GenerationRecord frozen config.
This migration only fills missing frozen generation configuration for old
records. It does not change status, charge credits, create tasks, enqueue
Celery jobs, or call application services.
"""
_ensure_postgresql()
_execute(_CREATE_BACKUP_TABLE_SQL)
_ensure_required_engines()
# Keep the original values for a guarded downgrade. The ON CONFLICT clause
# makes this migration safe to re-run inside a partially repaired database.
_execute(_INSERT_BACKUP_SQL, repair_key=REPAIR_KEY)
# Repair by generation type. The update SQL only targets records that are
# still prompt_optimized and still incomplete, so already repaired records
# are skipped.
_execute(_REPAIR_VIDEO_SQL)
_execute(_REPAIR_IMAGE_SQL)
def downgrade() -> None:
"""Restore backed-up values for records that are still not generated.
Records that moved past prompt_optimized are intentionally not restored;
reverting those after users have generated media would corrupt production
state.
"""
_ensure_postgresql()
if not _backup_table_exists():
return
_execute(_DOWNGRADE_SQL, repair_key=REPAIR_KEY)
_execute(_DROP_BACKUP_TABLE_SQL)
@@ -0,0 +1,266 @@
"""add celery runtime fencing fields
Revision ID: 7cf645f7c418
Revises: d8ebe79ab575
Create Date: 2026-07-22 13:45:57.949417
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision: str = "7cf645f7c418"
down_revision: str | None = "d8ebe79ab575"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_SHOT_SEGMENT_TABLE = "shot_replicate_segments"
_SHOT_TASK_SET_TABLE = "shot_replicate_task_sets"
_ANALYSIS_ATTEMPT_COLUMN = "analysis_attempt_no"
# SQLAlchemy Inspector caches reflected metadata. Always create a fresh
# inspector after DDL so partially applied migrations are detected correctly.
def _inspector() -> Inspector:
return sa.inspect(op.get_bind())
def _require_table(table_name: str) -> None:
if not _inspector().has_table(table_name):
raise RuntimeError(
f"Required table {table_name!r} does not exist; "
"refusing to mark migration 7cf645f7c418 as applied incompletely."
)
def _column_names(table_name: str) -> set[str]:
return {
str(column["name"])
for column in _inspector().get_columns(table_name)
}
def _add_column_if_missing(table_name: str, column: sa.Column[object]) -> bool:
"""Add one column only when its name is absent.
Returns True when DDL was executed and False when the column already exists.
"""
if column.name in _column_names(table_name):
return False
op.add_column(table_name, column)
return True
def _drop_column_if_exists(table_name: str, column_name: str) -> bool:
"""Drop one column only when both the table and column still exist."""
inspector = _inspector()
if not inspector.has_table(table_name):
return False
if column_name not in {
str(column["name"])
for column in inspector.get_columns(table_name)
}:
return False
op.drop_column(table_name, column_name)
return True
def _index_definitions(table_name: str) -> dict[str, dict[str, object]]:
return {
str(index["name"]): index
for index in _inspector().get_indexes(table_name)
if index.get("name")
}
def _ensure_index(
index_name: str,
table_name: str,
columns: Sequence[str],
*,
unique: bool = False,
) -> None:
"""Create an index if absent and reject a conflicting same-name index."""
required_columns = tuple(columns)
existing_columns = _column_names(table_name)
missing_columns = [name for name in required_columns if name not in existing_columns]
if missing_columns:
raise RuntimeError(
f"Cannot create index {index_name!r}: table {table_name!r} "
f"is missing columns {missing_columns!r}."
)
existing = _index_definitions(table_name).get(index_name)
if existing is not None:
reflected_columns = tuple(
str(name)
for name in (existing.get("column_names") or [])
)
reflected_unique = bool(existing.get("unique", False))
if reflected_columns != required_columns or reflected_unique != unique:
raise RuntimeError(
f"Index {index_name!r} already exists with an unexpected definition: "
f"columns={reflected_columns!r}, unique={reflected_unique!r}; "
f"expected columns={required_columns!r}, unique={unique!r}."
)
return
op.create_index(
index_name,
table_name,
list(required_columns),
unique=unique,
)
def _drop_index_if_exists(index_name: str, table_name: str) -> bool:
"""Drop one index only when the table and named index still exist."""
inspector = _inspector()
if not inspector.has_table(table_name):
return False
existing_names = {
str(index["name"])
for index in inspector.get_indexes(table_name)
if index.get("name")
}
if index_name not in existing_names:
return False
op.drop_index(index_name, table_name=table_name)
return True
def _ensure_analysis_attempt_column(table_name: str) -> None:
"""Create/backfill the non-null attempt counter safely for existing rows."""
_add_column_if_missing(
table_name,
sa.Column(
_ANALYSIS_ATTEMPT_COLUMN,
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
),
)
# Also repairs a partially applied/manual migration where the column exists
# but contains NULL values or still carries the temporary database default.
quoted_table = op.get_bind().dialect.identifier_preparer.quote(table_name)
quoted_column = op.get_bind().dialect.identifier_preparer.quote(
_ANALYSIS_ATTEMPT_COLUMN
)
op.execute(
sa.text(
f"UPDATE {quoted_table} "
f"SET {quoted_column} = 1 "
f"WHERE {quoted_column} IS NULL"
)
)
op.alter_column(
table_name,
_ANALYSIS_ATTEMPT_COLUMN,
existing_type=sa.Integer(),
nullable=False,
server_default=None,
)
def upgrade() -> None:
"""Add only the Celery fencing fields required by shot replication."""
_require_table(_SHOT_SEGMENT_TABLE)
_require_table(_SHOT_TASK_SET_TABLE)
# Segment split fencing.
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("split_claim_token", sa.String(length=64), nullable=True),
)
# Segment analysis fencing.
_ensure_analysis_attempt_column(_SHOT_SEGMENT_TABLE)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
)
_add_column_if_missing(
_SHOT_SEGMENT_TABLE,
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
)
_ensure_index(
"idx_shot_segments_analysis_lease",
_SHOT_SEGMENT_TABLE,
("analysis_status", "analysis_lease_until"),
)
_ensure_index(
"idx_shot_segments_split_lease",
_SHOT_SEGMENT_TABLE,
("split_status", "split_lease_until"),
)
# Task-set analysis fencing.
_ensure_analysis_attempt_column(_SHOT_TASK_SET_TABLE)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_claim_token", sa.String(length=64), nullable=True),
)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_started_at", sa.DateTime(timezone=True), nullable=True),
)
_add_column_if_missing(
_SHOT_TASK_SET_TABLE,
sa.Column("analysis_lease_until", sa.DateTime(timezone=True), nullable=True),
)
_ensure_index(
"idx_shot_task_sets_analysis_lease",
_SHOT_TASK_SET_TABLE,
("analysis_status", "analysis_lease_until"),
)
def downgrade() -> None:
"""Remove only fields and indexes introduced by this revision.
Every operation is guarded so a partially reverted database does not fail
merely because an index or column is already absent.
"""
_drop_index_if_exists(
"idx_shot_task_sets_analysis_lease",
_SHOT_TASK_SET_TABLE,
)
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_lease_until")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_started_at")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, "analysis_claim_token")
_drop_column_if_exists(_SHOT_TASK_SET_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
_drop_index_if_exists(
"idx_shot_segments_split_lease",
_SHOT_SEGMENT_TABLE,
)
_drop_index_if_exists(
"idx_shot_segments_analysis_lease",
_SHOT_SEGMENT_TABLE,
)
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_lease_until")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_started_at")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "analysis_claim_token")
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, _ANALYSIS_ATTEMPT_COLUMN)
_drop_column_if_exists(_SHOT_SEGMENT_TABLE, "split_claim_token")
@@ -0,0 +1,650 @@
"""add credit product renewal enabled
Revision ID: 8a6d2f4c9b10
Revises: 7f3c8a2d9e41
Create Date: 2026-08-06 17:20:00
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from alembic import op
import sqlalchemy as sa
revision = "8a6d2f4c9b10"
down_revision = "7f3c8a2d9e41"
branch_labels = None
depends_on = None
_PRODUCT_UPSERT_SQL = sa.text(
"""
INSERT INTO credit_products (
id,
product_code,
product_type,
name,
description,
features_json,
tier_code,
tier_rank,
billing_cycle,
monthly_grant_credits,
first_purchase_price,
regular_price,
activity_price,
activity_start_at,
activity_end_at,
grant_credits,
validity_months,
price,
credit_level,
currency,
is_active,
sort_order,
created_at,
updated_at,
renewal_enabled
) VALUES (
:id,
:product_code,
:product_type,
:name,
:description,
CAST(:features_json AS JSON),
:tier_code,
:tier_rank,
:billing_cycle,
:monthly_grant_credits,
:first_purchase_price,
:regular_price,
:activity_price,
:activity_start_at,
:activity_end_at,
:grant_credits,
:validity_months,
:price,
:credit_level,
:currency,
:is_active,
:sort_order,
:created_at,
:updated_at,
:renewal_enabled
)
ON CONFLICT (product_code) DO UPDATE SET
product_type = EXCLUDED.product_type,
name = EXCLUDED.name,
description = EXCLUDED.description,
features_json = EXCLUDED.features_json,
tier_code = EXCLUDED.tier_code,
tier_rank = EXCLUDED.tier_rank,
billing_cycle = EXCLUDED.billing_cycle,
monthly_grant_credits = EXCLUDED.monthly_grant_credits,
first_purchase_price = EXCLUDED.first_purchase_price,
regular_price = EXCLUDED.regular_price,
activity_price = EXCLUDED.activity_price,
activity_start_at = EXCLUDED.activity_start_at,
activity_end_at = EXCLUDED.activity_end_at,
grant_credits = EXCLUDED.grant_credits,
validity_months = EXCLUDED.validity_months,
price = EXCLUDED.price,
credit_level = EXCLUDED.credit_level,
currency = EXCLUDED.currency,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at,
renewal_enabled = EXCLUDED.renewal_enabled
"""
)
_POLICY_UPSERT_SQL = sa.text(
"""
INSERT INTO llm_billing_policies (
id,
scene_code,
scene_name,
pre_deduct_credits,
is_active,
version,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:id,
:scene_code,
:scene_name,
:pre_deduct_credits,
:is_active,
:version,
:created_by,
:updated_by,
:created_at,
:updated_at
)
ON CONFLICT (scene_code) DO UPDATE SET
scene_name = EXCLUDED.scene_name,
pre_deduct_credits = EXCLUDED.pre_deduct_credits,
is_active = EXCLUDED.is_active,
version = EXCLUDED.version,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at
"""
)
def _dt(value: str) -> datetime:
return datetime.fromisoformat(value)
def _seed_credit_products() -> None:
rows = [
{
"id": "0019fd653e4680767ab",
"product_code": "1",
"product_type": "subscription",
"name": "入门",
"description": None,
"features_json": "[]",
"tier_code": "入门",
"tier_rank": 1,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("1000.00"),
"first_purchase_price": Decimal("79.00"),
"regular_price": Decimal("122.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("122.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T09:07:30.020043+08:00"),
"updated_at": _dt("2026-08-06T09:52:15.579827+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd67e8a786dda0e",
"product_code": "2",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("2000.00"),
"first_purchase_price": Decimal("154.00"),
"regular_price": Decimal("239.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("239.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"updated_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd67f4c8bf75dd1",
"product_code": "3",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("3000.00"),
"first_purchase_price": Decimal("221.00"),
"regular_price": Decimal("353.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("353.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T09:54:54.768868+08:00"),
"updated_at": _dt("2026-08-06T09:55:10.408471+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd680784c16dd15",
"product_code": "4",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("4000.00"),
"first_purchase_price": Decimal("280.00"),
"regular_price": Decimal("476.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("476.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"updated_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68270abc3dcbd",
"product_code": "5",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("5000.00"),
"first_purchase_price": Decimal("1029.00"),
"regular_price": Decimal("1499.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1499.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 5,
"created_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"updated_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68373d24dbab0",
"product_code": "6",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("6000.00"),
"first_purchase_price": Decimal("1197.00"),
"regular_price": Decimal("1791.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1791.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 6,
"created_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"updated_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68469706aa522",
"product_code": "7",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("7000.00"),
"first_purchase_price": Decimal("1367.00"),
"regular_price": Decimal("2079.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("2079.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 7,
"created_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"updated_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd6904f72f59505",
"product_code": "8",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("10000.00"),
"first_purchase_price": Decimal("7308.00"),
"regular_price": Decimal("8399.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("8399.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 8,
"created_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"updated_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd692bc5e42b8be",
"product_code": "9",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("15000.00"),
"first_purchase_price": Decimal("10710.00"),
"regular_price": Decimal("12586.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("12586.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 9,
"created_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"updated_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69417fabda8c6",
"product_code": "10",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("20000.00"),
"first_purchase_price": Decimal("13440.00"),
"regular_price": Decimal("16685.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("16685.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 10,
"created_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"updated_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69a07e363c7b5",
"product_code": "ZLB-A",
"product_type": "credit_addon",
"name": "增量包A",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("500.00"),
"validity_months": 1,
"price": Decimal("0.01"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"updated_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69a640d7ccc92",
"product_code": "ZLB-B",
"product_type": "credit_addon",
"name": "增量包B",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1000.00"),
"validity_months": 1,
"price": Decimal("0.02"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T10:24:30.256294+08:00"),
"updated_at": _dt("2026-08-06T10:24:35.878389+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69ae039d3ba69",
"product_code": "ZLB-C",
"product_type": "credit_addon",
"name": "增量包C",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1500.00"),
"validity_months": 1,
"price": Decimal("0.03"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"updated_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69b3166a6a71c",
"product_code": "ZLB-D",
"product_type": "credit_addon",
"name": "增量包D",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("2000.00"),
"validity_months": 1,
"price": Decimal("0.04"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T10:25:22.811498+08:00"),
"updated_at": _dt("2026-08-06T10:25:29.647103+08:00"),
"renewal_enabled": False,
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_PRODUCT_UPSERT_SQL, row)
def _seed_llm_billing_policies() -> None:
admin_user_id = "0019e0a44895b6d837d"
rows = [
{
"id": "0019fd6aa5084a85a96",
"scene_code": "generation_record_text_prompt_optimize",
"scene_name": "AI创作-提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:41:53.809940+08:00"),
"updated_at": _dt("2026-08-06T10:41:53.809940+08:00"),
},
{
"id": "0019fd6aa834169f61d",
"scene_code": "hot_opening_image_prompt_optimize",
"scene_name": "爆款开头复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:06.818840+08:00"),
"updated_at": _dt("2026-08-06T10:42:06.818840+08:00"),
},
{
"id": "0019fd6aac862f821ca",
"scene_code": "shot_image_prompt_optimize",
"scene_name": "拆镜复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:24.496938+08:00"),
"updated_at": _dt("2026-08-06T10:42:24.496938+08:00"),
},
{
"id": "0019fd6aaf2fcae0765",
"scene_code": "shot_video_prompt_optimize",
"scene_name": "拆镜复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:35.404414+08:00"),
"updated_at": _dt("2026-08-06T10:42:35.404414+08:00"),
},
{
"id": "0019fd6aaaf31d7c7c7",
"scene_code": "hot_opening_video_prompt_optimize",
"scene_name": "爆款开头复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:18.003357+08:00"),
"updated_at": _dt("2026-08-06T10:42:39.440194+08:00"),
},
{
"id": "0019fd6abba149ef7e9",
"scene_code": "shot_original_video_analysis",
"scene_name": "拆镜复刻-原视频AI分析",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:26.390337+08:00"),
"updated_at": _dt("2026-08-06T10:43:26.390337+08:00"),
},
{
"id": "0019fd6abdbc4182c17",
"scene_code": "shot_segment_video_analysis",
"scene_name": "拆镜复刻-片段视频AI分析",
"pre_deduct_credits": Decimal("10.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:35.017213+08:00"),
"updated_at": _dt("2026-08-06T10:43:35.017213+08:00"),
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_POLICY_UPSERT_SQL, row)
def upgrade() -> None:
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
# 积分增值包不参与订阅续费,统一标记为关闭;已有订阅套餐默认保持开启。
op.execute(
"UPDATE credit_products "
"SET renewal_enabled = false "
"WHERE product_type = 'credit_addon'"
)
_seed_credit_products()
_seed_llm_billing_policies()
def downgrade() -> None:
# 产品和计费策略属于运营配置,降级仅撤销本次新增字段,不主动删除业务配置数据。
op.drop_column("credit_products", "renewal_enabled")
@@ -0,0 +1,194 @@
"""add api v3 tables (api_keys, api_generation_tasks, api_usage_logs, api_key_upscale_configs, api_upscale_links)
Revision ID: a1b2c3d4e5f6
Revises: 20da1d353914
Create Date: 2026-07-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6g'
down_revision: Union[str, None] = '20da1d353914'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# === 1. api_keys ===
op.create_table(
'api_keys',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('company_name', sa.String(128), nullable=False),
sa.Column('api_key_hash', sa.String(64), nullable=False, unique=True),
sa.Column('api_key_prefix', sa.String(16), nullable=False),
sa.Column('description', sa.Text, nullable=True),
sa.Column('callable_models', sa.Text, nullable=False, server_default='[]'),
sa.Column('quota_limit', sa.Float, nullable=True),
sa.Column('quota_cycle', sa.String(16), nullable=True),
sa.Column('quota_used', sa.Float, nullable=False, server_default='0.0'),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('max_concurrent_video_tasks', sa.Integer, nullable=True),
sa.Column('is_active', sa.Boolean, nullable=False, server_default='true'),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('idx_api_key_hash', 'api_keys', ['api_key_hash'], unique=True)
op.create_index('idx_api_keys_active', 'api_keys', ['is_active'])
op.create_index('idx_api_keys_company', 'api_keys', ['company_name'])
# === 2. api_generation_tasks ===
op.create_table(
'api_generation_tasks',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
sa.Column('external_idempotency_key', sa.String(64), nullable=True),
sa.Column('original_prompt', sa.Text, nullable=False),
sa.Column('optimized_prompt', sa.Text, nullable=True),
sa.Column('gen_type', sa.String(16), nullable=False, default='video'),
sa.Column('duration', sa.Integer, nullable=True),
sa.Column('aspect_ratio', sa.String(8), nullable=True),
sa.Column('resolution', sa.String(8), nullable=True),
sa.Column('provider_generation_resolution', sa.String(16), nullable=True),
sa.Column('image_size', sa.String(16), nullable=True),
sa.Column('image_proportion', sa.String(8), nullable=True),
sa.Column('image_px', sa.String(16), nullable=True),
sa.Column('generation_count', sa.Integer, nullable=False, default=1, server_default='1'),
sa.Column('engine_id', sa.String(32), nullable=True),
sa.Column('media_references', sa.Text, nullable=True),
sa.Column('engine_snapshot_json', sa.Text, nullable=True),
sa.Column('request_params_json', sa.Text, nullable=True),
sa.Column('status', sa.String(32), nullable=False, default='pending'),
sa.Column('pipeline_stage', sa.String(32), nullable=True),
sa.Column('generation_attempt_no', sa.Integer, nullable=False, default=1, server_default='1'),
sa.Column('resource_generation_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('deadline_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('provider_task_id', sa.String(128), nullable=True),
sa.Column('remote_result_url', sa.Text, nullable=True),
sa.Column('provider_response_json', sa.Text, nullable=True),
sa.Column('image_url', sa.String(512), nullable=True),
sa.Column('video_url', sa.String(512), nullable=True),
sa.Column('video_cover_url', sa.String(512), nullable=True),
sa.Column('video_upscale_enabled_snapshot', sa.Boolean, nullable=False, default=False, server_default='false'),
sa.Column('video_upscale_snapshot_json', sa.Text, nullable=True),
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
sa.Column('video_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('image_tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('error_message', sa.Text, nullable=True),
sa.Column('generated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_interval_seconds', sa.Integer, nullable=False, default=30, server_default='30'),
sa.Column('poll_count', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('provider_create_claim_token', sa.String(64), nullable=True),
sa.Column('provider_create_lease_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('provider_create_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_claim_token', sa.String(64), nullable=True),
sa.Column('poll_lease_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_error_count', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('download_celery_task_id', sa.String(160), nullable=True),
sa.Column('download_enqueued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('download_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('download_claim_token', sa.String(64), nullable=True),
sa.Column('download_lease_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('download_next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('download_attempt_count', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('download_last_error', sa.Text, nullable=True),
sa.Column('download_storage_date_dir', sa.String(16), nullable=True),
sa.Column('local_path', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('idx_api_generation_tasks_api_key', 'api_generation_tasks', ['api_key_id'])
op.create_index('idx_api_generation_tasks_status', 'api_generation_tasks', ['status'])
op.create_index('idx_api_generation_tasks_provider_task_id', 'api_generation_tasks', ['provider_task_id'])
op.create_index('idx_api_generation_tasks_next_poll_at', 'api_generation_tasks', ['next_poll_at'])
op.create_index('idx_api_generation_tasks_api_key_created', 'api_generation_tasks', ['api_key_id', 'created_at'])
op.create_index(
'uq_api_generation_tasks_key_idempotency',
'api_generation_tasks',
['api_key_id', 'external_idempotency_key'],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL AND external_idempotency_key IS NOT NULL"),
)
# === 3. api_usage_logs ===
op.create_table(
'api_usage_logs',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False),
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='SET NULL'), nullable=True),
sa.Column('request_type', sa.String(32), nullable=False),
sa.Column('model_name', sa.String(128), nullable=False),
sa.Column('gen_type', sa.String(16), nullable=False),
sa.Column('credits_cost', sa.Float, nullable=False, default=0.0, server_default='0.0'),
sa.Column('tokens_used', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('request_duration_ms', sa.Integer, nullable=False, default=0, server_default='0'),
sa.Column('status', sa.String(32), nullable=False),
sa.Column('error_message', sa.Text, nullable=True),
sa.Column('error_code', sa.String(64), nullable=True),
sa.Column('request_payload_json', sa.Text, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_api_usage_logs_api_key', 'api_usage_logs', ['api_key_id'])
op.create_index('idx_api_usage_logs_task_id', 'api_usage_logs', ['api_generation_task_id'])
op.create_index('idx_api_usage_logs_api_key_created', 'api_usage_logs', ['api_key_id', 'created_at'])
# === 4. api_key_upscale_configs ===
op.create_table(
'api_key_upscale_configs',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('api_key_id', sa.String(32), sa.ForeignKey('api_keys.id', ondelete='CASCADE'), nullable=False, unique=True),
sa.Column('enabled', sa.Boolean, nullable=False, default=False, server_default='false'),
sa.Column('delete_source_after_success', sa.Boolean, nullable=False, default=True, server_default='true'),
sa.Column('rules_json', sa.Text, nullable=False, server_default='[]'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# === 5. api_upscale_links ===
op.create_table(
'api_upscale_links',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('api_generation_task_id', sa.String(32), sa.ForeignKey('api_generation_tasks.id', ondelete='CASCADE'), nullable=False),
sa.Column('video_upscale_task_id', sa.String(32), sa.ForeignKey('video_upscale_tasks.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_api_upscale_links_api_task', 'api_upscale_links', ['api_generation_task_id'])
op.create_index('idx_api_upscale_links_video_task', 'api_upscale_links', ['video_upscale_task_id'])
def downgrade() -> None:
op.drop_index('idx_api_upscale_links_video_task', table_name='api_upscale_links')
op.drop_index('idx_api_upscale_links_api_task', table_name='api_upscale_links')
op.drop_table('api_upscale_links')
op.drop_table('api_key_upscale_configs')
op.drop_index('idx_api_usage_logs_api_key_created', table_name='api_usage_logs')
op.drop_index('idx_api_usage_logs_task_id', table_name='api_usage_logs')
op.drop_index('idx_api_usage_logs_api_key', table_name='api_usage_logs')
op.drop_table('api_usage_logs')
op.drop_index('uq_api_generation_tasks_key_idempotency', table_name='api_generation_tasks')
op.drop_index('idx_api_generation_tasks_api_key_created', table_name='api_generation_tasks')
op.drop_index('idx_api_generation_tasks_next_poll_at', table_name='api_generation_tasks')
op.drop_index('idx_api_generation_tasks_provider_task_id', table_name='api_generation_tasks')
op.drop_index('idx_api_generation_tasks_status', table_name='api_generation_tasks')
op.drop_index('idx_api_generation_tasks_api_key', table_name='api_generation_tasks')
op.drop_table('api_generation_tasks')
op.drop_index('idx_api_keys_company', table_name='api_keys')
op.drop_index('idx_api_keys_active', table_name='api_keys')
op.drop_index('idx_api_key_hash', table_name='api_keys')
op.drop_table('api_keys')
@@ -0,0 +1,230 @@
"""add client-selectable multi generation and image batch claim
Revision ID: abae3e1c70f7
Revises: 2026070902
Create Date: 2026-07-15 10:49:31.803342
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "abae3e1c70f7"
down_revision: Union[str, None] = "2026070902"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_CHAT_TASK_PARENT = "fk_chat_generation_tasks_parent_task_id"
CK_CHAT_TASK_GENERATION_COUNT = "ck_chat_generation_tasks_generation_count"
CK_CHAT_TASK_GENERATION_INDEX = "ck_chat_generation_tasks_generation_index"
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT = "ck_image_engines_max_generation_count"
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX = "ck_image_engines_multi_image_max_images"
CK_IMAGE_ENGINE_MAX_REFERENCE = "ck_image_engines_max_reference_image_count"
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT = "ck_video_engines_max_generation_count"
def upgrade() -> None:
# ChatGenerationTask:任务级实际生成数量、主子关联和图片批次执行租约。
op.add_column(
"chat_generation_tasks",
sa.Column("parent_task_id", sa.String(length=32), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_index", sa.Integer(), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_claim_token", sa.String(length=64), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_lease_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_started_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_COUNT,
"chat_generation_tasks",
"generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_INDEX,
"chat_generation_tasks",
"generation_index IS NULL OR generation_index > 0",
)
op.create_foreign_key(
FK_CHAT_TASK_PARENT,
"chat_generation_tasks",
"chat_generation_tasks",
["parent_task_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_chat_generation_tasks_parent",
"chat_generation_tasks",
["parent_task_id"],
unique=False,
)
op.create_index(
"idx_chat_generation_tasks_user_mode_created",
"chat_generation_tasks",
["user_id", "generation_mode", "created_at"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_claim_token",
"chat_generation_tasks",
["provider_create_claim_token"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_lease_until",
"chat_generation_tasks",
["provider_create_lease_until"],
unique=False,
)
op.create_index(
"uq_chat_generation_tasks_parent_index",
"chat_generation_tasks",
["parent_task_id", "generation_index"],
unique=True,
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.create_index(
"uq_chat_generation_tasks_user_chat_idempotency",
"chat_generation_tasks",
["user_id", "idempotency_key"],
unique=True,
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
# ImageEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"image_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"image_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("multi_image_max_images", sa.Integer(), server_default=sa.text("15"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("max_reference_image_count", sa.Integer(), server_default=sa.text("14"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("output_format", sa.String(length=16), server_default=sa.text("''"), nullable=False),
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT,
"image_engines",
"max_generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX,
"image_engines",
"multi_image_max_images BETWEEN 1 AND 15",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_REFERENCE,
"image_engines",
"max_reference_image_count BETWEEN 0 AND 14",
)
# VideoEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"video_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"video_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.create_check_constraint(
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT,
"video_engines",
"max_generation_count BETWEEN 1 AND 5",
)
def downgrade() -> None:
op.drop_constraint(CK_VIDEO_ENGINE_MAX_GENERATION_COUNT, "video_engines", type_="check")
op.drop_column("video_engines", "max_generation_count")
op.drop_column("video_engines", "multi_generation_enabled")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_REFERENCE, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MULTI_IMAGE_MAX, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_GENERATION_COUNT, "image_engines", type_="check")
op.drop_column("image_engines", "output_format")
op.drop_column("image_engines", "max_reference_image_count")
op.drop_column("image_engines", "multi_image_max_images")
op.drop_column("image_engines", "max_generation_count")
op.drop_column("image_engines", "multi_generation_enabled")
op.drop_index(
"uq_chat_generation_tasks_user_chat_idempotency",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
op.drop_index(
"uq_chat_generation_tasks_parent_index",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_lease_until",
table_name="chat_generation_tasks",
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_claim_token",
table_name="chat_generation_tasks",
)
op.drop_index("idx_chat_generation_tasks_user_mode_created", table_name="chat_generation_tasks")
op.drop_index("idx_chat_generation_tasks_parent", table_name="chat_generation_tasks")
op.drop_constraint(FK_CHAT_TASK_PARENT, "chat_generation_tasks", type_="foreignkey")
op.drop_constraint(CK_CHAT_TASK_GENERATION_INDEX, "chat_generation_tasks", type_="check")
op.drop_constraint(CK_CHAT_TASK_GENERATION_COUNT, "chat_generation_tasks", type_="check")
op.drop_column("chat_generation_tasks", "provider_create_started_at")
op.drop_column("chat_generation_tasks", "provider_create_lease_until")
op.drop_column("chat_generation_tasks", "provider_create_claim_token")
op.drop_column("chat_generation_tasks", "generation_index")
op.drop_column("chat_generation_tasks", "generation_count")
op.drop_column("chat_generation_tasks", "parent_task_id")
@@ -0,0 +1,56 @@
"""add api_model_pricings table
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2026-07-28 14:00:00.000000
API 模型价格表 - 使用直接金额计费镜像 credit_ratios 结构
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6g7h'
down_revision: Union[str, None] = 'a1b2c3d4e5f6g'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_model_pricings',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('model_config_id', sa.String(32), nullable=False, index=True),
sa.Column('gen_type', sa.String(16), nullable=False, default='video', index=True),
sa.Column('resolution', sa.String(16), nullable=False, index=True),
sa.Column('price_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('base_price', sa.Float, nullable=False, default=0.0),
sa.Column('per_second_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_video_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('input_video_base_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_video_per_second_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_image_ratio', sa.Float, nullable=False, default=1.0),
sa.Column('input_image_base_price', sa.Float, nullable=False, default=0.0),
sa.Column('input_image_per_image_price', sa.Float, nullable=False, default=0.0),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
)
op.create_index(
'ix_api_model_pricings_gen_type_engine_resolution',
'api_model_pricings',
['gen_type', 'model_config_id', 'resolution'],
)
op.create_index(
'ix_api_model_pricings_gen_type_resolution',
'api_model_pricings',
['gen_type', 'resolution'],
)
def downgrade() -> None:
op.drop_index('ix_api_model_pricings_gen_type_resolution', table_name='api_model_pricings')
op.drop_index('ix_api_model_pricings_gen_type_engine_resolution', table_name='api_model_pricings')
op.drop_table('api_model_pricings')
@@ -0,0 +1,75 @@
"""expand credit addon validity months to 1-36
Revision ID: b7e2c4d91a63
Revises: 1a4d1f095fa1
Create Date: 2026-08-11 13:15:00
"""
from alembic import op
revision = "b7e2c4d91a63"
down_revision = "1a4d1f095fa1"
branch_labels = None
depends_on = None
CONSTRAINT_NAME = "ck_credit_products_type_required_fields"
def _drop_type_constraint_if_exists() -> None:
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'credit_products'
AND c.conname = '{CONSTRAINT_NAME}'
) THEN
ALTER TABLE credit_products DROP CONSTRAINT {CONSTRAINT_NAME};
END IF;
END
$$;
"""
)
def _create_type_constraint(*, addon_condition: str) -> None:
op.create_check_constraint(
CONSTRAINT_NAME,
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
f"AND {addon_condition} AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
def upgrade() -> None:
# 当前版本中增值包被旧约束固定为 validity_months = 1。
# 先移除旧约束,再开放为1-36个自然月;不修改任何现有商品数据。
_drop_type_constraint_if_exists()
_create_type_constraint(addon_condition="validity_months BETWEEN 1 AND 36")
def downgrade() -> None:
# 回退到旧版本时,旧约束只允许1个月。为保证 downgrade 可执行,
# 将现有增值包有效期恢复为旧版本唯一合法值1个月。
_drop_type_constraint_if_exists()
op.execute(
"UPDATE credit_products "
"SET validity_months = 1 "
"WHERE product_type = 'credit_addon' "
"AND validity_months IS DISTINCT FROM 1"
)
_create_type_constraint(addon_condition="validity_months = 1")
@@ -0,0 +1,33 @@
"""add api_key_encrypted column to api_keys
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7h
Create Date: 2026-07-28 16:00:00.000000
添加 api_key_encrypted 字段用于存储加密的完整 API Key支持随时揭秘复制
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6g7h8'
down_revision: Union[str, None] = 'b2c3d4e5f6g7h'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_keys',
sa.Column('api_key_encrypted', sa.Text, nullable=True, comment='AES-256-GCM 加密的完整 API Key'),
)
# 为现有记录设置空值(新创建的 Key 会自动加密)
op.execute("UPDATE api_keys SET api_key_encrypted = '' WHERE api_key_encrypted IS NULL")
op.alter_column('api_keys', 'api_key_encrypted', nullable=False)
def downgrade() -> None:
op.drop_column('api_keys', 'api_key_encrypted')
@@ -0,0 +1,29 @@
"""add model_name to api_generation_tasks
Revision ID: c4d5e6f7g8h9
Revises: c3d4e5f6g7h8
Create Date: 2026-07-29 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c4d5e6f7g8h9'
down_revision: Union[str, None] = 'c3d4e5f6g7h8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_generation_tasks',
sa.Column('model_name', sa.String(128), nullable=False, server_default='', comment="模型名称,如 doubao-seedance-2-0-260128"),
)
def downgrade() -> None:
op.drop_column('api_generation_tasks', 'model_name')
@@ -0,0 +1,68 @@
"""add api_generation_task_id to video_upscale_tasks
Revision ID: d5e6f7g8h9i0
Revises: c4d5e6f7g8h9
Create Date: 2026-07-29 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e6f7g8h9i0'
down_revision: Union[str, None] = 'c4d5e6f7g8h9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加 api_generation_task_id 字段
op.add_column(
'video_upscale_tasks',
sa.Column('api_generation_task_id', sa.String(32), nullable=True, comment="API v3 任务ID,关联 api_generation_tasks.id"),
)
op.create_index(
'idx_video_upscale_tasks_api_generation_task_id',
'video_upscale_tasks',
['api_generation_task_id'],
)
op.create_foreign_key(
'fk_video_upscale_tasks_api_generation_task_id',
'video_upscale_tasks',
'api_generation_tasks',
['api_generation_task_id'],
['id'],
ondelete='CASCADE',
)
# 删除旧的检查约束,创建新的(允许 api_generation_task_id
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
op.execute("""
ALTER TABLE video_upscale_tasks
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
CHECK (
(chat_generation_task_id IS NOT NULL)::int +
(generation_record_id IS NOT NULL)::int +
(api_generation_task_id IS NOT NULL)::int = 1
)
""")
def downgrade() -> None:
# 恢复旧约束
op.execute("ALTER TABLE video_upscale_tasks DROP CONSTRAINT IF EXISTS ck_video_upscale_tasks_exactly_one_owner")
op.execute("""
ALTER TABLE video_upscale_tasks
ADD CONSTRAINT ck_video_upscale_tasks_exactly_one_owner
CHECK (
(chat_generation_task_id IS NOT NULL)::int +
(generation_record_id IS NOT NULL)::int = 1
)
""")
op.drop_constraint('fk_video_upscale_tasks_api_generation_task_id', 'video_upscale_tasks', type_='foreignkey')
op.drop_index('idx_video_upscale_tasks_api_generation_task_id', table_name='video_upscale_tasks')
op.drop_column('video_upscale_tasks', 'api_generation_task_id')
@@ -0,0 +1,87 @@
"""add module flow version and generation reference option
Revision ID: d8ebe79ab575
Revises: e6eac828ff61
Create Date: 2026-07-21 09:28:04.318458
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "d8ebe79ab575"
down_revision: Union[str, None] = "e6eac828ff61"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _column_names(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
return {str(column["name"]) for column in inspector.get_columns(table_name)}
def upgrade() -> None:
generation_record_columns = _column_names("generation_records")
if "include_media_references" not in generation_record_columns:
op.add_column(
"generation_records",
sa.Column(
"include_media_references",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
else:
op.execute(
sa.text(
"UPDATE generation_records "
"SET include_media_references = false "
"WHERE include_media_references IS NULL"
)
)
op.alter_column(
"generation_records",
"include_media_references",
existing_type=sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
)
project_columns = _column_names("module_generation_projects")
if "flow_version" not in project_columns:
op.add_column(
"module_generation_projects",
sa.Column(
"flow_version",
sa.String(length=16),
server_default=sa.text("'v1'"),
nullable=False,
),
)
else:
op.execute(
sa.text(
"UPDATE module_generation_projects "
"SET flow_version = 'v1' "
"WHERE flow_version IS NULL OR btrim(flow_version) = ''"
)
)
op.alter_column(
"module_generation_projects",
"flow_version",
existing_type=sa.String(length=16),
nullable=False,
server_default=sa.text("'v1'"),
)
def downgrade() -> None:
project_columns = _column_names("module_generation_projects")
if "flow_version" in project_columns:
op.drop_column("module_generation_projects", "flow_version")
generation_record_columns = _column_names("generation_records")
if "include_media_references" in generation_record_columns:
op.drop_column("generation_records", "include_media_references")
@@ -0,0 +1,43 @@
"""enhance api_usage_logs with detailed consumption fields
Revision ID: e6f7g8h9i0j1
Revises: d5e6f7g8h9i0
Create Date: 2026-07-29 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e6f7g8h9i0j1'
down_revision: Union[str, None] = 'd5e6f7g8h9i0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 添加新字段
op.add_column('api_usage_logs', sa.Column('price_action', sa.String(16), nullable=False, server_default='deduct', comment='deduct=扣除, refund=退回'))
op.add_column('api_usage_logs', sa.Column('resolution', sa.String(16), nullable=True, comment="分辨率: 480p/720p/1080p/2K/4K"))
op.add_column('api_usage_logs', sa.Column('duration', sa.Integer(), nullable=True, comment="视频时长(秒)"))
op.add_column('api_usage_logs', sa.Column('refund_amount', sa.Float(), nullable=False, server_default='0.0', comment='退回金额'))
op.add_column('api_usage_logs', sa.Column('quota_before', sa.Float(), nullable=True, comment='操作前配额余额'))
op.add_column('api_usage_logs', sa.Column('quota_after', sa.Float(), nullable=True, comment='操作后配额余额'))
op.add_column('api_usage_logs', sa.Column('price_detail_json', sa.Text(), nullable=True, comment='价格计算明细JSON'))
# 添加索引
op.create_index('idx_api_usage_logs_action', 'api_usage_logs', ['price_action'])
def downgrade() -> None:
op.drop_index('idx_api_usage_logs_action', table_name='api_usage_logs')
op.drop_column('api_usage_logs', 'price_detail_json')
op.drop_column('api_usage_logs', 'quota_after')
op.drop_column('api_usage_logs', 'quota_before')
op.drop_column('api_usage_logs', 'refund_amount')
op.drop_column('api_usage_logs', 'duration')
op.drop_column('api_usage_logs', 'resolution')
op.drop_column('api_usage_logs', 'price_action')
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: e7f2527691bb
Revises: 20260814_bank_tx, 20260814_subscription_no
Create Date: 2026-08-14 15:32:14.351079
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f2527691bb'
down_revision: Union[str, None] = ('20260814_bank_tx', '20260814_subscription_no')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,29 @@
"""add local_media_json to api_generation_tasks
Revision ID: f7g8h9i0j1k2
Revises: e6f7g8h9i0j1
Create Date: 2026-07-29 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f7g8h9i0j1k2'
down_revision: Union[str, None] = 'e6f7g8h9i0j1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'api_generation_tasks',
sa.Column('local_media_json', sa.Text, nullable=True, comment='下载到本地的媒体文件路径JSON'),
)
def downgrade() -> None:
op.drop_column('api_generation_tasks', 'local_media_json')
@@ -0,0 +1,3 @@
from app.admin_api.api_keys.routes import router
__all__ = ["router"]
@@ -0,0 +1,435 @@
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_key import ApiKey
from app.models.api.api_key_upscale_config import ApiKeyUpscaleConfig
from app.models.api.api_usage_log import ApiUsageLog
from app.models.user import User
from app.schemas.admin_api.api_key import (
ApiKeyCallableModel,
ApiKeyCreateRequest,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListOut,
ApiKeyQuotaAdjustRequest,
ApiKeyRevealResponse,
ApiKeyResponse,
ApiKeyUpdateRequest,
)
from app.schemas.admin_api.api_upscale import (
ApiUpscaleConfigData,
ApiUpscaleConfigResponse,
ApiUpscaleConfigSaveRequest,
)
from app.schemas.admin_api.api_usage import ApiUsageLogResponse, ApiUsageSummaryResponse
from app.services.api_v3 import (
key_service,
upscale_service,
usage_log_service,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-keys", tags=["admin-api-keys"])
def _key_to_list_item(key: ApiKey) -> ApiKeyListItem:
"""将 ApiKey 模型转为列表项 Schema。"""
try:
callable_models = json.loads(key.callable_models) if key.callable_models else []
except (json.JSONDecodeError, TypeError):
callable_models = []
return ApiKeyListItem(
id=key.id,
company_name=key.company_name,
api_key_prefix=f"{key.api_key_prefix}****",
description=key.description,
callable_models=[ApiKeyCallableModel(**m) for m in callable_models],
quota_limit=key.quota_limit,
quota_cycle=key.quota_cycle,
quota_used=key.quota_used,
is_active=key.is_active,
valid_from=key.valid_from,
valid_until=key.valid_until,
max_concurrent_video_tasks=key.max_concurrent_video_tasks,
last_used_at=key.last_used_at,
created_at=key.created_at,
)
# === API Key CRUD ===
@router.get("", response_model=ApiKeyListOut, summary="列出 API Key")
async def list_keys(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
company_name: str | None = None,
is_active: bool | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListOut:
"""列出所有 API Key(分页+筛选)。"""
total, keys = await key_service.list_api_keys(
db, skip=skip, limit=limit,
company_name=company_name, is_active=is_active,
)
return ApiKeyListOut(
total=total,
items=[_key_to_list_item(k) for k in keys],
)
@router.post(
"",
response_model=ApiKeyCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="创建 API Key",
)
async def create_key(
req: ApiKeyCreateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyCreateResponse:
"""创建新的 API Key。
返回包含完整明文 api_key仅此一次
"""
callable_models = [m.model_dump() for m in req.callable_models] if req.callable_models else []
key, raw_key = await key_service.create_api_key(
db=db,
company_name=req.company_name,
callable_models=callable_models,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
valid_from=req.valid_from,
valid_until=req.valid_until,
max_concurrent_video_tasks=req.max_concurrent_video_tasks,
description=req.description,
)
await db.commit()
return ApiKeyCreateResponse(
id=key.id,
company_name=key.company_name,
api_key=raw_key,
api_key_prefix=key.api_key_prefix,
valid_until=key.valid_until,
created_at=key.created_at,
)
@router.get("/{key_id}/reveal", response_model=ApiKeyRevealResponse, summary="揭秘 API Key")
async def reveal_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyRevealResponse:
"""揭秘 API Key(随时可获取完整明文 Key)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
plaintext = key.decrypt_api_key()
if not plaintext:
raise HTTPException(
status_code=400,
detail="该 API Key 创建时未启用加密存储,无法揭秘。请重新创建 Key。",
)
return ApiKeyRevealResponse(
id=key.id,
company_name=key.company_name,
api_key=plaintext,
api_key_prefix=key.api_key_prefix,
)
@router.get("/{key_id}", response_model=ApiKeyListItem, summary="获取 API Key 详情")
async def get_key(
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""获取单个 API Key 详情。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
return _key_to_list_item(key)
@router.put("/{key_id}", response_model=ApiKeyListItem, summary="更新 API Key")
async def update_key(
req: ApiKeyUpdateRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyResponse:
"""更新 API Key 配置。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
# model_dump 默认输出 snake_case 字段名,直接传给 service
update_data = req.model_dump(exclude_none=True)
if "callable_models" in update_data and update_data["callable_models"] is not None:
update_data["callable_models"] = [
m.model_dump() if hasattr(m, "model_dump") else m
for m in update_data["callable_models"]
]
key = await key_service.update_api_key(db, key, **update_data)
await db.commit()
return _key_to_list_item(key)
@router.delete("/{key_id}", summary="删除 API Key")
async def delete_key(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""软删除 API Key。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
await key_service.delete_api_key(db, key)
await db.commit()
return {"status": "deleted", "id": key_id}
# === 超分配置 ===
@router.get("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="获取 API Key 超分配置")
async def get_upscale_config(
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""获取 API Key 的超分配置。"""
config = await upscale_service.get_or_create_upscale_config(db, key_id)
try:
rules = json.loads(config.rules_json) if config.rules_json else []
except (json.JSONDecodeError, TypeError):
rules = []
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=rules,
),
)
@router.put("/{key_id}/upscale", response_model=ApiUpscaleConfigResponse, summary="保存 API Key 超分配置")
async def save_upscale_config(
req: ApiUpscaleConfigSaveRequest,
key_id: str = Path(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUpscaleConfigResponse:
"""保存 API Key 的超分配置。"""
config = await upscale_service.save_upscale_config(
db=db,
api_key_id=key_id,
enabled=req.data.enabled,
delete_source_after_success=req.data.delete_source_after_success,
rules=[r.model_dump() for r in req.data.rules],
)
await db.commit()
return ApiUpscaleConfigResponse(
data=ApiUpscaleConfigData(
enabled=config.enabled,
delete_source_after_success=config.delete_source_after_success,
rules=json.loads(config.rules_json) if config.rules_json else [],
),
)
# === 使用日志 ===
@router.get("/{key_id}/usage", response_model=ApiUsageSummaryResponse, summary="获取 API Key 使用统计")
async def get_usage(
key_id: str = Path(...),
days: int = Query(30, ge=1, le=365),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiUsageSummaryResponse:
"""获取 API Key 的使用统计和明细。"""
# 验证 key 存在
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
summary = await usage_log_service.get_usage_summary(db, api_key_id=key_id, days=days)
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=page_size, skip=(page - 1) * page_size)
return ApiUsageSummaryResponse(
total_requests=summary["total_requests"],
total_credits_cost=summary["total_credits_cost"],
total_tokens_used=summary["total_tokens_used"],
success_count=summary["success_count"],
failed_count=summary["failed_count"],
avg_duration_ms=summary["avg_duration_ms"],
total=total,
page=page,
page_size=page_size,
items=[
ApiUsageLogResponse(
id=log.id,
api_key_id=log.api_key_id,
api_generation_task_id=log.api_generation_task_id,
request_type=log.request_type,
model_name=log.model_name,
gen_type=log.gen_type,
credits_cost=log.credits_cost,
tokens_used=log.tokens_used,
request_duration_ms=log.request_duration_ms,
status=log.status,
error_message=log.error_message,
error_code=log.error_code,
created_at=log.created_at,
)
for log in logs
],
)
# === 整体消耗列表 ===
@router.get("/usage/all", response_model=dict, summary="获取整体 API 消耗列表")
async def list_all_usage(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
api_key_id: str | None = None,
gen_type: str | None = None,
status_filter: str | None = Query(None, alias="status"),
start_date: datetime | None = None,
end_date: datetime | None = None,
search: str | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> dict:
"""获取整体 API 消耗列表(跨所有 Key,支持筛选和分页)。"""
# 构建查询
query = select(ApiUsageLog, ApiKey.company_name, ApiKey.api_key_prefix).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
count_query = select(func.count(ApiUsageLog.id)).join(
ApiKey, ApiUsageLog.api_key_id == ApiKey.id
)
# 筛选条件
filters = []
if api_key_id:
filters.append(ApiUsageLog.api_key_id == api_key_id)
if gen_type:
filters.append(ApiUsageLog.gen_type == gen_type)
if status_filter:
filters.append(ApiUsageLog.status == status_filter)
if start_date:
filters.append(ApiUsageLog.created_at >= start_date)
if end_date:
filters.append(ApiUsageLog.created_at <= end_date)
if search:
search_pattern = f"%{search}%"
filters.append(
(ApiKey.company_name.ilike(search_pattern))
| (ApiKey.api_key_prefix.ilike(search_pattern))
)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
# 总数
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# 分页查询
query = query.order_by(ApiUsageLog.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
rows = result.all()
items = []
for log, company_name, key_prefix in rows:
items.append({
"id": log.id,
"apiKeyId": log.api_key_id,
"companyName": company_name,
"apiKeyPrefix": f"{key_prefix}****" if key_prefix else None,
"taskId": log.api_generation_task_id,
"requestType": log.request_type,
"modelName": log.model_name,
"genType": log.gen_type,
"creditsCost": log.credits_cost,
"tokensUsed": log.tokens_used,
"requestDurationMs": log.request_duration_ms,
"duration": log.duration,
"resolution": log.resolution,
"status": log.status,
"errorMessage": log.error_message,
"errorCode": log.error_code,
"createdAt": log.created_at.isoformat() if log.created_at else None,
})
return {
"total": total,
"items": items,
}
@router.post("/{key_id}/quota-adjust", response_model=ApiKeyListItem, summary="调整 API Key 配额")
async def quota_adjust(
req: ApiKeyQuotaAdjustRequest,
key_id: str = Path(..., description="API Key ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
) -> ApiKeyListItem:
"""调整 API Key 配额(增加总额/重置已用/设置限额/修改周期)。"""
key = await key_service.get_api_key(db, key_id)
if not key:
raise HTTPException(status_code=404, detail="API Key 不存在")
key, changes = await key_service.adjust_quota(
db,
key,
action=req.action,
quota_limit_delta=req.quota_limit_delta,
quota_limit=req.quota_limit,
quota_cycle=req.quota_cycle,
)
# 审计日志
try:
from app.services.operation_log import log_operation
await log_operation(
db=db,
user_id=str(admin.id),
username=str(admin.username),
action=f"quota_adjust:{req.action}",
method="POST",
path=f"/admin/api-keys/{key_id}/quota-adjust",
detail=json.dumps(
{**changes, "reason": req.reason},
ensure_ascii=False,
default=str,
),
)
except Exception as log_exc:
logger.warning("配额调整审计日志记录失败: %s", log_exc)
await db.commit()
return _key_to_list_item(key)
@@ -0,0 +1,3 @@
from app.admin_api.api_model_pricings.routes import router
__all__ = ["router"]
@@ -0,0 +1,131 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.api.api_model_pricing import ApiModelPricing
from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.admin_api.api_model_pricing import ApiModelPricingCreate, ApiModelPricingOut
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/admin/api-model-pricings", tags=["admin-api-model-pricings"])
async def _validate_pricing_engine(db: AsyncSession, req: ApiModelPricingCreate) -> None:
"""校验定价规则绑定的引擎是否存在。"""
gen_type = (req.gen_type or "").lower().strip()
engine_id = (req.model_config_id or "").strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if not engine_id:
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(
select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
detail = "图片定价规则绑定的图片引擎不存在" if gen_type == "image" else "视频定价规则绑定的视频引擎不存在"
raise HTTPException(status_code=400, detail=detail)
@router.get("", response_model=list[ApiModelPricingOut])
async def list_pricings(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).order_by(
ApiModelPricing.gen_type.desc(),
ApiModelPricing.model_config_id.desc(),
)
)
return result.scalars().all()
@router.post("", response_model=ApiModelPricingOut)
async def create_pricing(
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建 API 模型价格。"""
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
pricing = ApiModelPricing(id=generate_id(), **data)
db.add(pricing)
await db.commit()
await db.refresh(pricing)
return pricing
@router.put("/{pricing_id}", response_model=ApiModelPricingOut)
async def update_pricing(
pricing_id: str,
req: ApiModelPricingCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await _validate_pricing_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
for k, v in data.items():
setattr(pricing, k, v)
await db.commit()
await db.refresh(pricing)
return pricing
@router.delete("/{pricing_id}")
async def delete_pricing(
pricing_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除 API 模型价格。"""
result = await db.execute(
select(ApiModelPricing).where(ApiModelPricing.id == pricing_id).limit(1)
)
pricing = result.scalar_one_or_none()
if not pricing:
raise HTTPException(status_code=404, detail="定价规则不存在")
await db.delete(pricing)
await db.commit()
return {"message": "ok"}
@router.get("/grouped", response_model=dict)
async def list_pricings_grouped(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""按 gen_type 分组列出价格。"""
result = await db.execute(select(ApiModelPricing))
pricings = result.scalars().all()
grouped = {}
for pricing in pricings:
if pricing.gen_type not in grouped:
grouped[pricing.gen_type] = []
grouped[pricing.gen_type].append(ApiModelPricingOut.model_validate(pricing))
return grouped
@@ -0,0 +1,3 @@
from app.admin_api.bank.routes import router
__all__ = ["router"]
+222
View File
@@ -0,0 +1,222 @@
"""银行账户管理与交易查询后台路由。"""
from datetime import datetime, time
from fastapi import APIRouter, Depends, HTTPException, Path, Query
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.bank_account import BankAccount
from app.models.bank_transaction import BankTransaction
from app.models.user import User
from app.services.bank.service import query_transactions_with_log
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/bank", tags=["admin-bank"])
# ============================================================
# 银行账户 CRUD
# ============================================================
@router.get("/accounts")
async def list_accounts(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有银行账户。"""
result = await db.execute(select(BankAccount).order_by(BankAccount.is_default.desc(), BankAccount.created_at.desc()))
accounts = result.scalars().all()
return {"items": [
{
"id": a.id,
"account_name": a.account_name,
"bank_name": a.bank_name,
"account_no": a.account_no,
"is_active": a.is_active,
"is_default": a.is_default,
"description": a.description,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in accounts
]}
@router.post("/accounts")
async def create_account(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""新增银行账户。"""
account_name = (body.get("account_name") or "").strip()
bank_name = (body.get("bank_name") or "").strip()
account_no = (body.get("account_no") or "").strip()
if not account_name or not bank_name or not account_no:
raise HTTPException(status_code=400, detail="账户名称、开户银行、银行账号不能为空")
# 检查账号唯一性
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == account_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
is_default = bool(body.get("is_default", False))
# 如果设为默认,取消其他默认
if is_default:
await db.execute(
BankAccount.__table__.update().where(BankAccount.is_default.is_(True)).values(is_default=False)
)
account = BankAccount(
id=generate_id(),
account_name=account_name,
bank_name=bank_name,
account_no=account_no,
is_active=bool(body.get("is_active", True)),
is_default=is_default,
description=body.get("description"),
)
db.add(account)
await db.commit()
return {"id": account.id, "message": "创建成功"}
@router.put("/accounts/{account_id}")
async def update_account(
account_id: str = Path(..., description="账户 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""编辑银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
if "account_name" in body:
account.account_name = str(body["account_name"]).strip()
if "bank_name" in body:
account.bank_name = str(body["bank_name"]).strip()
if "account_no" in body:
new_no = str(body["account_no"]).strip()
if new_no != account.account_no:
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == new_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
account.account_no = new_no
if "is_active" in body:
account.is_active = bool(body["is_active"])
if "description" in body:
account.description = body.get("description")
if body.get("is_default"):
await db.execute(
BankAccount.__table__.update()
.where(BankAccount.is_default.is_(True))
.where(BankAccount.id != account_id)
.values(is_default=False)
)
account.is_default = True
await db.commit()
return {"message": "更新成功"}
@router.delete("/accounts/{account_id}")
async def delete_account(
account_id: str = Path(..., description="账户 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
await db.delete(account)
await db.commit()
return {"message": "删除成功"}
# ============================================================
# 银行交易查询
# ============================================================
@router.get("/transactions")
async def list_transactions(
account_id: str = Query(..., description="银行账户 ID"),
start_date: str = Query(..., description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(..., description="结束日期 (YYYY-MM-DD)"),
dc_flag: int | None = Query(None, description="借贷方向: 0-借/出金, 1-贷/入金, 不传返回全部"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""查询本地已同步的银行交易流水。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="银行账户不存在")
# 构建查询条件(日期字符串转为 datetime,避免 PostgreSQL 类型错误)
conditions = [BankTransaction.account_id == account_id]
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
conditions.append(BankTransaction.transaction_time >= start_dt)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
conditions.append(BankTransaction.transaction_time <= end_dt)
if dc_flag is not None:
direction = "DR" if dc_flag == 0 else "CR"
conditions.append(BankTransaction.balance_direction == direction)
# 查总数
count_result = await db.execute(
select(sa.func.count()).where(*conditions)
)
total = count_result.scalar() or 0
# 查分页数据
offset = (page - 1) * page_size
data_result = await db.execute(
select(BankTransaction)
.where(*conditions)
.order_by(BankTransaction.transaction_time.desc())
.offset(offset)
.limit(page_size)
)
transactions = data_result.scalars().all()
return {
"items": [
{
"id": t.id,
"account_id": t.account_id,
"account_no": t.account_no,
"transaction_no": t.transaction_no,
"transaction_time": t.transaction_time.isoformat() if t.transaction_time else None,
"transaction_amount": t.transaction_amount,
"balance_direction": t.balance_direction,
"balance_after": t.balance_after,
"counterparty_name": t.counterparty_name,
"counterparty_account": t.counterparty_account,
"counterparty_bank": t.counterparty_bank,
"remark": t.remark,
"digest_code": t.digest_code,
"sync_batch": t.sync_batch,
"is_synced": t.is_synced,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
],
"total": total,
}
@@ -0,0 +1,3 @@
from app.admin_api.scheduled_tasks.routes import router
__all__ = ["router"]
@@ -0,0 +1,171 @@
"""定时任务管理后台路由。"""
import json
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.scheduled_task import ScheduledTask
from app.models.user import User
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/scheduled-tasks", tags=["admin-scheduled-tasks"])
def _task_to_dict(task: ScheduledTask) -> dict:
return {
"id": task.id,
"name": task.name,
"task_type": task.task_type,
"schedule": task.schedule,
"config": task.config,
"is_active": task.is_active,
"last_run_at": task.last_run_at,
"last_status": task.last_status,
"last_error": task.last_error,
"created_by": task.created_by,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
@router.get("")
async def list_tasks(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有定时任务。"""
result = await db.execute(select(ScheduledTask).order_by(ScheduledTask.created_at.desc()))
tasks = result.scalars().all()
return {"items": [_task_to_dict(t) for t in tasks]}
@router.post("")
async def create_task(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建定时任务。"""
name = (body.get("name") or "").strip()
task_type = (body.get("task_type") or "internal_method").strip()
schedule = (body.get("schedule") or "").strip()
if not name or not schedule:
raise HTTPException(status_code=400, detail="任务名称、调度表达式不能为空")
if task_type != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
config = body.get("config")
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
# 验证 JSON 合法性
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task = ScheduledTask(
id=generate_id(),
name=name,
task_type=task_type,
schedule=schedule,
config=config,
is_active=bool(body.get("is_active", True)),
created_by=admin.id,
)
db.add(task)
await db.commit()
return {"id": task.id, "message": "创建成功"}
@router.put("/{task_id}")
async def update_task(
task_id: str = Path(..., description="任务 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if "name" in body:
task.name = str(body["name"]).strip()
if "task_type" in body:
t = body["task_type"]
if t != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
task.task_type = t
if "schedule" in body:
task.schedule = str(body["schedule"]).strip()
if "is_active" in body:
task.is_active = bool(body["is_active"])
if "config" in body:
config = body["config"]
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task.config = config
await db.commit()
return {"message": "更新成功"}
@router.delete("/{task_id}")
async def delete_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
await db.delete(task)
await db.commit()
return {"message": "删除成功"}
@router.post("/{task_id}/run")
async def run_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""手动执行一次定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
from app.tasks.celery_app import RECOVERY_QUEUE
from app.tasks.scheduled_tasks import execute_scheduled_task
execute_scheduled_task.apply_async(args=[task_id], queue=RECOVERY_QUEUE)
return {"message": "任务已提交执行"}
@router.post("/{task_id}/toggle")
async def toggle_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""启用/禁用定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_active = not task.is_active
await db.commit()
return {"is_active": task.is_active, "message": "已启用" if task.is_active else "已禁用"}
@@ -0,0 +1,3 @@
from app.admin_api.vp_v3_quota.routes import router
__all__ = ["router"]

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