710 Commits
Author SHA1 Message Date
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
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 cba3450596 1 2026-07-11 12:18:37 +08:00
root 39160822f7 1、修改素材云样式
2、修改用户私域人像素材默认上限PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT为50
2026-07-11 12:16:13 +08:00
sjy 32673f0f25 “历史素材” 2026-07-11 11:35:18 +08:00
sjy 742c8688bc “历史素材” 2026-07-11 11:32:20 +08:00
root 37588d0f05 前台素材云页面样式统一修改 2026-07-11 11:31:30 +08:00
root f5f4be2986 取消 2026-07-11 11:05:19 +08:00
root d2a30c58ca 1、后台系统设置settings页面增加一个开启关闭网站的按钮
2、如果网站关闭,前台页面所有请求暂时跳转单独的页如果网站关闭,前台页面所有请求暂时跳转单独的页面,页面内容 系统正在升级相关信息
3、要提供一个开发人员可以查看真实网站内容的入口
2026-07-11 10:58:48 +08:00
root 111c74c7c6 增加架构md跟使用md优化 2026-07-11 09:17:07 +08:00
root 8641d507e6 1 2026-07-10 19:28:39 +08:00
root 330ac27b1e 1 2026-07-10 19:27:17 +08:00
root 508732bc76 1 2026-07-10 19:23:52 +08:00
root 614ae1b4d6 1 2026-07-10 19:20:53 +08:00
root 63e1a43b2a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 18:33:35 +08:00
root 8270dc6500 1 2026-07-10 18:33:27 +08:00
root d0bd171ab5 1 2026-07-10 18:24:07 +08:00
GinHa 698626a15a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 17:26:15 +08:00
GinHa 0fd93e53b9 AI计价规则BUG修复 2026-07-10 17:26:11 +08:00
root 70345de660 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 17:09:29 +08:00
root d07cd508ee 1 2026-07-10 17:09:14 +08:00
GinHa f00c36c262 交易流水对账明细 | AI引擎计价规则 | admin build 2026-07-10 17:00:49 +08:00
GinHa 468184d352 交易流水对账明细 | AI引擎计价规则 2026-07-10 16:57:23 +08:00
root ad2e40bc67 1 2026-07-10 14:33:19 +08:00
root bbb0916ada 1 2026-07-10 14:32:58 +08:00
sjy 6518010a36 “dist出错” 2026-07-10 14:29:15 +08:00
root 8b4149f0c5 1 2026-07-10 14:27:32 +08:00
root 1e54c0dc11 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 14:25:48 +08:00
root 8f51f218fc 1 2026-07-10 14:25:37 +08:00
sjy 1ff5e6e050 首页素材案例弹窗修改 2026-07-10 14:25:19 +08:00
sjy 6ddfda578f Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 14:23:49 +08:00
sjy 8c74aaa889 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 14:22:24 +08:00
root f3a14a9e31 1 2026-07-10 14:21:09 +08:00
sjy f2f8625425 首页素材案例弹窗修改 2026-07-10 14:20:49 +08:00
root 51104449a7 1 2026-07-10 14:19:29 +08:00
root f920b58e71 1 2026-07-10 14:14:44 +08:00
sjy d99062ca98 “首页样式优化” 2026-07-10 10:20:59 +08:00
GinHa 4f0cd6baf6 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-10 09:20:24 +08:00
GinHa f11e23ec00 修复拆镜复刻切片重试BUG 2026-07-10 09:20:10 +08:00
sjy 493b72922e “资源管理” 2026-07-09 18:32:33 +08:00
sjy dbd1024f86 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 18:31:01 +08:00
sjy 0231067770 历史资源管理 2026-07-09 18:22:50 +08:00
root 8d4ed4a3d1 1 2026-07-09 18:06:35 +08:00
GinHa 353a5d5093 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 17:20:36 +08:00
GinHa 452ba3ad05 拆镜复刻超时秒数更新为1小时 2026-07-09 17:20:32 +08:00
root 54ecb5368c 1 2026-07-09 16:50:21 +08:00
root 356d4860e3 1 2026-07-09 16:45:47 +08:00
root 65f1514e89 1 2026-07-09 16:34:36 +08:00
root 0abfda7f2d 增加chat_generation_tasks、generation_records时间generated_at索引 2026-07-09 16:21:56 +08:00
root 3f1e84f0c9 1 2026-07-09 15:42:52 +08:00
root a75b863f16 1 2026-07-09 15:16:22 +08:00
Lrd 2c0a51883c 管理员打包 2026-07-09 13:50:34 +08:00
Lrd a9f3260b10 Merge branch 'main' of gitee.com:wg123/video-gen 2026-07-09 13:48:30 +08:00
Lrd 3e001ce66f 完成后端模板列表 2026-07-09 13:48:23 +08:00
GinHa be2930b0e8 拆镜复刻片段重试切片API | 真人/虚拟素材库上传API | app build 2026-07-09 12:09:34 +08:00
GinHa e0143462bb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 12:07:37 +08:00
GinHa 2b996e114e 拆镜复刻片段重试切片API | 真人/虚拟素材库上传API 2026-07-09 12:07:28 +08:00
root 23ab0a2027 1 2026-07-09 11:57:48 +08:00
root 796b533add 1 2026-07-09 11:41:18 +08:00
root 96de545a40 1 2026-07-09 11:37:50 +08:00
root 4b2787a785 1 2026-07-09 11:37:23 +08:00
root b040bede05 1 2026-07-09 11:26:56 +08:00
root 54bf0a53f5 1 2026-07-09 11:19:16 +08:00
root 8d740a8fde 1 2026-07-09 11:18:40 +08:00
root abef1c8df9 1 2026-07-09 10:57:33 +08:00
root 790d546451 1、后台积分比例需要对图片增加和列表增加传入图片价格,倍率、基础积分、每张积分
2、后台列表视频倍率、视频基础积分、视频每秒积分放在单独的传入视频列里
3、列表图片的对应放在单独的传入图片列里
4、/credits/credit-ratios接口对应返回图片添加的规则
5、/credits/ratios接口对应返回图片添加的规则
6、同步修改前台AI创作的/conversation的具体最终图片积分计算逻辑
2026-07-09 10:48:06 +08:00
GinHa c05a52498e 管理后台上传集成API | 首页素材装修追加提词/附件 | admin build 2026-07-09 10:42:12 +08:00
GinHa 8d5230e8fe Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 10:40:33 +08:00
GinHa 0148bea66a 管理后台上传集成API | 首页素材装修追加提词/附件 2026-07-09 10:40:23 +08:00
sjy 6bc05a00d0 ai比例修改 2026-07-09 10:32:16 +08:00
sjy 349a93c18b Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 10:31:21 +08:00
sjy 581507d15d si 2026-07-09 10:30:59 +08:00
root dff9bd6c0e 1 2026-07-09 10:16:45 +08:00
root 3bb778f51a 1 2026-07-09 09:43:28 +08:00
root d3814939ed 1 2026-07-09 09:40:06 +08:00
root b2b0f51109 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-09 09:10:55 +08:00
root fec47bf47a 1 2026-07-09 09:10:49 +08:00
sjy ef5044dc9b 真人人像修改 2026-07-08 17:44:47 +08:00
sjy bb53063358 真人人像修改 2026-07-08 17:15:19 +08:00
GinHa 465ed9fa04 上传素材管控-历史素材 app build 2026-07-08 16:26:56 +08:00
GinHa 3259e3d184 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-08 16:25:15 +08:00
GinHa d085a6d2ec 上传素材管控-历史素材success 2026-07-08 16:25:03 +08:00
root 2882d37112 1 2026-07-08 14:24:50 +08:00
root 5545af003f 后台所有的修改添加的操作都要对应人记录到操作日志里 ​,除了GET之外都要记录 2026-07-08 11:30:31 +08:00
GinHa 657505e63b Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 19:52:19 +08:00
GinHa 2d419b171e 爆款开头复刻/拆镜复刻追加日志|拆镜复刻追加视频AI分析API 2026-07-07 19:52:12 +08:00
root c684348613 1 2026-07-07 19:20:58 +08:00
root 2e491780b1 1 2026-07-07 19:04:15 +08:00
root 50a8e5fe3f 1 2026-07-07 19:00:26 +08:00
root 9cef3106f3 1 2026-07-07 18:58:53 +08:00
root 5ebb64a5c4 1 2026-07-07 18:19:50 +08:00
root 2698216aac 1 2026-07-07 18:11:08 +08:00
GinHa 35ffa317e7 虚拟素材库集成到真人素材库 2026-07-07 17:42:31 +08:00
root 141a2051ba 1 2026-07-07 16:38:45 +08:00
root 5bd72b89de 1 2026-07-07 16:20:27 +08:00
root a0def59ece 1、前台/team-management加入申请的通过失败按钮增加一下图标,区分一下
2、前台/join-team页面上边要增加网站名称,要不然用户看不到是什么网站
3、后台/users的后台用户我新增一个,然后设置菜单权限,实际登陆没有生效
2026-07-07 16:20:08 +08:00
GinHa b92dd75fd4 真人/虚拟人像库app build V2 2026-07-07 15:19:40 +08:00
GinHa d0d3cc56e2 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 15:18:57 +08:00
GinHa 98e8fe145f 真人/虚拟人像库app build 2026-07-07 15:18:52 +08:00
root 9f63adcd0a Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 15:18:05 +08:00
root 0fb2c5f165 1 2026-07-07 15:18:00 +08:00
sjy 6026ec8670 ai 2026-07-07 14:28:37 +08:00
sjy a0086a15c7 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 14:21:42 +08:00
GinHa 48b41f7ab9 真人/虚拟人像库BUG修复V2 | admin build 2026-07-07 14:13:04 +08:00
GinHa 01b520ac49 真人/虚拟人像库BUG修复 | app build 2026-07-07 13:55:30 +08:00
GinHa 64714c6d42 真人素材库V3 | 虚拟人像库 | app/admin build 2026-07-07 13:32:07 +08:00
GinHa 5ffe8e0893 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 13:21:06 +08:00
GinHa 6ae5e0d663 真人素材库V3 2026-07-07 13:20:50 +08:00
root e94225995c 1 2026-07-07 12:08:12 +08:00
root ac869d351f 1 2026-07-07 11:46:08 +08:00
root 3574a9f1cf 1 2026-07-07 11:25:03 +08:00
root d80f5c3577 1 2026-07-07 11:07:15 +08:00
root 88bdccd727 1 2026-07-07 10:56:20 +08:00
root 4a7c845138 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 10:55:45 +08:00
root 4375e4980c 1、生成邀请码默认过期时间设置一天
2、邀请链接内容在列表没有显示完全,复制也没有生效
3、检查用户通过邀请链接访问是否未注册需要注册登陆,如果有账户直接登陆直接进行申请,如果已经登陆直接弹窗显示是否加入具体团队,避免单用户多次提交申请
4、如果团队负责人有未处理的加入申请,弹窗通知
2026-07-07 10:53:34 +08:00
GinHa 4036ce9eea 真人素材库V2 build 2026-07-07 10:50:33 +08:00
GinHa 126754c933 真人素材库修复相关BUG 2026-07-07 10:48:52 +08:00
sjy 7e7cd6bd6e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-07 10:27:07 +08:00
sjy 5ae7f5b64b 上传组件修改 2026-07-07 10:26:40 +08:00
root ab995ce3cf 1 2026-07-06 18:46:30 +08:00
root 32688ab52c 1 2026-07-06 18:45:06 +08:00
root a3f67a76f8 1 2026-07-06 18:42:48 +08:00
root c49f93dcd9 1 2026-07-06 18:35:41 +08:00
root cc74a2ee8b 1 2026-07-06 18:23:40 +08:00
root 6ce40baad3 1 2026-07-06 18:20:36 +08:00
Lrd f6ef4a7fc1 管理后台打包 2026-07-06 18:09:14 +08:00
Lrd b85f4739ef 解决冲突 2026-07-06 18:08:21 +08:00
Lrd 4da83e4859 完成前测素材,模板列表 2026-07-06 18:07:45 +08:00
root 73c559ed74 1 2026-07-06 17:54:56 +08:00
root 420b9147fe 1 2026-07-06 17:25:24 +08:00
root 4818bbc2e9 1 2026-07-06 17:13:18 +08:00
root 3f5d3d0194 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 17:07:59 +08:00
root 3768220a78 1 2026-07-06 17:07:58 +08:00
GinHa 60ed40df2f 真人素材库授权BUG修复 6 2026-07-06 17:05:17 +08:00
root c3ad806e10 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 16:58:01 +08:00
root 3c77b79629 1 2026-07-06 16:57:59 +08:00
GinHa 0a16199c27 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 16:57:13 +08:00
GinHa 3a0aed8647 真人素材库授权BUG修复 5 2026-07-06 16:57:04 +08:00
root e72f54b65d 1 2026-07-06 16:52:57 +08:00
GinHa 0a5f697c04 真人素材库授权BUG修复 4 2026-07-06 16:50:15 +08:00
GinHa cd17a12c06 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 16:46:10 +08:00
GinHa df251b83f5 真人素材库授权BUG修复 3 2026-07-06 16:46:00 +08:00
root cff69423d7 1 2026-07-06 16:35:22 +08:00
root 4412bc9378 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 16:22:21 +08:00
root 04110db524 1、团队积分变动增加对应手机号和日期搜索
2、翻页样式应该放在左下角,别的页面也在左下角吧
3、列表页面显示总使用积分和下载excel
4、团队管理员分配积分的逻辑有问题,团队积分变动不显示,后台显示流水类型也不对,应该增加团队的内部积分变动类型,同步搜索下载
5、调整积分弹窗应该让用户显示增加还是减少的button
6、检查代码是否有不合理的地方,比如枚举是否放在enums里了
2026-07-06 16:21:41 +08:00
GinHa 732cdd6ed1 真人素材库授权BUG修复 2 2026-07-06 16:18:44 +08:00
GinHa f4d278eae4 真人素材库授权BUG修复 2 2026-07-06 15:58:50 +08:00
GinHa 056a26c42b Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 15:47:50 +08:00
GinHa 1517730c8b 真人素材库授权BUG修复 2026-07-06 15:47:44 +08:00
root f515362c8e 1 2026-07-06 15:41:01 +08:00
root 789142ac6c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 15:39:39 +08:00
root 9b213b1551 1 2026-07-06 15:39:33 +08:00
GinHa 8389ae1db6 真人素材库遗漏文件追加 2026-07-06 15:38:13 +08:00
GinHa eac29371ea build 真人素材库 2026-07-06 15:27:14 +08:00
GinHa 26079d03ab merge main 2026-07-06 15:16:30 +08:00
GinHa ab67bdf19e 真人素材库本地完成 2026-07-06 15:13:41 +08:00
root fa4f48a62f 1 2026-07-06 14:23:02 +08:00
root 14e0fd8b25 1 2026-07-06 14:06:25 +08:00
root 0aa47f455a 1 2026-07-06 13:56:29 +08:00
root 9c69e3b67a 1 2026-07-06 13:54:48 +08:00
root 30c1ea970d 1 2026-07-06 13:47:57 +08:00
root e15197a437 1 2026-07-06 13:45:31 +08:00
root 25f3ecf14c 1 2026-07-06 13:43:28 +08:00
root 2bd7d6ecae 1 2026-07-06 13:41:03 +08:00
root 34e566f6bf 1 2026-07-06 13:40:05 +08:00
root 05b6062bd0 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-06 13:39:27 +08:00
root c68215c177 1 2026-07-06 13:39:08 +08:00
root 16b3d223c1 1、后台/teams需要可以设置管理人,后台只给这个管理人分配积分,然后前台管理员自己分配积分
2、前台判断用户是管理人,左下角显示团队管理
3、团队管理可以看到团队的人员、分配人员积分、人员的积分情况的功能
4、团队管理还可以邀请用户,比如生成个链接,未注册需要注册绑定团队,已注册用户访问弹窗是否加入这个团队,然后都需要管理同意才可以加入团队
2026-07-06 13:38:27 +08:00
18610128193 75d1f7a428 增加刷新token为空判断 2026-07-06 10:47:21 +08:00
18610128193 3c0647a6aa 新增统一时间工具,新增后台列表 2026-07-03 18:30:54 +08:00
sjy a0016b436e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-03 18:14:36 +08:00
sjy 84c4683d88 拆镜弹窗预览修改 2026-07-03 18:12:36 +08:00
Lrd d8a02778f1 管理后台打包 2026-07-03 18:12:20 +08:00
Lrd 1fb732c7a8 Merge branch 'main' of gitee.com:wg123/video-gen 2026-07-03 18:11:44 +08:00
Lrd 9474c4f725 新增素材前测模板,投放平台授权,素材ID列表 2026-07-03 18:11:33 +08:00
sjy c99f0f8c8f ai 2026-07-03 18:07:58 +08:00
GinHa fca85b0157 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-03 16:33:55 +08:00
GinHa c9174a22d2 错误信息截断追加5000字符 | 日志信息不在加密 2026-07-03 16:33:48 +08:00
Lrd 2bdee80406 管理后台打包 2026-07-03 15:30:09 +08:00
Lrd 7f9a70d1ee 新增素材前测模板,投放平台授权列表,素材ID列表 2026-07-03 15:22:54 +08:00
Lrd b5538ee27a 打包用户端和管理后台 2026-07-03 14:22:11 +08:00
18610128193 7e6318742c 快手授权不完善 2026-07-03 13:54:22 +08:00
Lrd 30db9fb425 Merge branch 'main' of gitee.com:wg123/video-gen 2026-07-03 13:15:50 +08:00
Lrd 741b138a22 用户端打包 2026-07-03 13:15:45 +08:00
root 9ba24a4f51 1 2026-07-03 12:40:52 +08:00
root 94302719b2 1 2026-07-03 12:38:21 +08:00
GinHa 54410c7854 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-03 12:31:14 +08:00
GinHa e4cf7799a4 修复poll降频检测时间BUG 2026-07-03 12:31:01 +08:00
root 8d9b970ac4 1 2026-07-03 12:27:24 +08:00
18610128193 1705c992e8 增加时区 2026-07-03 11:49:30 +08:00
sjy 778a89cabb ai对话修改 2026-07-03 11:46:13 +08:00
GinHa 28b1d24173 音频文件上传 | AI创作模块兼容音频 2026-07-03 11:44:32 +08:00
Lrd 899732aa54 用户端打包 2026-07-03 11:37:08 +08:00
Lrd 4df90b6d85 管理端打包 2026-07-03 11:29:25 +08:00
Lrd a54256d5af 更新授权应用列表接口 2026-07-03 11:27:27 +08:00
Lrd a53f75a2bb 解决冲突 2026-07-03 11:25:31 +08:00
Lrd b1e328d92d 素材云添加视频图片占位符,对接授权应用接口 2026-07-03 11:20:41 +08:00
root 8d5a210949 1 2026-07-03 11:17:55 +08:00
root 61d74fcc04 1 2026-07-03 10:57:14 +08:00
root 4992191da9 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-03 10:49:58 +08:00
root 8d3116f7fa 1 2026-07-03 10:49:56 +08:00
18610128193 a967f32245 Merge branch 'main' of gitee.com:wg123/video-gen into main 2026-07-03 10:47:38 +08:00
18610128193 5d25317f68 去掉搜索条件 2026-07-03 10:47:25 +08:00
root 3916dbf6ae 1 2026-07-03 10:46:31 +08:00
root f8fc44c68a 1 2026-07-03 10:28:04 +08:00
root 67287cc4ef 1 2026-07-03 10:18:58 +08:00
root 036d8efc15 1 2026-07-03 10:14:06 +08:00
18610128193 08bc4f14bb 修改日志保存路径 2026-07-03 10:10:03 +08:00
root 2d7b8ca334 1 2026-07-03 10:05:21 +08:00
root 5a98d7c1f2 1 2026-07-02 20:30:57 +08:00
root b0a56eccde 1 2026-07-02 19:52:58 +08:00
root 545c4ffc55 1 2026-07-02 19:42:47 +08:00
root 82db946631 1 2026-07-02 19:25:39 +08:00
root 69e664a3a2 1 2026-07-02 19:24:31 +08:00
root 1dd356fd9d 1 2026-07-02 19:03:17 +08:00
sjy 488c98617d ai页面修改 2026-07-02 18:30:46 +08:00
sjy 9edaeb4031 ai页面修改 2026-07-02 18:10:56 +08:00
Lrd b39dae6d55 修改素材云顶部按钮样式 2026-07-02 17:58:09 +08:00
root ce3c49d00e 1 2026-07-02 17:18:45 +08:00
root 0d5534dbca 1 2026-07-02 17:17:32 +08:00
root 6190236fc0 1 2026-07-02 17:16:40 +08:00
root 4a401d101c 增加操作手册 2026-07-02 17:16:23 +08:00
Lrd d39404f1b7 Merge branch 'main' of gitee.com:wg123/video-gen 2026-07-02 15:58:02 +08:00
Lrd 33a78cb4a5 解决页面资源加载过慢问题 2026-07-02 15:57:52 +08:00
GinHa 0fcc21a2f5 AI创作模块优化样式 V3 2026-07-02 15:42:22 +08:00
GinHa 913c1d4026 merge main 2026-07-02 15:20:58 +08:00
GinHa d3bd00fe47 AI创作模块优化样式|修复相关BUG 2026-07-02 15:15:21 +08:00
Lrd ac87ac7059 前测模板选填添加收起功能 2026-07-02 15:13:24 +08:00
sjy b93f93d3f8 素材云删除限制 2026-07-02 15:09:06 +08:00
sjy 049a269374 素材云删除 2026-07-02 14:57:16 +08:00
sjy 43202caa15 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 14:55:37 +08:00
sjy 86fff5fd83 冲突解决 2026-07-02 14:54:57 +08:00
Lrd 28c77fad38 修改平台管理logo展示的样式 2026-07-02 14:30:49 +08:00
sjy b96e4ec4bf 素材云批量删除 2026-07-02 14:13:40 +08:00
GinHa 6b7604dd38 AI创作模块优化样式 2026-07-02 13:27:59 +08:00
root 854a251d22 1 2026-07-02 13:04:11 +08:00
root 3272e8167b Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 13:03:51 +08:00
root 11e0fae735 1 2026-07-02 13:03:33 +08:00
Lrd 44aba3516b 用户端打包线上 2026-07-02 12:01:18 +08:00
Lrd fa56acf455 解决冲突 2026-07-02 12:00:25 +08:00
Lrd 1f67f2cb27 素材云完成视频尺寸要求提交 2026-07-02 11:59:49 +08:00
root d0aa82e1cd 1 2026-07-02 11:53:28 +08:00
root 60ad714010 1 2026-07-02 11:52:23 +08:00
root 575a6f7499 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 11:42:10 +08:00
root c50f0c4fdc 1 2026-07-02 11:42:04 +08:00
18610128193 9275556db3 修改授权,如果是同一个登录账号,任意授权都会更新全部token 2026-07-02 11:38:53 +08:00
root 0061068ac8 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 11:38:12 +08:00
root c2b203f658 修改AI创作界面和后台视频模型添加选择是否支持全能或者收尾帧 2026-07-02 11:38:11 +08:00
GinHa b8d48f6ad3 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 11:37:43 +08:00
GinHa 0fba6fdf09 素材云批量删除API 2026-07-02 11:37:32 +08:00
Lrd 8b21d7cdde 素材云获取视频宽高 2026-07-02 10:28:10 +08:00
root aa076e4e9a 1 2026-07-02 10:11:02 +08:00
root 9731083079 1 2026-07-02 10:09:24 +08:00
root f55f4e82cb 1 2026-07-02 10:07:53 +08:00
root 80c721a940 1 2026-07-02 10:05:26 +08:00
root 6d4dcb33b9 1 2026-07-02 10:01:52 +08:00
GinHa 20e6740e48 合并版本迁移 2026-07-02 09:45:45 +08:00
GinHa 1b9d54e884 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-02 09:36:55 +08:00
GinHa f6c5032c7b celery worker poll 频次机制调整| celery beat 设置poll检测任务| 拆镜复刻切片删除API | 交易流水时区BUG 2026-07-02 09:36:38 +08:00
root 7a2d127c92 1 2026-07-01 21:09:11 +08:00
root 91aac6a931 1 2026-07-01 20:29:11 +08:00
root 1f812908a7 1 2026-07-01 20:28:34 +08:00
root 5441fbc8b9 1 2026-07-01 20:25:47 +08:00
root 5d620b58ff 1 2026-07-01 19:40:47 +08:00
root 5302d7f531 前台页面/conversation修改积分计算逻辑
1、后台页面/credit-ratios,需要选择视频,上边是模型价格,下边增加传入视频价格,也要输入对应的倍率、基础积分、每秒积分
2、前台/conversation获取积分逻辑的接口也要增加对应的传入视频的比例
3、然后前台根据用户上传视频自动获取对应的比例加上对应模型选择的积分计算总积分
4、后台提交也要验证对应的积分是否正确
5、多个视频总时长需限制15s,最低视频时长2s
2026-07-01 19:37:51 +08:00
root 5e70064751 1 2026-07-01 19:15:39 +08:00
Lrd 288c0ea1cc 解决冲突 2026-07-01 17:59:05 +08:00
Lrd 0c562fb325 素材云解决组件懒加载问题 2026-07-01 17:57:56 +08:00
sjy 1656637702 素材案例对接,资源存储修改 2026-07-01 17:56:15 +08:00
sjy 34a877ea8c Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-01 17:54:48 +08:00
sjy 13ede48105 素材案例对接,资源存储修改 2026-07-01 17:54:25 +08:00
root 889715444b 修改ai创作失败原因显示 2026-07-01 17:49:56 +08:00
root ab5814ef0d 1 2026-07-01 17:44:23 +08:00
root eb1ae69d84 修改AI创作可以再次编辑 2026-07-01 17:14:34 +08:00
18610128193 cbe111548a 修改前测post提交json 2026-07-01 16:42:19 +08:00
18610128193 efe92c4c2c 新增素材列表判断 2026-07-01 14:43:17 +08:00
root cf16ed8dfe Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-01 13:44:51 +08:00
root 4cd07c0019 1 2026-07-01 13:44:49 +08:00
sjy 04f34c1075 ai创建 2026-07-01 13:44:19 +08:00
root 823a58d86f 1 2026-07-01 12:05:00 +08:00
root ecf430badb 1 2026-07-01 12:00:33 +08:00
root 69282a47b5 图片增加最大上传个数 2026-07-01 11:56:30 +08:00
18610128193 1fe6f21fe1 打印视频信息 2026-07-01 11:48:15 +08:00
root 632ea108f4 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-01 11:32:41 +08:00
root 0d345761e8 1 2026-07-01 11:32:39 +08:00
sjy d2f9e50b59 ai创建,接口清除 2026-07-01 11:32:00 +08:00
root 0aa65c0723 1 2026-07-01 10:53:30 +08:00
18610128193 37cb96e05d 新增素材列表前测提交 2026-07-01 10:51:15 +08:00
root dddda824d6 1 2026-07-01 10:50:31 +08:00
root 3587e99e25 1 2026-07-01 10:49:15 +08:00
sjy 803e004f84 对应参数选择 2026-07-01 10:48:02 +08:00
sjy 9b575d7bfe ai创作未使用接口清除,添加@选取 2026-07-01 10:41:59 +08:00
sjy 291b240805 ai创作未使用接口清除,添加@选取 2026-07-01 10:41:19 +08:00
GinHa c6df04a895 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-01 10:28:40 +08:00
GinHa 87c0739863 素材云多模块API追加 2026-07-01 10:28:30 +08:00
root 9309a7ffdd 1 2026-07-01 10:20:51 +08:00
18610128193 ee2867ff1a Merge branch 'main' of gitee.com:wg123/video-gen into main 2026-07-01 10:18:26 +08:00
18610128193 43f16338bd 记录日志内容 2026-07-01 10:18:17 +08:00
root 3543be4628 1、后台界面/video-engines,添加修改需要增加可传图片和视频的数量
2、前台接口/credits/ratios,同步返回
2026-07-01 10:10:43 +08:00
18610128193 a011e4575b 修改接口返回错误显示 2026-07-01 10:02:22 +08:00
root 40b3df3219 1 2026-07-01 09:40:30 +08:00
GinHa 838215736f Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-07-01 09:28:12 +08:00
GinHa 7f35f658d4 修复首页装修素材API 2026-07-01 09:28:02 +08:00
root 01f8abec0b 1 2026-07-01 09:26:32 +08:00
sjy 935af9ee2d “dist” 2026-06-30 19:34:23 +08:00
sjy 6b4fc7fc17 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-30 19:30:33 +08:00
sjy 86e5bafad2 比例选择样式修改 2026-06-30 19:30:11 +08:00
root b98b748c4b 1 2026-06-30 19:17:47 +08:00
Lrd 0bf54bfb50 打包用户端 2026-06-30 18:46:13 +08:00
Lrd 7c5580c76a 解决搜索组件传参问题 2026-06-30 18:45:10 +08:00
Lrd 976fa1b5a6 素材列表添加多选功能 2026-06-30 17:45:43 +08:00
Lrd 571067f8ed 用户端打包上线 2026-06-30 17:21:38 +08:00
Lrd 9e6d6f4d74 解决冲突 2026-06-30 17:20:14 +08:00
Lrd ecb0f512c3 添加城市地区缓存 2026-06-30 17:19:42 +08:00
root 5865f8d3b3 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-30 17:10:53 +08:00
root 0c63512a29 取消背景图 2026-06-30 17:10:51 +08:00
GinHa f5dc4a0075 首页素材装修模块build 2026-06-30 17:08:03 +08:00
GinHa 1389d373c9 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-30 17:06:56 +08:00
GinHa b48979d4c4 首页素材装修模块完成 2026-06-30 17:06:45 +08:00
root ef9d9e8a74 修改ai创作背景图 2026-06-30 17:03:07 +08:00
root 0541fc0046 修改弹窗的关闭图标和整体样式,增加素材云的浮动下载 2026-06-30 16:46:18 +08:00
root a3e70b5d49 修改数据库上线忽略规则 2026-06-30 15:20:28 +08:00
Lrd b78b0428c0 用户端打包上传 2026-06-30 14:49:24 +08:00
Lrd 8f152d29fe 解决冲突 2026-06-30 14:48:30 +08:00
Lrd 5cd5d9f24f 前测功能完善详情编辑删除 2026-06-30 14:47:51 +08:00
sjy b6e44565e7 资源存储/ai对话缓存 2026-06-30 14:04:46 +08:00
sjy 1154aab919 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-30 14:00:42 +08:00
sjy 240107ca34 ai对话缓存 2026-06-30 13:58:48 +08:00
Lrd 72a1b6e2b0 素材云添加前测参数 2026-06-30 13:12:18 +08:00
GinHa 37203cdbaf 团队管理设置merge 2026-06-30 11:39:05 +08:00
GinHa 9fb81dcfc1 团队管理设置 2026-06-30 11:31:54 +08:00
Lrd 195c809915 用户端打包上传 2026-06-30 10:54:08 +08:00
Lrd e0a8082d8c 解决冲突 2026-06-30 10:53:16 +08:00
Lrd f24ac1c4f7 修改路由跳转带参问题 2026-06-30 10:52:27 +08:00
root 79a0dd449c 1 2026-06-30 10:30:00 +08:00
root 9028b33072 1 2026-06-30 10:16:03 +08:00
孙佳艺 84559eaded Merge remote-tracking branch 'origin/main' 2026-06-30 10:13:20 +08:00
root 6bd43180c3 1 2026-06-30 10:09:34 +08:00
root 024b1e7a34 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-30 10:09:13 +08:00
root 702a8b3a5d /projects、/conversation、/initial、/removelens这四个的Header样式风格和返回按钮风格 2026-06-30 10:08:54 +08:00
Lrd 88c31dce9f 用户端打包上传 2026-06-30 09:50:54 +08:00
Lrd d6b32620b7 授权列表新增过期时间和重新授权按钮 2026-06-30 09:49:34 +08:00
18610128193 7c2071aa80 添加授权列表返回token有效期 2026-06-30 09:23:09 +08:00
孙佳艺 0cf4bd554b Merge remote-tracking branch 'origin/main' 2026-06-30 09:22:42 +08:00
Lrd c05639fb47 Merge branch 'main' of gitee.com:wg123/video-gen 2026-06-30 09:19:25 +08:00
Lrd 1e24195bd6 ai用户端打包上传 2026-06-30 09:19:15 +08:00
root 8bbd36a1de 1 2026-06-30 09:18:58 +08:00
Lrd 2a73919879 解决冲突问题 2026-06-30 09:16:07 +08:00
Lrd 67c2484b8d 操作栏添加浮动操作,对接授权账户列表 2026-06-30 09:15:29 +08:00
孙佳艺 66c3328e23 资源存储 2026-06-30 09:10:27 +08:00
root 152392e3d9 1 2026-06-29 18:39:46 +08:00
root 188119009e 1 2026-06-29 18:29:33 +08:00
root 63c2f0fb33 1 2026-06-29 18:26:06 +08:00
root 13970941d5 1、修改左上角背景图颜色修改
2、左下角文字变大
3、默认账户名字修改
4、右下角联系我们修改位置跟可以移动
2026-06-29 18:12:48 +08:00
GinHa f61e5035ff 用户生成资源容量管控管理后台build 2026-06-29 13:53:23 +08:00
GinHa 3f376d63bf Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-29 13:51:32 +08:00
GinHa 25fa41f894 用户生成资源容量管控 2026-06-29 13:50:39 +08:00
root 241bfd4aed 1 2026-06-29 11:58:17 +08:00
root f35422fda2 1 2026-06-29 11:57:27 +08:00
root 1ad9b0ff14 1 2026-06-29 11:54:13 +08:00
root f4a6be6e39 1 2026-06-29 11:46:10 +08:00
root 31d2a0d6cd 1 2026-06-29 11:46:05 +08:00
root e66cb3e865 1 2026-06-29 11:39:42 +08:00
root c5f6268829 1 2026-06-29 11:36:32 +08:00
root 9dd1bcdefc 1 2026-06-29 11:26:14 +08:00
root 43d12894c1 1 2026-06-29 11:09:43 +08:00
root bc8e4c9fff 1 2026-06-29 11:06:08 +08:00
root 696172108b 1 2026-06-29 11:04:09 +08:00
root d03f6b4368 1 2026-06-29 11:02:12 +08:00
root fabab1dbe9 1 2026-06-29 10:56:17 +08:00
root 39440f2ad4 1 2026-06-29 10:51:59 +08:00
root 776e122adf 1、前台登陆的《用户协议》和《隐私政策》合并为一个文件,用户协议及隐私政策,后台系统设置上传同步更改
2、前台登陆最下方加入版权信息添加,后台系统设置同步修改
2026-06-29 10:42:13 +08:00
root 92fb64aa99 修改
1、微信回调签名验证绕过问题
2、联系请求竞态条件问题
3、contact列表count过滤bug
4、核心业务表索引添加
2026-06-29 10:15:10 +08:00
root b21f35582e 修改消息相关索引 2026-06-29 09:09:58 +08:00
18610128193 627087797b 广告,本地推都是用广告 2026-06-26 18:19:31 +08:00
Lrd 8f9140b6e7 上传build打包 2026-06-26 18:11:41 +08:00
Lrd 450c9e538d 解决dist冲突 2026-06-26 18:09:42 +08:00
Lrd b26b9e6af1 完成素材云列表添加历史功能 2026-06-26 18:03:55 +08:00
root 6c8543a5f6 1 2026-06-26 17:51:11 +08:00
root ab02f7ef34 1 2026-06-26 17:46:19 +08:00
root a7019f1344 合并 2026-06-26 17:45:18 +08:00
root 54cd7c0e19 1 2026-06-26 17:42:35 +08:00
孙佳艺 ded24b4a91 dist/首页 2026-06-26 17:40:41 +08:00
孙佳艺 5bdd5b4eb3 dist/首页 2026-06-26 17:33:59 +08:00
root a986ddf037 1 2026-06-26 17:30:43 +08:00
孙佳艺 f2dbfbf62f 首页 2026-06-26 17:25:11 +08:00
root ef9522a47b 1 2026-06-26 17:24:19 +08:00
18610128193 4f2be8a137 增加本地推上传 2026-06-26 17:14:55 +08:00
GinHa cf1ca2be12 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-26 17:04:46 +08:00
GinHa de63c44ba3 消息推送API追加用户积分数据 2026-06-26 17:04:39 +08:00
root a78c93df13 1 2026-06-26 16:27:49 +08:00
root 02864677ec 1 2026-06-26 16:27:04 +08:00
root ee89a5ab38 1 2026-06-26 16:26:44 +08:00
root 5c9fb2e713 1 2026-06-26 16:26:16 +08:00
root 14dae263bb 1 2026-06-26 16:21:26 +08:00
root 30e263e7d7 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-26 16:07:42 +08:00
root 4e33900317 1 2026-06-26 16:07:40 +08:00
18610128193 87bbc5c1c1 上传图片使用默认名称 2026-06-26 15:58:34 +08:00
root 0903543994 1 2026-06-26 15:57:46 +08:00
root d82c7a1490 调整前台页面手机适配的问题
1、登陆页面

2、登陆成功菜单展示,调整为显示一级,然后点击显示二级,然后跳转对应页面
2026-06-26 15:52:30 +08:00
GinHa 5a07fbb36f merge main 2026-06-26 15:44:39 +08:00
GinHa 6ce6c23b55 home页生成记录API 补追文件 2026-06-26 15:43:43 +08:00
GinHa 7d4eaebb79 home页生成记录API 2026-06-26 15:43:17 +08:00
root c6997789dc 增加枚举数据 2026-06-26 15:38:40 +08:00
18610128193 9c51aac97b 新增账户管理列表 2026-06-26 15:22:52 +08:00
Lrd 6120e03183 去除文件副本 2026-06-26 15:22:01 +08:00
Lrd 04f9a4e893 重新打包上传客户端文件 2026-06-26 15:12:13 +08:00
Lrd 02cd4a582b 解决冲突 2026-06-26 15:11:18 +08:00
Lrd 5ef4719d4e 完成开户方式图片路径的修改 2026-06-26 15:09:40 +08:00
root 47ebe17459 1 2026-06-26 15:07:10 +08:00
root 76a0c1be4e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-26 15:05:55 +08:00
root e21ba57597 修改Web Crypto API 在 HTTP 环境下不可用的问题,自动降级
- HTTPS/localhost :正常启用加密
- HTTP(非 localhost) :自动禁用加密,以明文方式通信
2026-06-26 14:56:03 +08:00
Lrd 7e2a44c0a1 完成开户方式的修改 2026-06-26 14:52:13 +08:00
root 157e7e5bba 1 2026-06-26 14:39:42 +08:00
root b1aa4f4a52 1 2026-06-26 14:19:17 +08:00
18610128193 fc5fb41d42 Merge branch 'main' of gitee.com:wg123/video-gen into main 2026-06-26 14:00:27 +08:00
18610128193 9b37cd789e 新增返回所有的账户数据 2026-06-26 14:00:18 +08:00
GinHa 9bf181e14c 修复celery异步输出json序列化异常BUG 2026-06-26 13:59:58 +08:00
GinHa 479e1a91fb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-26 13:47:14 +08:00
Lrd f7a5ec4b58 修改素材云上传source_model接口参数 2026-06-26 13:45:38 +08:00
18610128193 43ed6c0868 Merge branch 'main' of gitee.com:wg123/video-gen into main 2026-06-26 13:25:45 +08:00
Lrd c0e8d1caae 提交任务上传历史列表的时间修改 2026-06-26 13:25:35 +08:00
18610128193 2c899196d9 增加异步上传捕获异常 2026-06-26 13:25:28 +08:00
GinHa ee03242e6c celery异步恢复任务独立队列|扩增celery子进程连接池上限配置 2026-06-26 13:15:25 +08:00
Lrd 02c068e693 打包build上传 2026-06-26 12:49:38 +08:00
Lrd 81d0c20422 解决冲突 2026-06-26 12:47:27 +08:00
Lrd 7b37fcfc61 素材云批量提交 2026-06-26 12:43:13 +08:00
root 8fa5dc4308 1 2026-06-26 12:36:05 +08:00
root af932cce33 修复登陆积分和每日签到积分记录保存和联系我们数据问题 2026-06-26 12:22:50 +08:00
root 24fac8fc1b 1 2026-06-26 12:00:08 +08:00
GinHa 06cdab9cbc 修复生辰任务恢复机制BUG|修复生成视频/图片token快照不回落交易流水BUG 2026-06-26 11:51:02 +08:00
root faf31c8141 增加右下角联系我们整体功能 2026-06-26 11:49:35 +08:00
18610128193 e62956af79 新增上传素材重试 2026-06-26 11:28:49 +08:00
18610128193 e431cb1caf Revert "上传素材接口,增加3次请求"
This reverts commit 6be98fe3bf.
2026-06-26 11:19:39 +08:00
18610128193 f7331a8b73 Merge branch 'main' of gitee.com:wg123/video-gen into main 2026-06-26 11:13:42 +08:00
18610128193 6be98fe3bf 上传素材接口,增加3次请求 2026-06-26 11:13:34 +08:00
root a58380cc5e Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-26 11:11:25 +08:00
root fbc6354f76 1 2026-06-26 11:11:23 +08:00
Lrd b383df8b17 Merge branch 'main' of gitee.com:wg123/video-gen 2026-06-26 11:01:46 +08:00
Lrd b152085918 修改素材列表前测状态 2026-06-26 11:01:32 +08:00
18610128193 ae850de128 新增日志文件记录 2026-06-26 11:00:56 +08:00
Lrd c06c4b1dde 解决素材云批量上传弹框组件样式问题 2026-06-26 10:52:02 +08:00
root c794b33a0e 1 2026-06-26 10:49:56 +08:00
root 8c33c824ab 1 2026-06-26 10:41:23 +08:00
Lrd a6db2bf400 解决冲突 2026-06-26 10:32:40 +08:00
Lrd 5268f21705 解决冲突 2026-06-26 10:31:18 +08:00
root 3b9e5fab10 1 2026-06-26 10:24:10 +08:00
Lrd 72fb7995ca 解决dist冲突 2026-06-26 10:23:49 +08:00
root d6b6732ec2 1 2026-06-26 10:23:08 +08:00
root f3675924dc 1 2026-06-26 10:12:05 +08:00
root 857c70f493 1 2026-06-26 10:10:40 +08:00
root db3bddca8c 1 2026-06-26 10:08:42 +08:00
root 509892ca7b 1 2026-06-26 10:06:17 +08:00
root cee7093db6 增加爆款榜单、创意广场假页面 2026-06-26 09:55:46 +08:00
root abc53234f1 1 2026-06-26 09:25:48 +08:00
root 6fcc3f8a0c 1 2026-06-26 09:20:50 +08:00
root 4efb360b93 修改后台菜单逻辑,默认显示用户全部可见菜单 2026-06-26 09:09:33 +08:00
Lrd 9f8aca5f0d 打包build生成线上文件 2026-06-25 18:54:25 +08:00
Lrd 278c9f96a1 修改消耗列表跳转入口 2026-06-25 18:52:46 +08:00
Lrd 2d59b8f335 提交授权等待,素材云,消耗列表页面更新 2026-06-25 18:39:21 +08:00
Lrd d82001c60f 注释juliang_callback授权提示 2026-06-25 18:10:25 +08:00
Lrd 06eee7ae47 取消juliang_callback跳转 2026-06-25 18:05:43 +08:00
Lrd a8494c08a5 更新juliang_callback接口 2026-06-25 18:02:34 +08:00
Lrd 772aa21fae 解决冲突 2026-06-25 17:39:23 +08:00
Lrd fc0bbad3ba 解决dist冲突 2026-06-25 17:32:42 +08:00
孙佳艺 3c3797a734 dist 2026-06-25 17:29:17 +08:00
GinHa 5d038268ce Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-25 17:25:40 +08:00
GinHa fa2e56d021 版本迁移同步合并|管理后台build 2026-06-25 17:25:35 +08:00
Lrd 8f00e35cc5 解决dist冲突 2026-06-25 17:20:44 +08:00
孙佳艺 5f06fd06cf 修改边距 2026-06-25 17:19:35 +08:00
Lrd 690d1baba0 解决card组件废弃属性 2026-06-25 17:07:32 +08:00
GinHa 50afcf02d0 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-25 16:53:46 +08:00
GinHa 30db7d3bf5 交易流水对账明细导出完成 2026-06-25 16:53:04 +08:00
18610128193 537c121361 判断错误异常 2026-06-25 16:44:26 +08:00
孙佳艺 78c3f36ca2 手动分割调整 2026-06-25 16:04:57 +08:00
18610128193 a0a60bd121 修改授权接口,返回对应错误 2026-06-25 15:41:11 +08:00
Lrd 6da4fe78f3 用户端上传消耗列表 2026-06-25 15:16:23 +08:00
Lrd 5f8e2ce93a 管理员系统完成消耗列表及表头接口对接 2026-06-25 14:29:20 +08:00
18610128193 a382c27dc3 增加前测任务获取前测结果 2026-06-25 13:58:31 +08:00
Lrd 586bce2aeb 素材云完善切换报错问题 2026-06-25 11:42:47 +08:00
孙佳艺 87e412ef74 素材云下载 2026-06-25 11:11:06 +08:00
18610128193 5fc1e43d4e 上传日志表增加前测字段 2026-06-25 11:04:40 +08:00
18610128193 3c41f56ce5 上传使用自定义名称 2026-06-25 10:58:14 +08:00
Lrd 3ae2fe8d01 修改素材列表列顺序 2026-06-25 09:57:38 +08:00
孙佳艺 7102683191 merge 2026-06-25 09:44:33 +08:00
孙佳艺 ef31453ffa 背景压缩 2026-06-25 09:37:45 +08:00
Lrd bdb5ce891c 对接素材列表解决 2026-06-25 09:34:40 +08:00
18610128193 8aafd7de32 素材列表展示 2026-06-24 18:47:36 +08:00
孙佳艺 28c6e2c772 拆镜复刻 2026-06-24 17:36:17 +08:00
18610128193 7f1b18314e 素材列表增加返回文件名称 2026-06-24 16:55:12 +08:00
18610128193 313471c1dd 新增资源表文件名字段 2026-06-24 14:30:05 +08:00
GinHa 143ae52878 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-24 11:54:48 +08:00
GinHa 9b1a88d131 修复爆款开头复刻/拆镜复刻软删步骤时未处理生成资源|素材云记录追加资源数据ID 2026-06-24 11:54:37 +08:00
Lrd 749b576f6c Merge branch 'main' of gitee.com:wg123/video-gen 2026-06-24 11:17:38 +08:00
Lrd 8be899d5e4 修改素材云批量上传 2026-06-24 11:17:33 +08:00
18610128193 adb6b09094 添加素材列表 2026-06-24 10:02:16 +08:00
孙佳艺 4a1118a3ec 拆镜复刻跳转修改 2026-06-24 09:10:45 +08:00
GinHa ddaa64aef0 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-23 15:30:11 +08:00
孙佳艺 da056ea64b ai创建修改 2026-06-23 15:29:50 +08:00
GinHa 3b5e428eb8 Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-06-23 15:14:33 +08:00
GinHa fb55c03dd8 save gitignore 2026-06-23 15:05:56 +08:00
18610128193 242030c50b 新增素材更新功能 2026-06-23 14:16:31 +08:00
18610128193 b88546ab66 新增开户方式管理表 2026-06-23 13:55:14 +08:00
GinHa 0985027746 客户端编译同步 2026-06-23 10:22:52 +08:00
Lrd 5041660ca4 完成对接前测表单创建及列表接口 2026-06-22 18:24:05 +08:00
GinHa a37b0e4435 视频提示SCHEMA管理后台配置联动修改 react 客户端BUG修复 2026-06-22 16:59:30 +08:00
GinHa 270628049b 视频提示SCHEMA管理后台配置联动修改三端 2026-06-22 16:49:05 +08:00
孙佳艺 58b1372232 爆款开头复刻样式修改 2026-06-22 16:11:55 +08:00
GinHa 03e5b049b4 视频提词优化管理后台配置BUG修复 2026-06-22 15:56:09 +08:00
GinHa ada0243919 视频提词优化管理后台配置build dist merge 2026-06-22 14:42:18 +08:00
GinHa 5e432c2d3f 视频提词优化管理后台配置build dist 2026-06-22 14:40:34 +08:00
GinHa 73567b4143 视频提词优化管理后台配置 2026-06-22 14:39:00 +08:00
root 5d67b30b9a 1 2026-06-22 14:22:48 +08:00
root 721bcd97e4 修改后台登陆错误逻辑 2026-06-22 14:18:55 +08:00
602 changed files with 122953 additions and 19328 deletions
+8 -3
View File
@@ -8,13 +8,17 @@ __pycache__/
.claude/
.vscode/
.trae/
video-gen-app/dist/
video-gen-api/dist/
# video-gen-app/dist/
#video-gen-api/dist/
bak/
# 使用通配符
*.log
*.tmp
.DS_Store
.playwright-mcp
*.zip
*.bat
# 否定规则(不忽略)
# !important.log
@@ -22,4 +26,5 @@ video-gen-api/dist/
# 忽略特定类型文件但保留目录
# *.pyc
# !dir/*.pycnode_modules/
*.tmp.*
*.tmp.*
*_上线.py
+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/ # 错误日志
```
+882
View File
@@ -0,0 +1,882 @@
# VideoGen 代码架构指南
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
# AI 工作规则(最高优先级)
当你执行开发任务时:
1. 不要扫描整个 Repository。
2. 不要执行全量目录探索。
3. 仅阅读本任务涉及的目录。
4. 优先按照本文档中的"快速定位表"寻找文件。
5. 如果无法定位文件,再进行有限范围搜索。
6. 单次最多搜索一级目录。
7. 不允许重复搜索已经访问过的目录。
8. 阅读完目标文件后立即开始修改代码。
---
## 一、整体架构
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI)
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
```
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
---
## 二、后端架构 (video-gen-api)
### 2.1 目录结构
```
app/
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
│ │ ├── __init__.py # 聚合所有 v1 router
│ │ ├── auth.py
│ │ ├── projects.py
│ │ ├── generation.py
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1
│ │ └── ...
│ └── admin/ # 额外的管理端细分路由
│ ├── __init__.py
│ ├── team.py
│ ├── home_material.py
│ └── ...
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
│ ├── generation.py
│ ├── auth.py
│ └── ...
├── models/ # SQLAlchemy ORM 模型(41 个)
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
│ ├── user.py
│ └── ...
├── services/ # 业务逻辑层(核心业务写在这里)
│ ├── auth.py # JWT 认证、密码哈希
│ ├── credits.py # 积分扣减
│ ├── operation_log.py # 操作日志
│ └── ...
├── enums/ # 枚举定义(全部用 str, Enum
│ ├── generation_status.py
│ ├── celery_queue.py
│ └── ...
├── tasks/ # Celery 异步任务
│ ├── celery_app.py # Celery 实例 + 配置
│ ├── cleanup.py
│ └── ...
├── middleware/ # FastAPI 中间件
│ ├── logging.py
│ ├── rate_limit.py
│ ├── request_encrypt.py
│ ├── anti_crawler.py
│ └── ...
├── utils/ # 通用工具
│ ├── id_gen.py # generate_id(), generate_order_no()
│ ├── exceptions.py # 自定义 HTTPException
│ └── security.py # AES 加密、HMAC
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
├── config.py # Pydantic Settings,读取 .env
└── main.py # FastAPI 应用入口、lifespan、种子数据
```
### 2.2 分层约定(重要)
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
```
Route (api/) → Service (services/) → Model (models/)
解析参数 业务逻辑 数据库操作
调用 service 编排调用 纯 CRUD
返回序列化结果 不直接操作 ORM 无业务规则
```
**禁止:**
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
- Model 文件中写业务逻辑 → model 只定义字段和关系
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
### 2.3 SQLAlchemy 模型约定
使用 SQLAlchemy 2.0 风格的 `mapped_column`
```python
# app/models/project.py — 标准模板
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
class Project(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(128))
industry: Mapped[str] = mapped_column(String(32))
```
**约定:**
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
- 外键统一加 `index=True``ondelete="CASCADE"` 按需
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)`
- 时间统一用 `DateTime(timezone=True)`
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
```python
# app/models/base.py — 公共 Mixin
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SoftDeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
```
### 2.4 Pydantic Schema 约定
```python
# app/schemas/project.py
from pydantic import BaseModel, Field
from app.schemas.common import NaiveDatetime
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
name: str = Field(..., max_length=128)
industry: str = Field(..., max_length=64)
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
id: str
name: str
industry: str
created_at: NaiveDatetime
updated_at: NaiveDatetime
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
```
**命名约定:**
- `XxxCreate` — POST 创建请求
- `XxxUpdate` — PUT 更新请求
- `XxxOut` / `XxxResponse` — 响应
- `XxxListOut` — 列表响应(含 items + total
- `XxxRequest` — 通用请求
**公共工具:**
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
- `PaginatedResponse` — 分页响应基类
### 2.5 API 路由约定
```python
# app/api/v1/projects.py — 标准模板
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectOut
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
async def list_projects(
current_user: User = Depends(get_current_user), # 鉴权依赖
db: AsyncSession = Depends(get_db), # 数据库 session
):
result = await db.execute(
select(Project).where(
Project.user_id == current_user.id,
Project.deleted_at.is_(None), # 软删除过滤
).order_by(Project.created_at.desc())
)
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
@router.post("", response_model=ProjectOut)
async def create_project(
req: ProjectCreate, # 请求体自动校验
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
project = Project(
id=generate_id(), # 主键手工生成
user_id=current_user.id,
name=req.name,
industry=req.industry,
)
db.add(project)
await db.flush() # flush 获取自增字段但不出事务
return project
```
**关键约定:**
- `prefix` 不含 `/api``/api``main.py``include_router` 统一加)
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
- 管理员接口用 `Depends(get_admin_user)`
- 可选登录用 `Depends(get_optional_current_user)`
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`
### 2.6 依赖注入约定
```python
# app/dependencies.py
get_db # 生成 AsyncSession,自动 commit/rollback
get_current_user # 验证 JWT → 返回 User,要求密码已设置
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
get_optional_current_user # 验证 JWT → User | None(不强制登录)
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin
```
### 2.7 服务层约定
```python
# app/services/team_service.py — 标准模板
from __future__ import annotations # 前向引用必需的 import
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.team import Team
from app.schemas.team import TeamCreate, TeamUpdate
from app.utils.id_gen import generate_id
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
team = Team(
id=generate_id(),
name=req.name,
code=req.code,
description=req.description,
)
db.add(team)
await db.flush()
return team
```
**约定:**
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
-`from __future__ import annotations` 支持前向引用
- 纯查询类服务可以接收 `db` 作为第一个参数
### 2.8 枚举约定
```python
# app/enums/generation_status.py
from enum import Enum
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
prompt_optimized = "prompt_optimized"
generating = "generating"
completed = "completed"
failed = "failed"
class GenerationType(str, Enum):
video = "video"
image = "image"
```
**约定:**
- 所有枚举继承 `str, Enum`(可序列化为 JSON
- 枚举值用 snake_case
- 枚举文件统一放 `app/enums/`
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
### 2.9 异常处理约定
```python
# app/utils/exceptions.py — 预定义异常
InsufficientCreditsError # 402 积分不足
CaptchaFailedError # 400 验证码失败
RecordNotFoundError # 404 记录不存在
ProjectNotFoundError # 404 项目不存在
InvalidStatusError # 400 状态不允许操作
# 在 service 或 route 中抛出
raise InsufficientCreditsError()
```
### 2.10 认证约定
```python
# Token 创建
create_access_token(user_id, remember_me=False) str # JWT 令牌
# Token 解码
decode_access_token(token) str | None # 返回 user_id 或 None
# 密码
hash_password(plain) str # bcrypt 哈希
verify_password(plain, hashed) bool # bcrypt 校验
```
**鉴权流程:**
1. 前端 Authorization header: `Bearer <token>`
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
3. 查 User 表验证 `is_active`
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
### 2.11 数据库 Session 模式
```python
# dependencies.py 中的 get_db — 自动管理事务
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit() # 成功自动提交
except Exception:
await session.rollback() # 异常自动回滚
raise
finally:
await session.close()
```
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
### 2.12 中间件约定
中间件按**注册顺序**从外到内执行(`main.py`):
```python
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
application.add_middleware(CORSMiddleware, ...) # 5. CORS
```
**加密中间件行为:**
- 请求有 `X-Encrypted: true` header → 解密请求体
- GET 请求无 body 但**响应仍加密**
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
### 2.13 Celery 任务约定
```python
# app/tasks/cleanup.py — 标准模板
from app.tasks.celery_app import celery_app
@celery_app.task # 装饰器注册任务
def cleanup_expired_video_urls():
"""Run hourly. Clear expired video URL tokens."""
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
from app.models.base import async_session # 延迟导入避免循环
async with async_session() as db:
...
await db.commit()
```
**约定:**
- 任务装饰器: `@celery_app.task`
- 同步入口 → `asyncio.run()` 包异步逻辑
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`
- 队列路由在 `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
# app/config.py — 基于 pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
SECRET_KEY: str = "change-me"
# ... 所有配置项有默认值,生产通过 .env 覆盖
settings = Settings() # 全局单例
```
**约定:**
- 所有环境变量在 `config.py` 中有类型注解和默认值
- 通过 `settings.XXX` 全局访问
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
- 列表值用 `list[str]`pydantic 自动解析 JSON 数组字符串)
### 2.15 操作日志约定
```python
from app.services.operation_log import log_operation
# 在路由中调用(通常在变更操作后)
await log_operation(
db,
admin.id,
admin.username,
f"创建团队 {team.name}", # action 描述
"POST", # HTTP method
"/admin/teams", # path
detail=None, # 可选详情
ip=None, # 可选 IP
)
```
---
## 三、前端架构 (video-gen-app & video-gen-admin)
### 3.1 相同点
两个前端项目遵循完全相同的架构约定:
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
- Zustand 状态管理
- 统一的 API 调用层(crypto/client/index 三层)
- 路由结构 SPAreact-router-dom v7
### 3.2 目录结构(以 video-gen-app 为例)
```
src/
├── api/ # API 调用层
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
├── store/ # Zustand 状态
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
│ └── useAppStore.ts # 应用状态
├── types/ # TypeScript 类型定义
│ └── index.ts # 所有 interface/type
├── pages/ # 页面组件(路由对应)
│ ├── LoginPage.tsx
│ ├── ProjectsPage.tsx
│ └── ...
├── components/ # 可复用组件
│ ├── Layout/
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content
│ │ └── AppLayout.css
│ ├── privatePortrait/
│ └── ...
├── utils/ # 工具函数
│ ├── formatDate.ts
│ └── ...
├── App.tsx # 路由定义 + 全局配置
└── main.tsx # 入口
```
### 3.3 API 调用约定(核心)
```typescript
// ── client.ts — 核心请求函数 ──
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
// 调用参数:
interface RequestOptions {
method?: string // GET/POST/PUT/DELETE,默认 GET
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
auth?: boolean // 是否带 Authorization header,默认 true
encryptBody?: boolean // 是否加密,默认跟随全局开关
signal?: AbortSignal // 取消请求
skipAuthRedirect?: boolean // 401 时不跳转登录页
}
```
```typescript
// ── index.ts — 业务 API 函数 ──
// 命名约定:动词 + 业务名,camelCase
export async function login(username: string, password: string): Promise<User> {
if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>(
'/auth/login',
{ username, password },
false, // auth: false(登录不需要 token
true // encryptBody: true(登录凭证需要加密)
);
setToken(res.accessToken);
return res.user;
}
// GET 列表
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
return api.get<Project[]>('/projects');
}
// POST 创建
export async function createProject(name: string, industry: Industry): Promise<Project> {
if (USE_MOCK) return mock.mockCreateProject(name, industry);
return api.post<Project>('/projects', { name, industry });
}
// 带查询参数的 GET
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
const query = new URLSearchParams();
if (params.projectId) query.set('project_id', params.projectId);
query.set('page', String(page));
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
}
```
**关键约定:**
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
- 所有 id 字段前端用 `string`(不转 number
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`user_id → userId
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
### 3.4 状态管理约定(Zustand
```typescript
// store/useAuthStore.ts — 标准模板
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api';
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: true,
login: async (username, password) => {
const user = await api.login(username, password);
set({ user });
},
// ...
}));
```
**约定:**
- 全局状态用 Zustand `create`,不用 Redux
- API 调用写在 store actions 或页面中,**不要**写在 components 里
- 状态更新用 `set()`,异步操作加 `async/await`
- 组件中消费: `const { user, login } = useAuthStore()`
### 3.5 页面组件约定
```typescript
// pages/ProjectsPage.tsx — 标准模板
import React, { useEffect, useState } from 'react';
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { getIndustries } from '../api';
import type { IndustryConfig } from '../types';
const ProjectsPage: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const loadProjects = async () => {
setLoading(true);
try {
const data = await getProjects(); // 调 API
setProjects(data);
} catch (e: any) {
message.error(e?.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
return (
<div>
<Typography.Title level={4}></Typography.Title>
{/* ... */}
</div>
);
};
export default ProjectsPage;
```
**约定:**
- 页面组件是 `React.FC`,默认导出
- 业务数据类型从 `../types` 导入(`import type`
- API 函数从 `../api` 导入
- 不用 `axios`,所有请求走 `api/index.ts`
-`message.success/error/warning` 显示操作反馈
-`useNavigate()` 编程式导航
- 列表加载用 `loading` 状态 + `Spin` 组件
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
### 3.6 路由约定
```typescript
// App.tsx — 路由结构
// 公开路由
<Route path="/login" element={<LoginPage />} />
<Route path="/join-team" element={<JoinTeamPage />} />
// 受保护路由(需要登录)
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
</Route>
```
**约定:**
- 路由定义集中在 `App.tsx`
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`
- 未登录用户访问受保护路由 → 跳 `/login`
- 动态路由参数用 `:paramName`
- 嵌套路由用 `<Outlet />` 渲染子页面
### 3.7 TypeScript 类型约定
```typescript
// types/index.ts — 全局类型定义
export interface User {
id: string; // 所有 ID 都是 string
username: string;
credits: number;
// ...
}
```
**约定:**
- 全局共享类型在 `types/index.ts` 定义
- 类型/接口名 PascalCase + 业务含义
- 所有 ID、外键字段类型为 `string`
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
### 3.8 加密约定
```typescript
// crypto.ts
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
// 前端加密流程:
// 1. apiRequest 中如果 encryptBody=true:
// body → JSON.stringify → encrypt() → { data: "<密文>" }
// headers['X-Encrypted'] = 'true'
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
// decrypt(parsed.data) → JSON.parse → 返回
//
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
```
### 3.9 Mock 模式约定
`VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
```typescript
// api/index.ts 中每个 API 函数都检查 USE_MOCK
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
return api.get<Project[]>('/projects'); // ← 后走真实请求
}
```
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
---
## 四、跨项目约定(前后端协作)
### 4.1 字段名转换
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|--------------|-------------------|------|
| `snake_case` | `camelCase` | client.ts 自动转 |
| `created_at` | `createdAt` | 自动 |
| `user_id` | `userId` | 自动 |
**新增 API 时**:
- 后端 request schema 字段用 `snake_case`
- 前端 types/interface 字段用 `camelCase`
- **不需要**手动转,`client.ts``keysToCamel()` 递归转换所有 key
### 4.2 API 路径约定
```
/api/auth/login → 登录(公开)
/api/projects → 项目管理(需登录)
/api/admin/users → 用户管理(需管理员)
/admin/teams → 团队管理(需管理员,二级路由)
```
- `/api/` 前缀统一
- 管理接口路径含 `/admin/`
- 路径用小写 + 短横线(kebab-case
### 4.3 分页约定
后端返回:
```python
@router.get("", response_model=GenerationRecordPageListOut)
# { items: [...], total: 100, page: 1, page_size: 20 }
```
前端类型:
```typescript
export interface GenerationRecordPageListOut {
page: number;
pageSize: number;
total: number;
items: GenerationRecord[];
}
```
### 4.4 错误处理约定
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`
常见错误码:
- `401` — token 无效/过期 → 自动跳 `/login`
- `403` — 权限不足
- `404` — 资源不存在
- `402` — 积分不足(`InsufficientCreditsError`
- `400` — 参数错误
---
## 五、修改检查清单
### 新增后端接口时 ✅
- [ ]`app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`
- [ ]`app/services/` 中实现业务逻辑函数
- [ ]`app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`
- [ ] 确认 `__init__.py` 中注册了新 router
- [ ] 复杂操作记录 `log_operation()`
- [ ] 写入操作后正确 `flush()` + `commit()`
### 新增后端 Model 时 ✅
- [ ]`app/models/` 中新建或编辑 model 文件
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
- [ ] 主键用 `generate_id()`,外键加 `index=True`
- [ ]`app/models/__init__.py` 中 import
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
- [ ] 执行 `alembic upgrade head` 应用迁移
### 新增前端页面/功能时 ✅
- [ ]`types/index.ts` 中定义/补充类型
- [ ]`api/index.ts` 中添加 API 函数(含 mock 分支)
- [ ]`pages/` 中新建页面组件(`React.FC` + 默认导出)
- [ ]`App.tsx` 中注册路由
- [ ] 受保护路由包裹 `<ProtectedRoute>`
- [ ]`mock.ts` 中添加对应 mock 数据函数
---
## 六、常见反模式(应避免)
| ❌ 反模式 | ✅ 正确做法 |
|----------|-----------|
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
| Model 中有业务逻辑 | Model 只定义字段和关系 |
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
| 新增 Model 不加到 `__init__.py` | 立即 import |
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
| 密码明文存储 | bcrypt 哈希(通过 `hash_password` |
---
## 七、快速定位表
| 想修改什么 | 去哪里 |
|-----------|--------|
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
| 改业务规则 | `app/services/xxx.py` |
| 改枚举值 | `app/enums/xxx.py` |
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
| 改配置项 | `app/config.py` + `.env.example` |
| 改中间件行为 | `app/middleware/xxx.py` |
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py``task_routes` |
+357 -72
View File
@@ -5,11 +5,13 @@
```
video_item/
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
└── DEPLOYMENT.md # 本文档
```
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
---
## 一、环境要求
@@ -18,14 +20,13 @@ video_item/
|------|---------|------|
| Python | >= 3.10 | 推荐 3.12 |
| Node.js | >= 18 | 推荐 20+ |
| PostgreSQL | >= 14 | 推荐 16 |
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
| PostgreSQL | >= 14 | 推荐 16**必须** |
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
---
@@ -34,7 +35,7 @@ video_item/
### 1. 安装依赖
```bash
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
# CentOS/RHEL
sudo yum install -y ca-certificates
# Ubuntu/Debian
@@ -49,7 +50,10 @@ python -m venv .venv
# Linux/Mac
source .venv/bin/activate
# 安装基础依赖 + PostgreSQL 驱动
# 安装基础依赖
pip install -e .
# 安装 PostgreSQL 驱动(生产环境必须)
pip install -e ".[pg]"
# 如需 Redis 支持(限流、验证码、Celery)
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
# 如需 Celery 异步任务(ChatAPI 生成流水线)
pip install -e ".[pg,redis,celery]"
#安装阿里支付sdk
# 如需支付宝
pip install -e ".[pg,redis,celery,alipay]"
# 安装微信支付sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
#安装火山sdk
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
# 如需微信支付
pip install -e ".[pg,redis,celery,alipay,wxpay]"
# 如需火山引擎 SDK(短信等)
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
```
### 2. 配置环境变量
复制 `.env.example``.env`,修改以下关键配置:
复制 `.env.example``.env`,修改关键配置:
```bash
cp .env.example .env
@@ -81,13 +83,14 @@ cp .env.example .env
```ini
# ── 基础配置 ──
APP_NAME=VideoGen API
APP_VERSION=1.0.0
DEBUG=false
SECRET_KEY=改成一个随机的长字符串
SECRET_KEY=改成一个随机的长字符串JWT 签名密钥)
# ── 数据库 ──
# ── 数据库(必须) ──
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
# ── Redis(可选,留空则禁用限流验证码) ──
# ── Redis(可选,留空则禁用限流/验证码/Celery ──
REDIS_URL=redis://localhost:6379/0
# ── JWT ──
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
# ── LLM 提示词优化 ──
LLM_MOCK=false
LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY=
LLM_MODEL=gpt-4o
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
ENCRYPTION_KEY=你的32字节base64密钥
# ── 存储路径 ──
# ── 存储路径(本地存储) ──
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./storage/generate/videos
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
UPLOAD_LOCAL_PATH=./storage/uploads
# ── 跨域(生产环境务必限制域名) ──
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有 ──
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
# ── 回调基础地址 ──
BASE_URL=https://你的域名
# ── 验证码 ──
CAPTCHA_ENABLED=true
# ── 短信(火山引擎 SDKSMS_MOCK=false 时生效) ──
SMS_MOCK=true
VOLC_SMS_ACCESS_KEY_ID=
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
# ── 支付(PAYMENT_MOCK=false 时生效) ──
PAYMENT_MOCK=true
WECHAT_MCH_ID=
WECHAT_API_KEY=
ALIPAY_APP_ID=
ALIPAY_PRIVATE_KEY=
ALIPAY_PUBLIC_KEY=
ALIPAY_NOTIFY_URL=
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
CELERY_BROKER_URL=redis://localhost:6379/5
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
FFMPEG_BIN=/usr/bin/ffmpeg
```
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
### 3. 初始化数据库
```bash
# 创建 PostgreSQL 数据库
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
# 启动后端(首次启动自动建表 + 填充种子数据
# 启动后端(首次启动自动建表)
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```
首次启动会自动完成:
- 创建所有数据表(23 个 model)
- 创建管理员账号:`admin` / `123456`
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
首次启动时会**自动创建所有数据表**(41 个 model)。
**首次部署后务必修改默认密码。**
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
>
> **如果你需要种子数据,**有以下选择:
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
> 2. 手动通过 API 或数据库脚本创建管理员账号
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
### 4. 数据库迁移 (Alembic)
项目使用 Alembic 管理数据库结构变更`env.py` 已导入全部 20 个 model
项目使用 Alembic 管理数据库结构变更。
```bash
cd video-gen-api
@@ -179,17 +197,13 @@ python -m alembic history
python -m alembic downgrade -1
```
**部署流程** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
### 5. 生产运行
```bash
# 方式一:直接运行(推荐 4 workers
# 直接运行(推荐 4 workers
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
# 方式二:使用 systemd (Linux)
```
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`
@@ -220,18 +234,72 @@ sudo systemctl start videogen-api
### 6. Celery Worker(可选)
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
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处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,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]
@@ -239,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,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
@@ -247,25 +315,94 @@ 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`,一键启动完整环境:
项目提供 `Dockerfile``docker-compose.yml`
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
```bash
cd video-gen-api
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
# 使用前需修改 Dockerfile 第 6 行为:
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
docker compose up -d
```
启动的服务:
| 服务 | 端口 | 说明 |
|------|------|------|
| api | 8000 | FastAPI 应用(`--reload`开发模式) |
| worker | - | Celery Worker |
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
| worker | - | Celery Worker(消费所有队列) |
| postgres | 5432 | PostgreSQL 16 |
| redis | 6379 | Redis 7 |
@@ -276,7 +413,7 @@ server {
listen 80;
server_name api.yourdomain.com;
# 上传文件大小限制
# 上传文件大小限制(视频上传需要较大值)
client_max_body_size 100M;
location / {
@@ -300,6 +437,8 @@ server {
## 三、前台部署 (video-gen-app)
用户前台,面向最终用户。
### 1. 安装依赖 & 构建
```bash
@@ -310,8 +449,8 @@ npm install
# 配置 API 地址(创建 .env.production
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
@@ -319,7 +458,15 @@ npm run build
构建产物在 `dist/` 目录。
### 2. Nginx 配置
### 2. 前端环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -328,12 +475,12 @@ server {
root /opt/video-gen-app/dist;
index index.html;
# SPA 路由
# SPA 路由:所有页面请求回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
# 静态资源缓存(带 hash 的文件名可长期缓存)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
@@ -345,6 +492,8 @@ server {
## 四、后台管理部署 (video-gen-admin)
管理员后台,面向运营/管理人员。
### 1. 安装依赖 & 构建
```bash
@@ -356,13 +505,21 @@ npm install
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
# 如需前后端加密通信
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
# 构建
npm run build
```
### 2. Nginx 配置
### 2. 后台环境变量
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
### 3. Nginx 配置
```nginx
server {
@@ -456,6 +613,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 七、默认账号
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|------|--------|--------|------|------|
| 管理员 | admin | 13800000000 | 123456 | 10000 |
@@ -467,32 +626,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
## 八、架构说明
### 异步任务处理
### 后端启动时的后台任务
系统有两套异步任务机制
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务
| 机制 | 说明 | 依赖 |
|------|------|------|
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
| 任务 | 说明 |
|------|------|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
| `upload_queue` | 上传任务队列 |
| `material_consumption_queue` | 素材消耗队列 |
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
| `poll_pre_test_results` | 每分钟轮询前测结果 |
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
### 中间件栈(从外到内)
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
### 中间件栈(从外到内,即请求到达的顺序)
1. `RequestLoggingMiddleware` — 请求/响应日志记录
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
5. `CORSMiddleware` — 跨域
5. `CORSMiddleware` — 跨域expose `X-Encrypted` 响应头)
### 日志系统
### 前后端通信加密
| 日志类型 | 目录 | 控制方式 |
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
**生效条件:** 同时满足以下两个条件才启用加密:
- 后端 `.env``ENCRYPTION_KEY` 非空
- 前端 `.env``VITE_ENCRYPTION_KEY` 非空
- 运行在安全上下文(HTTPS 或 localhost
**加密范围:**
| 请求类型 | 请求体 | 响应体 |
|---------|--------|--------|
| GET(无 body) | 不加密(无内容) | **加密** |
| POST/PUT/DELETE(有 body | **加密** | **加密** |
**工作流:**
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
### 文件日志加密(独立机制)
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
| 日志类型 | 目录 | 加密方式 |
|---------|------|---------|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面
### 内部管理端点
| 端点 | 用途 |
|------|------|
| `/internal/` | 后端入口导航页 |
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
| `/internal/api-docs` | Swagger API 文档 |
| `/internal/api-redoc` | ReDoc API 文档 |
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
### 前端 Mock 模式
前端支持 Mock 数据模式,无需后端即可开发:
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
- 适用场景:纯前端开发、演示、无后端环境
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
### 存储路径
@@ -522,6 +733,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
│ ├── .env # 环境变量
│ ├── .venv/ # Python 虚拟环境
│ ├── app/ # 应用代码
│ │ ├── api/ # API 路由
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
│ │ │ └── admin/ # 管理端接口
│ │ ├── middleware/ # 中间件
│ │ ├── models/ # 数据模型 (41 个)
│ │ ├── services/ # 业务服务
│ │ ├── tasks/ # Celery 任务
│ │ ├── enums/ # 枚举定义
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移文件
│ ├── storage/
│ │ ├── generate/
@@ -565,4 +785,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
# 生成迁移文件
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
# 查看 Celery 活动任务
celery -A app.tasks.celery_app inspect active
```
---
## 十一、环境变量完整参考
### 后端 (video-gen-api/.env)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
| `STORAGE_TYPE` | 否 | local | 存储类型 |
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery |
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
### 前台 (video-gen-app/.env.production)
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
### 后台管理 (video-gen-admin/.env.production)
同前台。
---
## 十二、故障排查
| 现象 | 可能原因 | 解决方案 |
|------|---------|---------|
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
+3 -1
View File
@@ -1,5 +1,7 @@
# VITE_API_BASE=http://192.168.120.17:8000
#VITE_API_BASE=https://apiforeign.minzhongzc.com
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
#VITE_API_BASE=http://localhost:8000
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
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-D3ycepPx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
<script type="module" crossorigin src="/assets/index-CfFryG8z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
<div id="root"></div>
+251
View File
@@ -13,7 +13,9 @@
"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"
},
"devDependencies": {
@@ -1235,6 +1237,22 @@
}
}
},
"node_modules/adler-32": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.2.0.tgz",
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
"license": "Apache-2.0",
"dependencies": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
},
"bin": {
"adler32": "bin/adler32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/antd": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz",
@@ -1299,6 +1317,28 @@
"react-dom": ">=18.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cfb/node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1308,6 +1348,34 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.14.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.14.0.tgz",
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
"license": "Apache-2.0",
"dependencies": {
"commander": "~2.14.1",
"exit-on-epipe": "~1.0.1"
},
"bin": {
"codepage": "bin/codepage.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage/node_modules/commander": {
"version": "2.14.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.14.1.tgz",
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
"license": "MIT"
},
"node_modules/commander": {
"version": "2.17.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"license": "MIT"
},
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
@@ -1327,6 +1395,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1349,6 +1429,27 @@
"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",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
"license": "Apache-2.0",
"engines": {
"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",
@@ -1367,6 +1468,21 @@
}
}
},
"node_modules/fflate": {
"version": "0.3.11",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.3.11.tgz",
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
"license": "MIT"
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1658,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",
@@ -1677,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",
@@ -1726,6 +1867,47 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/printj": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
"license": "Apache-2.0",
"bin": {
"printj": "bin/printj.njs"
},
"engines": {
"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",
@@ -1753,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",
@@ -1863,6 +2060,18 @@
"node": ">=0.10.0"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
@@ -2001,6 +2210,48 @@
}
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx-js-style": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.2.0",
"cfb": "^1.1.4",
"codepage": "~1.14.0",
"commander": "~2.17.1",
"crc-32": "~1.2.0",
"exit-on-epipe": "~1.0.1",
"fflate": "^0.3.8",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
+2
View File
@@ -14,7 +14,9 @@
"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"
},
"devDependencies": {
+31
View File
@@ -3,9 +3,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AdminLayout from './pages/AdminLayout';
import AdminAuthoriz from './pages/AdminAuthoriz';
import AdminConsume from './pages/AdminConsume';
import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard';
import AdminPlatform from './pages/AdminPlatform';
import AdminUsers from './pages/AdminUsers';
import AdminTeams from './pages/AdminTeams';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
@@ -27,6 +31,18 @@ import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplication
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 { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -73,6 +89,7 @@ const App = () => {
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="teams" element={<AdminTeams />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
@@ -84,6 +101,11 @@ const App = () => {
<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="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
@@ -94,6 +116,15 @@ const App = () => {
<Route path="shot-replications" element={<AdminShotReplications />} />
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
<Route path="authoriza" element={<AdminAuthoriz />} />
<Route path="consume" element={<AdminConsume />} />
<Route path="platform" element={<AdminPlatform />} />
<Route path="contact-requests" element={<AdminContactRequests />} />
<Route path="home-materials" element={<AdminHomeMaterials />} />
<Route path="pretest-templates" element={<AdminPreTestTemplates />} />
<Route path="oauth-list" element={<AdminOAuthList />} />
<Route path="material-list" element={<AdminMaterialList />} />
<Route path="private-portrait-projects" element={<AdminPrivatePortraitProjects />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+28 -17
View File
@@ -1,12 +1,13 @@
/**
* Real API client for connecting to the FastAPI backend.
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
* Supports both JSON bodies and multipart FormData bodies.
*/
import { encrypt, decrypt } from './crypto';
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
interface RequestOptions {
method?: string;
@@ -24,6 +25,7 @@ function toCamel(s: string): string {
function keysToCamel(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(keysToCamel);
if (obj !== null && typeof obj === 'object') {
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj;
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
);
@@ -54,31 +56,35 @@ async function tryDecrypt(data: string): Promise<string | null> {
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (!isFormData) headers['Content-Type'] = 'application/json';
if (auth) {
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
}
let bodyStr: string | undefined;
let requestBody: BodyInit | undefined;
if (body !== undefined) {
const json = JSON.stringify(body);
if (encryptBody) {
headers['X-Encrypted'] = 'true';
bodyStr = JSON.stringify({ data: await encrypt(json) });
if (isFormData) {
requestBody = body as FormData;
} else {
bodyStr = json;
const json = JSON.stringify(body);
if (encryptBody) {
headers['X-Encrypted'] = 'true';
requestBody = JSON.stringify({ data: await encrypt(json) });
} else {
requestBody = json;
}
}
}
const res = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: bodyStr,
body: requestBody,
});
if (res.status === 204) return undefined as T;
@@ -96,7 +102,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
// If the response is encrypted, decrypt it
const hasEncryptedData = parsed && typeof parsed.data === 'string';
if (hasEncryptedData && encryptBody) {
if (hasEncryptedData && encryptBody && !isFormData) {
const decrypted = await tryDecrypt(parsed.data);
if (decrypted !== null) {
parsed = JSON.parse(decrypted);
@@ -107,10 +113,15 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
// Handle error responses
if (!res.ok) {
const msg = parsed?.detail || `请求失败 (${res.status})`;
const rawMsg = parsed?.detail || parsed?.message || `请求失败 (${res.status})`;
const msg = typeof rawMsg === 'string' ? rawMsg : JSON.stringify(rawMsg);
if (res.status === 401) {
clearToken();
window.location.href = '/login';
// Only redirect to login if not already on login page
const currentPath = window.location.pathname;
if (currentPath !== '/login') {
window.location.href = '/login';
}
}
throw new Error(msg);
}
@@ -121,7 +132,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
// Convenience methods
export const api = {
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
};
+19 -1
View File
@@ -1,6 +1,7 @@
/**
* AES-256-GCM encryption/decryption for API request/response.
* Uses Web Crypto API with a shared symmetric key.
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
*/
const ALGO = 'AES-GCM';
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
let cryptoKey: CryptoKey | null = null;
export function isCryptoAvailable(): boolean {
return typeof window !== 'undefined' &&
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined';
}
async function getCryptoKey(): Promise<CryptoKey> {
if (cryptoKey) return cryptoKey;
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
if (!isCryptoAvailable()) {
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
}
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
if (keyBytes.length !== 32) {
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
}
export async function encrypt(plaintext: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Encryption not available in non-secure context');
}
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext);
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
}
export async function decrypt(cipherB64: string): Promise<string> {
if (!isCryptoAvailable()) {
throw new Error('Decryption not available in non-secure context');
}
const key = await getCryptoKey();
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH);
const cipherBytes = combined.slice(IV_LENGTH);
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
return new TextDecoder().decode(plainBuf);
}
}
+778 -60
View File
@@ -10,8 +10,50 @@ import type {
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
} from '../types';
import type {
HomeMaterialAsset,
HomeMaterialAssetQueryParams,
HomeMaterialAssetStatusOut,
HomeMaterialAssetUpdatePayload,
HomeMaterialCategory,
HomeMaterialCategoryPayload,
HomeMaterialCategoryQueryParams,
HomeMaterialConfig,
HomeMaterialListResponse,
HomeMaterialRegeneratePayload,
HomeMaterialTextWatermarkConfig,
HomeMaterialTextWatermarkPayload,
HomeMaterialTextWatermarkPreviewRequest,
HomeMaterialTextWatermarkPreviewResponse,
HomeMaterialUploadAssetParams,
HomeMaterialUploadResult,
HomeMaterialWatermark,
HomeMaterialWatermarkPayload,
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> {
@@ -97,11 +139,72 @@ export async function getAdminStats(startDate?: string, endDate?: string): Promi
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
}
export async function getAdminUsers(page = 1, pageSize = 20, search?: string): Promise<{ items: AdminUser[]; total: number }> {
export async function getAdminUsers(
page = 1,
pageSize = 20,
search?: string,
userType?: string,
frontendUserKind?: string,
teamId?: string,
): Promise<{ items: AdminUser[]; total: number }> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
if (search) params.set('search', search);
if (userType) params.set('user_type', userType);
if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind);
if (teamId) params.set('team_id', teamId);
return api.get(`/admin/users?${params.toString()}`);
}
export async function updateFrontendUserKind(userId: string, frontendUserKind: 'internal' | 'external'): Promise<AdminUser> {
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
}
export async function updateUserTeam(userId: string, teamId: string | null): Promise<AdminUser> {
return api.put(`/admin/users/${userId}/team`, { team_id: teamId });
}
export async function getAdminTeams(filters?: AdminTeamQueryParams): Promise<AdminTeamListResponse> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'keyword', filters?.keyword);
setMaybe(params, 'status', filters?.status);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/teams${q}`);
}
export async function getTeamOptions(includeDisabled = true): Promise<AdminTeamOption[]> {
return api.get(`/admin/teams/options?include_disabled=${includeDisabled ? 'true' : 'false'}`);
}
export async function saveAdminTeam(team: Partial<AdminTeamPayload> & { id?: string }): Promise<AdminTeam> {
const payload = {
name: team.name,
code: team.code || null,
description: team.description || null,
status: team.status || 'active',
sort_order: team.sort_order ?? 0,
};
if (team.id) return api.put(`/admin/teams/${team.id}`, payload);
return api.post('/admin/teams', payload);
}
export async function deleteAdminTeam(id: string): Promise<void> {
await api.delete(`/admin/teams/${id}`);
}
export async function setTeamManager(teamId: string, userId: string | null): Promise<AdminTeam> {
return api.put(`/admin/teams/${teamId}/manager`, { user_id: userId });
}
export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize = 50): Promise<{ items: AdminUser[]; total: number }> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
params.set('user_type', 'frontend');
params.set('team_id', teamId);
return api.get(`/admin/users?${params.toString()}`);
}
@@ -134,54 +237,105 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value });
}
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('config_key', configKey);
const token = localStorage.getItem('auth_token');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
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');
}
export async function saveGlobalResourceCapacity(payload: ResourceCapacityConfigPayload): Promise<ResourceCapacityConfigOut> {
return api.put('/admin/resource-capacity/global', {
enabled: payload.enabled,
limit_value: payload.limitValue ?? null,
limit_unit: payload.limitUnit ?? null,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '上传失败');
}
export async function getUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
return api.get(`/admin/users/${userId}/resource-capacity`);
}
export async function saveUserResourceCapacity(userId: string, payload: ResourceCapacityConfigPayload): Promise<AdminUserResourceCapacityOut> {
return api.put(`/admin/users/${userId}/resource-capacity`, {
enabled: payload.enabled,
limit_value: payload.limitValue ?? null,
limit_unit: payload.limitUnit ?? null,
});
}
export async function deleteUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
return api.delete(`/admin/users/${userId}/resource-capacity`);
}
export async function uploadAdminFile(
file: File,
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
): Promise<AdminUploadFileResult> {
const form = new FormData();
form.append('file', file);
form.append('scene', options.scene);
form.append('resource_type', options.resourceType);
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
form.append('duration_seconds', String(options.durationSeconds));
}
return res.json();
return api.post<AdminUploadFileResult>('/admin/uploads/files', form);
}
export async function uploadPdf(file: File, _configKey?: string): Promise<{ url: string }> {
const res = await uploadAdminFile(file, { scene: 'system_pdf', resourceType: 'pdf' });
return { url: res.url };
}
export async function uploadLogo(file: File): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
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 baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const res = await fetch(`${baseUrl}/api/admin/upload-logo`, {
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: formData,
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '上传失败');
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || '上传失败');
}
return res.json();
}
export async function getCreditRecords(filters?: {
user_id?: string;
user_name?: string;
type?: string;
start_date?: string;
end_date?: string;
}): Promise<any> {
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
}
export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): Promise<AdminCreditRecordListResponse> {
const params = new URLSearchParams();
if (filters?.user_id) params.set('user_id', filters.user_id);
if (filters?.user_name) params.set('user_name', filters.user_name);
if (filters?.type) params.set('type', filters.type);
if (filters?.start_date) params.set('start_date', filters.start_date);
if (filters?.end_date) params.set('end_date', filters.end_date);
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'user_id', filters?.userId);
setMaybe(params, 'user_name', filters?.userName);
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
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);
setMaybe(params, 'start_date', filters?.startDate);
setMaybe(params, 'end_date', filters?.endDate);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
@@ -199,8 +353,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> {
@@ -212,8 +367,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> {
@@ -238,6 +394,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');
}
@@ -271,10 +552,11 @@ export async function getPaymentStats(params?: {
return api.get(url);
}
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; 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?.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));
@@ -322,7 +604,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
// ── User Creation ───────────────────────────────────────
export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise<any> {
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; is_admin?: boolean; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_asset_limit?: number }): Promise<any> {
return api.post('/admin/users', data);
}
@@ -330,6 +612,10 @@ export async function updateUserMenus(userId: string, allowedMenus: string[] | n
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
}
export async function updateUserAdminStatus(userId: string, isAdmin: boolean): Promise<void> {
await api.put(`/admin/users/${userId}/admin-status`, { is_admin: isAdmin });
}
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
}
@@ -359,8 +645,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}`);
}
@@ -375,7 +669,7 @@ export async function createOauthApp(data: {
app_id: string;
secret: string;
open_type: number;
count?: number;
max_count?: number;
auth_url?: string;
company?: string;
}): Promise<any> {
@@ -401,31 +695,87 @@ export async function deleteOauthApp(id: string): Promise<void> {
await api.get(`/admin/user-oauth-apps/delete/${id}`);
}
// ── Open Type ───────────────────────────────────────────────
export async function getOpenTypeList(params?: {
page?: number;
page_size?: number;
type_name?: string;
open_type?: number;
}): Promise<{
pagination: any;
data: never[];
}> {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.page_size) q.set('page_size', String(params.page_size));
if (params?.type_name) q.set('type_name', params.type_name);
if (params?.open_type) q.set('open_type', String(params.open_type));
const qs = q.toString();
return api.get(`/open-type/list${qs ? `?${qs}` : ''}`);
}
export async function getOpenType(id: string): Promise<any> {
return api.get(`/open-type/select/${id}`);
}
export async function createOpenType(data: {
open_type: number;
description: string;
type_name: string;
thumb?: string;
}): Promise<any> {
return api.post('/open-type/create', data);
}
export async function updateOpenType(id: string, data: {
open_type?: number;
description?: string;
type_name?: string;
thumb?: string;
}): Promise<any> {
return api.put(`/open-type/update/${id}`, data);
}
export async function deleteOpenType(id: string): Promise<void> {
await api.delete(`/open-type/delete/${id}`);
}
export interface OpenTypeItem {
id: string;
openType: number;
typeName: string;
description: string;
thumb?: string;
}
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
return api.get('/open-type/open_type_all');
}
// ── Generation Records (Admin) ─────────────────────────────
export async function getAdminGenerationRecords(params?: {
userId?: string; status?: string; page?: number; pageSize?: number;
userId?: string;
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
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?.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) ─────────────────────────────
@@ -441,6 +791,9 @@ export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryPa
if (params?.pageSize) q.set('page_size', String(params.pageSize));
if (params?.userId) q.set('user_id', params.userId);
if (params?.userName) q.set('user_name', params.userName);
if (params?.engineId) q.set('engine_id', params.engineId);
if (params?.createdStart) q.set('created_start', params.createdStart);
if (params?.createdEnd) q.set('created_end', params.createdEnd);
const qs = q.toString();
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
}
@@ -468,8 +821,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> {
@@ -508,6 +866,366 @@ 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}`);
}
// ── Video Prompt Schema Config (Admin) ─────────────────────
export async function getVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
return api.get<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config');
}
export async function saveVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
return api.put<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config', payload);
}
export async function resetVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/reset-default');
}
export async function exportVideoPromptSchemaConfig(): Promise<VideoPromptSchemaExportOut> {
return api.get<VideoPromptSchemaExportOut>('/admin/video-prompt-schema-config/export');
}
export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/import', payload);
}
export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise<VideoPromptSchemaPreviewOut> {
return api.post<VideoPromptSchemaPreviewOut>('/admin/video-prompt-schema-config/preview', payload);
}
// 获取授权链接
export interface RequestOAuthParams {
open_type: number;
}
export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
return api.post(`/user-oauth/request_oauth`, params);
}
// 授权列表
export interface OAuthListParams {
account_userid?: string;
open_type?: number;
account_id?: string;
page?: number;
page_size?: number;
}
export async function getOAuthList(params: OAuthListParams): Promise<any> {
const query = new URLSearchParams();
if (params.account_userid) query.set('account_userid', params.account_userid);
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
if (params.account_id) query.set('account_id', params.account_id);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/user-oauth/oauth_list?${query.toString()}`);
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
return { url: res.url, filename: res.originalFileName || res.fileName };
}
// 自定义表头字段
export async function getFields(): Promise<any> {
return api.get('/material-consumption/fields');
}
// 查询素材消耗列表
export interface MaterialConsumpParams {
advertiser_id?: string;
user_id?: string;
consume_date?: [string, string];
page?: number;
page_size?: number;
}
export async function getMaterialConsumpList(params: MaterialConsumpParams): Promise<any> {
const query = new URLSearchParams();
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
if (params.user_id) query.set('user_id', params.user_id);
if (params.consume_date !== undefined) query.set('consume_date', params.consume_date[0] + ',' + params.consume_date[1]);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-consumption/admin/list?${query.toString()}`);
}
// ── 首页素材行业装修 ──────────────────────────────────────
export async function getHomeMaterialConfig(): Promise<HomeMaterialConfig> {
return api.get<HomeMaterialConfig>('/admin/home-material/config');
}
export async function saveHomeMaterialConfig(payload: HomeMaterialConfig): Promise<HomeMaterialConfig> {
return api.put<HomeMaterialConfig>('/admin/home-material/config', {
enabled: payload.enabled,
title: payload.title,
subtitle: payload.subtitle,
show_original_in_admin: payload.showOriginalInAdmin,
});
}
export async function getHomeMaterialCategories(filters?: HomeMaterialCategoryQueryParams): Promise<HomeMaterialListResponse<HomeMaterialCategory>> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'keyword', filters?.keyword);
setMaybe(params, 'is_active', filters?.isActive);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/home-material/categories${q}`);
}
export async function saveHomeMaterialCategory(payload: HomeMaterialCategoryPayload & { id?: string }): Promise<HomeMaterialCategory> {
const body = {
name: payload.name,
key: payload.key,
description: payload.description || null,
icon: payload.icon || null,
is_active: payload.is_active,
sort_order: payload.sort_order,
};
if (payload.id) return api.put(`/admin/home-material/categories/${payload.id}`, body);
return api.post('/admin/home-material/categories', body);
}
export async function deleteHomeMaterialCategory(id: string): Promise<void> {
await api.delete(`/admin/home-material/categories/${id}`);
}
export async function getHomeMaterialWatermarks(filters?: HomeMaterialWatermarkQueryParams): Promise<HomeMaterialListResponse<HomeMaterialWatermark>> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'is_active', filters?.isActive);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/home-material/watermarks${q}`);
}
export async function uploadHomeMaterialWatermark(file: File, name?: string, isDefault = false): Promise<HomeMaterialWatermark> {
const form = new FormData();
form.append('file', file);
if (name) form.append('name', name);
form.append('is_default', String(isDefault));
return api.post<HomeMaterialWatermark>('/admin/home-material/watermarks', form);
}
export async function updateHomeMaterialWatermark(id: string, payload: HomeMaterialWatermarkPayload): Promise<HomeMaterialWatermark> {
return api.put(`/admin/home-material/watermarks/${id}`, payload);
}
export async function deleteHomeMaterialWatermark(id: string): Promise<void> {
await api.delete(`/admin/home-material/watermarks/${id}`);
}
export async function getHomeMaterialAssets(filters?: HomeMaterialAssetQueryParams): Promise<HomeMaterialListResponse<HomeMaterialAsset>> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'category_id', filters?.categoryId);
setMaybe(params, 'media_type', filters?.mediaType);
setMaybe(params, 'status', filters?.status);
setMaybe(params, 'is_active', filters?.isActive);
setMaybe(params, 'keyword', filters?.keyword);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/home-material/assets${q}`);
}
export async function getHomeMaterialAsset(id: string): Promise<HomeMaterialAsset> {
return api.get(`/admin/home-material/assets/${id}`);
}
export async function getHomeMaterialAssetStatus(id: string): Promise<HomeMaterialAssetStatusOut> {
return api.get(`/admin/home-material/assets/${id}/status`);
}
function toHomeMaterialTextWatermarkPayload(config?: HomeMaterialTextWatermarkConfig | null): HomeMaterialTextWatermarkPayload | null {
if (!config) return null;
return {
text: config.text,
opacity_level: config.opacityLevel,
font_size_px: config.fontSizePx,
color: config.color,
rotate_deg: config.rotateDeg,
gap_x: config.gapX,
gap_y: config.gapY,
staggered: config.staggered,
};
}
export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetParams): Promise<HomeMaterialUploadResult> {
const form = new FormData();
form.append('category_id', payload.categoryId);
form.append('file', payload.file);
form.append('media_type', payload.mediaType);
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
if (payload.generationPrompt && payload.generationPrompt.trim()) form.append('generation_prompt', payload.generationPrompt.trim());
if (payload.mediaReferences && payload.mediaReferences.length > 0) form.append('media_references_json', JSON.stringify(payload.mediaReferences));
form.append('watermark_type', payload.watermarkType);
if (payload.watermarkType === 'image') {
if (payload.watermarkId) form.append('watermark_id', payload.watermarkId);
if (payload.watermarkFile) form.append('watermark_file', payload.watermarkFile);
form.append('opacity_level', String(payload.opacityLevel));
form.append('position', payload.position);
if (payload.customXRatio !== undefined && payload.customXRatio !== null) form.append('custom_x_ratio', String(payload.customXRatio));
if (payload.customYRatio !== undefined && payload.customYRatio !== null) form.append('custom_y_ratio', String(payload.customYRatio));
form.append('size_mode', payload.sizeMode);
if (payload.widthRatio !== undefined && payload.widthRatio !== null) form.append('width_ratio', String(payload.widthRatio));
if (payload.widthPx !== undefined && payload.widthPx !== null) form.append('width_px', String(payload.widthPx));
form.append('margin_x', String(payload.marginX));
form.append('margin_y', String(payload.marginY));
} else {
const text = toHomeMaterialTextWatermarkPayload(payload.textWatermark);
if (text) {
form.append('text_watermark_text', text.text);
form.append('text_watermark_opacity_level', String(text.opacity_level));
form.append('text_watermark_font_size_px', String(text.font_size_px));
form.append('text_watermark_color', text.color);
form.append('text_watermark_rotate_deg', String(text.rotate_deg));
form.append('text_watermark_gap_x', String(text.gap_x));
form.append('text_watermark_gap_y', String(text.gap_y));
form.append('text_watermark_staggered', String(text.staggered));
}
}
form.append('is_active', String(payload.isActive));
form.append('sort_order', String(payload.sortOrder));
form.append('wait', String(!!payload.wait));
form.append('wait_timeout_seconds', String(payload.waitTimeoutSeconds ?? 30));
return api.post<HomeMaterialUploadResult>('/admin/home-material/assets', form);
}
export async function updateHomeMaterialAsset(id: string, payload: HomeMaterialAssetUpdatePayload): Promise<HomeMaterialAsset> {
return api.put(`/admin/home-material/assets/${id}`, payload);
}
export async function previewHomeMaterialTextWatermark(payload: HomeMaterialTextWatermarkPreviewRequest): Promise<HomeMaterialTextWatermarkPreviewResponse> {
return api.post<HomeMaterialTextWatermarkPreviewResponse>('/admin/home-material/text-watermark-preview', payload);
}
export async function regenerateHomeMaterialWatermark(id: string, payload: HomeMaterialRegeneratePayload): Promise<HomeMaterialUploadResult> {
return api.post(`/admin/home-material/assets/${id}/regenerate-watermark`, payload);
}
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
await api.delete(`/admin/home-material/assets/${id}`);
}
export interface OAuthAccountParams {
id?: string;
phone?: string;
account_id?: string;
account_userid?: string;
created_at?: [string, string];
appid?: string;
open_type?: number;
page?: number;
page_size?: number;
}
export async function getOAuthAccountList(params: OAuthAccountParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.account_id) query.set('account_id', params.account_id);
if (params.account_userid) query.set('account_userid', params.account_userid);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.appid) query.set('appid', params.appid);
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/oauth-list?${query.toString()}`);
}
export interface MaterialListParams {
id?: string;
phone?: string;
resource_type?: string;
advertiser_id?: string;
material_id?: string;
upload_id?: string;
created_at?: [string, string];
status?: string;
page?: number;
page_size?: number;
}
export async function getMaterialList(params: MaterialListParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.resource_type) query.set('resource_type', params.resource_type);
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
if (params.material_id) query.set('material_id', params.material_id);
if (params.upload_id) query.set('upload_id', params.upload_id);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.status) query.set('status', params.status);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/material-list?${query.toString()}`);
}
export interface PreTestTemplateListParams {
id?: string;
phone?: string;
created_at?: [string, string];
page?: number;
page_size?: number;
}
export async function getPreTestTemplateList(params: PreTestTemplateListParams): Promise<any> {
const query = new URLSearchParams();
if (params.id) query.set('id', params.id);
if (params.phone) query.set('phone', params.phone);
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
return api.get(`/material-admin/pre-test-template-list?${query.toString()}`);
}
export interface GetAreaParams {
level?: string;
parent_code?: string;
}
export async function getArea(params?: GetAreaParams): Promise<any> {
const query = new URLSearchParams();
if (params?.level) query.set('level', params.level);
if (params?.parent_code) query.set('parent_code', params.parent_code);
return api.get(`/pre-test-template/getArea?${query.toString()}`);
}
// ── Private Portrait Admin ────────────────────────────────
export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId);
if (params.libraryType) query.set('library_type', params.libraryType);
if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
}
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.userId) query.set('user_id', params.userId);
if (params.projectId) query.set('project_id', params.projectId);
if (params.libraryType) query.set('library_type', params.libraryType);
if (params.assetType) query.set('asset_type', params.assetType);
if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
}
export async function adminGetPrivatePortraitConfig(userId: string): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`);
}
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 });
}
@@ -0,0 +1,180 @@
import React, { useState } from 'react';
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
interface PreResultData {
video_id: string;
advertiser_id: number;
material_id: string;
is_ad_high_quality_material: string;
is_ecp_high_quality_material: string;
is_inefficient_material: string;
is_first_publish_material: string;
is_local_high_quality_material: string;
not_ad_high_quality_reason: string[] | null;
not_ecp_high_quality_reason: string[] | null;
}
interface PreResultDisplayProps {
preResult: string;
}
const fieldConfig = [
{ key: 'is_ad_high_quality_material', label: 'AD优质', noLabel: 'AD非优质素材', unknownLabel: 'AD未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_ecp_high_quality_material', label: '千川优质', noLabel: '千川非优质素材', unknownLabel: '千川未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_local_high_quality_material', label: '本地推优质', noLabel: '本地推非优质', unknownLabel: '本地推未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
{ key: 'is_inefficient_material', label: '低效', noLabel: '非低效', unknownLabel: '是否低效未知', yesColor: 'red', noColor: 'green', unknownColor: 'default' },
{ key: 'is_first_publish_material', label: '首发', noLabel: '非首发', unknownLabel: '是否首发未知', yesColor: 'blue', noColor: 'default', unknownColor: 'default' },
];
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
const [hasError, setHasError] = useState(false);
React.useEffect(() => {
if (!preResult) {
setParsedData(null);
setHasError(false);
return;
}
try {
const data = JSON.parse(preResult);
setParsedData(data);
setHasError(false);
} catch {
setParsedData(null);
setHasError(true);
}
}, [preResult]);
if (!preResult || hasError || !parsedData) {
return <span style={{ color: '#64748b' }}>-</span>;
}
const getIndicatorDisplay = (key: string) => {
const config = fieldConfig.find(c => c.key === key);
if (!config) return null;
const value = parsedData[key as keyof PreResultData];
const isYes = value === 'YES';
const isNo = value === 'NO';
const isUnknown = value === 'UNKNOWN';
if (isUnknown) return null;
let displayLabel, displayColor;
if (isYes) {
displayLabel = config.label;
displayColor = config.yesColor;
} else if (isNo) {
displayLabel = config.noLabel;
displayColor = config.noColor;
}
return {
tag: (
<Tag
key={key}
color={displayColor}
style={{ marginRight: 6, fontSize: 12, borderRadius: 4 }}
>
{displayLabel}
</Tag>
),
detail: {
label: config.label.replace('优质', '优质素材'),
value: displayLabel,
color: displayColor,
},
};
};
const content = (
<div style={{ maxWidth: 500, padding: '8px 0' }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, color: '#1e293b' }}></Typography.Text>
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="视频ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.video_id}</Descriptions.Item>
<Descriptions.Item label="广告主ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.advertiser_id}</Descriptions.Item>
<Descriptions.Item label="素材ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.material_id}</Descriptions.Item>
</Descriptions>
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}></Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
{fieldConfig.map(config => {
const value = parsedData[config.key as keyof PreResultData];
const isYes = value === 'YES';
const isNo = value === 'NO';
const isUnknown = value === 'UNKNOWN';
if (isUnknown) return null;
let displayLabel, displayColor;
if (isYes) {
displayLabel = config.label;
displayColor = config.yesColor;
} else if (isNo) {
displayLabel = config.noLabel;
displayColor = config.noColor;
}
return (
<Tag
key={config.key}
color={displayColor}
style={{ fontSize: 12, borderRadius: 4 }}
>
{displayLabel}
</Tag>
);
})}
</div>
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
<div style={{ marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
AD非优质原因
</Typography.Text>
<List
dataSource={parsedData.not_ad_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
{parsedData.not_ecp_high_quality_reason && parsedData.not_ecp_high_quality_reason.length > 0 && (
<div>
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
</Typography.Text>
<List
dataSource={parsedData.not_ecp_high_quality_reason}
renderItem={(item, index) => (
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
{index + 1}. {item}
</List.Item>
)}
size="small"
/>
</div>
)}
</div>
);
return (
<Popover
content={content}
title={null}
trigger="hover"
placement="topLeft"
overlayStyle={{ borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 4}}>
{fieldConfig.map(config => {
const display = getIndicatorDisplay(config.key);
return display?.tag;
})}
</div>
</Popover>
);
};
export default PreResultDisplay;
@@ -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;
+353
View File
@@ -0,0 +1,353 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Card, Space, Table, Tag, Modal, Select, App, Input, Typography } from 'antd';
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
const PORT_TYPE_MAP: Record<number, string> = {
1: '巨量',
2: '磁力',
3: '巨量星图',
4: '服务单',
5: '腾讯',
};
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
interface AuthorizationData {
id: string;
status: string;
description: string;
account_userid?: string;
open_type?: number;
account_id?: string;
}
const AuthorizationPage: React.FC = () => {
const { message } = App.useApp();
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [selectedOpenType, setSelectedOpenType] = useState<number | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchParams, setSearchParams] = useState({
account_userid: '',
open_type: undefined as number | undefined,
account_id: '',
});
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
useEffect(() => {
loadOAuthList();
loadOpenTypeList();
}, []);
const loadOpenTypeList = async () => {
try {
const res = await getOpenTypeAll();
const data = res.data || [];
const map: Record<number, string> = {};
const options: { value: number; label: string }[] = [];
data.forEach(item => {
map[item.openType] = item.typeName;
options.push({ value: item.openType, label: item.typeName });
});
setOpenTypeMap(map);
setOpenTypeOptions(options);
} catch (error) {
console.error('加载开户方式列表失败:', error);
}
};
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
setListLoading(true);
try {
const response = await getOAuthList({
page,
page_size: pageSize,
account_userid: params.account_userid || undefined,
open_type: params.open_type,
account_id: params.account_id || undefined,
});
if (response) {
if (response.data) {
setAuthorizations(response.data.data || response.data);
}
if (response.pagination) {
setTotal(response.pagination.total || 0);
setCurrentPage(response.pagination.page || 1);
setPageSize(response.pagination.pageSize || 10);
}
} else {
setAuthorizations(response || []);
}
} catch (error) {
message.error('获取授权列表失败');
} finally {
setListLoading(false);
}
};
const handleAuthorize = () => {
setShowModal(true);
};
const handleConfirm = async () => {
if (!selectedOpenType) {
message.warning('请选择开户方式');
return;
}
setLoading(true);
try {
const response = await requestOAuth({ open_type: selectedOpenType });
if (response.authUrl) {
window.open(response.authUrl, '_blank');
} else {
message.error('获取授权链接失败');
}
} catch (error) {
message.error('请求授权失败');
} finally {
setLoading(false);
setShowModal(false);
setSelectedOpenType(undefined);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 160,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权账户用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
{
title: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
},
{
title: '是否敏感物料授权',
dataIndex: 'materialAuthStatus',
key: 'materialAuthStatus',
width: 100,
render: (text: boolean) => (
<Tag color={text ? 'green' : 'default'}>
{text ? '是' : '否'}
</Tag>
),
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
},
{
title: '平台端口',
dataIndex: 'portType',
key: 'portType',
ellipsis: true,
render: (text: number) => <span style={{ color: '#1e293b' }}>{PORT_TYPE_MAP[text] || text}</span>,
},
{
title: '用户id',
dataIndex: 'userId',
key: 'userId',
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 160,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
];
const tableData = authorizations.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<LockOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button
type="primary"
size="medium"
icon={<PlusOutlined />}
onClick={handleAuthorize}
loading={loading}
style={{
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
}}
>
</Button>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="账号用户ID"
value={searchParams.account_userid}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_userid: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Select
placeholder="开户方式"
value={searchParams.open_type}
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
style={{ width: 140 }}
options={openTypeOptions}
/>
<Input
placeholder="账号ID"
value={searchParams.account_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_id: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Button
type="primary"
size="medium"
onClick={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
>
</Button>
<Button
size="medium"
onClick={() => {
setSearchParams({ account_userid: '', open_type: undefined, account_id: '' });
setCurrentPage(1);
loadOAuthList(1, pageSize);
}}
>
</Button>
</div>
<Table
dataSource={tableData}
columns={columns}
loading={listLoading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize: pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
loadOAuthList(page, size);
},
size: 'small',
}}
/>
</Card>
<Modal
title="选择开户方式"
open={showModal}
onOk={handleConfirm}
onCancel={() => {
setShowModal(false);
setSelectedOpenType(undefined);
}}
okText="确认授权"
cancelText="取消"
confirmLoading={loading}
>
<Select
placeholder="请选择开户方式"
value={selectedOpenType}
onChange={(value) => setSelectedOpenType(value)}
style={{ width: '100%' }}
options={openTypeOptions}
/>
</Modal>
</div>
);
};
export default AuthorizationPage;
+337
View File
@@ -0,0 +1,337 @@
import React, { useState, useEffect } from 'react';
import { Card, Space, Table, Button, Typography, Modal, Checkbox, Input, message, DatePicker } from 'antd';
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getFields, getMaterialConsumpList } from '../api';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
interface ConsumptionRecord {
id: string;
advertiser_id?: string;
user_id?: string;
consume_date?: string;
[key: string]: any;
}
const ConsumePage: React.FC = () => {
const navigate = useNavigate();
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [columnsData, setColumnsData] = useState<{ name: string; description: string }[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [advertiserId, setAdvertiserId] = useState('');
const [userId, setUserId] = useState('');
const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
useEffect(() => {
loadColumns();
loadData();
}, [page, pageSize, consumeDateRange]);
const loadData = async () => {
setLoading(true);
try {
const res = await getMaterialConsumpList({
advertiser_id: advertiserId || undefined,
user_id: userId || undefined,
consume_date: consumeDateRange,
page,
page_size: pageSize,
});
setConsumptionRecords(res.data || []);
setTotal(res.pagination?.total || 0);
} catch (e: any) {
message.error('加载数据失败');
} finally {
setLoading(false);
}
};
const loadColumns = async () => {
try {
const res = await getFields();
const fields = res.data || [];
const formattedFields = fields.map((item: { field: string; description: string }) => ({
name: toCamelCase(item.field),
description: item.description,
}));
setColumnsData(formattedFields);
const saved = localStorage.getItem('consumeColumns');
if (saved) {
setSelectedColumns(JSON.parse(saved));
} else {
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
}
} catch (e: any) {
message.error('加载表头字段失败');
}
};
const tableData = consumptionRecords.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
const handleBack = () => {
navigate('/authorization');
};
const handleSearch = () => {
setPage(1);
loadData();
};
const handleReset = () => {
setAdvertiserId('');
setUserId('');
setConsumeDateRange(undefined);
setPage(1);
loadData();
};
const dynamicColumns = selectedColumns
.map(colName => columnsData.find(col => col.name === colName))
.filter((col): col is { name: string; description: string } => !!col)
.map(col => ({
title: col.description,
dataIndex: col.name,
key: col.name,
ellipsis: true,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
{/*<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}></Button>
</Space>
</div>
<div style={{ marginBottom: 16 }}>
<Space wrap>
<Input
placeholder="广告主ID"
value={advertiserId}
onChange={(e) => setAdvertiserId(e.target.value)}
style={{ width: 200 }}
/>
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
style={{ width: 200 }}
/>
<RangePicker
value={consumeDateRange ? [dayjs(consumeDateRange[0]), dayjs(consumeDateRange[1])] : undefined}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setConsumeDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else {
setConsumeDateRange(undefined);
}
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}></Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}></Button>
</Space>
</div>
<Table
dataSource={tableData}
columns={dynamicColumns}
loading={loading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
size: 'small',
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
<Modal
title="自定义表头"
open={showModal}
onOk={() => {
localStorage.setItem('consumeColumns', JSON.stringify(selectedColumns));
message.success('表头设置已保存');
setShowModal(false);
}}
onCancel={() => {
setShowModal(false);
setSearchText('');
}}
okText="确定"
cancelText="取消"
width={800}
>
<div style={{ display: 'flex', gap: 20, height: 400 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Input
placeholder="搜索指标..."
value={searchText}
allowClear={true}
onChange={(e) => setSearchText(e.target.value)}
style={{ marginBottom: 8 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, marginBottom: 12, display: 'block' }}>
</Typography.Text>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}></Typography.Text>
</div>
<Checkbox
checked={selectedColumns.length === columnsData.length && columnsData.length > 0}
indeterminate={selectedColumns.length > 0 && selectedColumns.length < columnsData.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(columnsData.map(item => item.name));
} else {
setSelectedColumns([]);
}
}}
style={{ marginBottom: 12 }}
>
</Checkbox>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{columnsData
.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase()) ||
item.name.toLowerCase().includes(searchText.toLowerCase())
)
.map(item => (
<Checkbox
key={item.name}
checked={selectedColumns.includes(item.name)}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(prev => [...prev, item.name]);
} else {
setSelectedColumns(prev => prev.filter(col => col !== item.name));
}
}}
>
{item.description}
</Checkbox>
))}
</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>
({selectedColumns.length})
</Typography.Text>
<Space>
<Button
size="small"
onClick={() => setSelectedColumns([])}
disabled={selectedColumns.length === 0}
>
</Button>
<Button
size="small"
onClick={async () => {
try {
const res = await getFields();
const fields = res.data || [];
const formattedFields = fields.map((item: { field: string; description: string }) => ({
name: toCamelCase(item.field),
description: item.description,
}));
setColumnsData(formattedFields);
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
localStorage.removeItem('consumeColumns');
message.success('表头字段已更新');
} catch (e: any) {
message.error('更新表头字段失败');
}
}}
>
</Button>
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
{selectedColumns.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 14, textAlign: 'center', display: 'block', padding: '40px 0' }}>
</Typography.Text>
) : (
<div>
{selectedColumns.map((colName, index) => {
const col = columnsData.find(c => c.name === colName);
return (
<div
key={colName}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderBottom: '1px solid #f0f0f5',
cursor: 'move',
userSelect: 'none',
}}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('index', String(index));
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
const fromIndex = parseInt(e.dataTransfer.getData('index'));
const toIndex = index;
if (fromIndex !== toIndex) {
const newSelected = [...selectedColumns];
const [removed] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, removed);
setSelectedColumns(newSelected);
}
}}
>
<Typography.Text>{col?.description || colName}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{index + 1}
</Typography.Text>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</Modal>
</div>
);
};
export default ConsumePage;
@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty } from 'antd';
import { CheckOutlined, DeleteOutlined, EyeOutlined, FilterOutlined, MessageOutlined } from '@ant-design/icons';
import { useAdminStore } from '../store';
import { api } from '../api/client';
import { formatDate } from '../utils/formatDate';
interface ContactRequest {
id: string;
userId: string;
phone: string;
companyName: string;
industry: string;
name: string;
message: string | null;
isHandled: boolean;
createdAt: string;
}
const AdminContactRequests: React.FC = () => {
const [data, setData] = useState<ContactRequest[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isHandledFilter, setIsHandledFilter] = useState<boolean | null>(null);
const [selectedItem, setSelectedItem] = useState<ContactRequest | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const { user } = useAdminStore();
const fetchData = async () => {
// if (!user?.isAdmin) return;
setLoading(true);
try {
const query = new URLSearchParams();
query.set('page', String(page));
query.set('page_size', String(pageSize));
if (isHandledFilter !== null) {
query.set('is_handled', String(isHandledFilter));
}
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/admin/contact-requests?${query.toString()}`);
setData(res.items);
setTotal(res.total);
} catch (err: any) {
message.error(err?.message || '获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [page, pageSize, isHandledFilter]);
const handleMarkHandled = async (id: string) => {
try {
await api.put(`/admin/contact-requests/${id}/handle`);
message.success('已标记为处理');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/contact-requests/${id}`);
message.success('已删除');
fetchData();
} catch (err: any) {
message.error(err?.message || '删除失败');
}
};
const handleViewDetail = (item: ContactRequest) => {
setSelectedItem(item);
setDetailModalOpen(true);
};
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 100,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 120,
},
{
title: '公司名称',
dataIndex: 'companyName',
key: 'companyName',
width: 150,
ellipsis: true,
},
{
title: '行业',
dataIndex: 'industry',
key: 'industry',
width: 120,
},
{
title: '状态',
dataIndex: 'isHandled',
key: 'isHandled',
width: 80,
render: (isHandled: boolean) => (
<Tag color={isHandled ? 'green' : 'orange'}>
{isHandled ? '已处理' : '待处理'}
</Tag>
),
},
{
title: '提交时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date: string) => formatDate(date),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: unknown, record: ContactRequest) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
</Button>
{!record.isHandled && (
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkHandled(record.id)}>
</Button>
)}
<Popconfirm title="确定删除该记录?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger size="small" 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>
<MessageOutlined 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={isHandledFilter === null ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(null)}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={isHandledFilter === false ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(false)}
size="small"
>
</Button>
<Button
type={isHandledFilter === true ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(true)}
size="small"
>
</Button>
</div>
</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: 900 }}
/>
)}
</Card>
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => setDetailModalOpen(false)}
footer={null}
width={500}
>
{selectedItem && (
<div style={{ padding: 8 }}>
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{selectedItem.name}
<Tag color={selectedItem.isHandled ? 'green' : 'orange'} style={{ marginLeft: 12 }}>
{selectedItem.isHandled ? '已处理' : '待处理'}
</Tag>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.phone}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.companyName}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.industry}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(selectedItem.createdAt)}</Typography.Text>
{selectedItem.message && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.message}</Typography.Text>
</>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{!selectedItem.isHandled && (
<Button
type="primary"
onClick={() => {
handleMarkHandled(selectedItem.id);
setDetailModalOpen(false);
}}
icon={<CheckOutlined />}
>
</Button>
)}
<Button onClick={() => setDetailModalOpen(false)}></Button>
</div>
</div>
)}
</Modal>
</div>
);
};
export default AdminContactRequests;
+162 -16
View File
@@ -22,6 +22,12 @@ interface CreditRatio {
ratio: number;
baseCredits: number;
perSecondCredits: number;
inputVideoRatio: number;
inputVideoBaseCredits: number;
inputVideoPerSecondCredits: number;
inputImageRatio: number;
inputImageBaseCredits: number;
inputImagePerImageCredits: number;
}
interface CreditRatioFormValues {
@@ -31,6 +37,12 @@ interface CreditRatioFormValues {
ratio: number;
baseCredits: number;
perSecondCredits?: number;
inputVideoRatio?: number;
inputVideoBaseCredits?: number;
inputVideoPerSecondCredits?: number;
inputImageRatio?: number;
inputImageBaseCredits?: number;
inputImagePerImageCredits?: number;
}
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
@@ -117,7 +129,7 @@ const AdminCreditRatios: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
const payload: any = {
model_config_id: values.modelConfigId,
gen_type: values.genType,
resolution: values.resolution,
@@ -125,6 +137,14 @@ const AdminCreditRatios: React.FC = () => {
base_credits: values.baseCredits,
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
};
if (values.genType === 'video') {
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
}
payload.input_image_ratio = values.inputImageRatio ?? 1.0;
payload.input_image_base_credits = values.inputImageBaseCredits ?? 0;
payload.input_image_per_image_credits = values.inputImagePerImageCredits ?? 0;
if (modal.ratio) {
await saveCreditRatio({ id: modal.ratio.id, ...payload });
message.success('已更新');
@@ -174,10 +194,27 @@ const AdminCreditRatios: React.FC = () => {
ratio: ratio.ratio,
baseCredits: ratio.baseCredits,
perSecondCredits: ratio.perSecondCredits,
inputVideoRatio: ratio.inputVideoRatio,
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
inputImageRatio: ratio.inputImageRatio,
inputImageBaseCredits: ratio.inputImageBaseCredits,
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
});
} else {
form.resetFields();
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
form.setFieldsValue({
genType: 'video',
ratio: 1.0,
baseCredits: 60,
perSecondCredits: 2,
inputVideoRatio: 1.0,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 0.5,
inputImageRatio: 1.0,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.5,
});
}
};
@@ -216,13 +253,72 @@ const AdminCreditRatios: React.FC = () => {
),
},
{
title: '示例计算', key: 'example', width: 120,
title: '传入视频',
children: [
{
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : (
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
)}</Typography.Text>
),
},
{
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
),
},
{
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
),
},
],
},
{
title: '传入图片',
children: [
{
title: '倍率', dataIndex: 'inputImageRatio', width: 90,
render: (v: number) => (
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
),
},
{
title: '基础积分', dataIndex: 'inputImageBaseCredits', width: 100,
render: (v: number) => <Typography.Text>{v} </Typography.Text>,
},
{
title: '每张积分', dataIndex: 'inputImagePerImageCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
},
],
},
{
title: '示例计算', key: 'example', width: 160,
render: (_: any, r: CreditRatio) => {
let total: number;
if (r.genType === 'image') {
total = Math.round(r.baseCredits * r.ratio);
if (r.inputImageRatio && r.inputImagePerImageCredits) {
total += Math.round(
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
);
}
} else {
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
total += Math.round(
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
);
}
if (r.inputImageRatio && r.inputImagePerImageCredits) {
total += Math.round(
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
);
}
}
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
@@ -243,7 +339,7 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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' }} />
@@ -258,16 +354,18 @@ const AdminCreditRatios: React.FC = () => {
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: 160 }}
addonAfter="积分"
/>
<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>
@@ -275,7 +373,7 @@ const AdminCreditRatios: React.FC = () => {
</Card>
{/* Generation Credit Ratios */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
@@ -297,7 +395,7 @@ const AdminCreditRatios: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 860 }}
scroll={{ x: 1500 }}
/>
</Card>
@@ -347,6 +445,54 @@ const AdminCreditRatios: React.FC = () => {
</Form.Item>
)}
</div>
{genType !== 'image' && (
<div style={{
marginTop: 8,
padding: '12px 16px',
backgroundColor: '#f5f5ff',
borderRadius: 8,
border: '1px solid #e0e0ff',
}}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
</Typography.Text>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</div>
)}
<div style={{
marginTop: 12,
padding: '12px 16px',
backgroundColor: '#f0fff4',
borderRadius: 8,
border: '1px solid #c6f6d5',
}}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#059669' }}>
</Typography.Text>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="inputImageRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片倍率' }]}>
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputImageBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片基础积分' }]}>
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputImagePerImageCredits" label="每张积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片每张积分' }]}>
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</div>
</Form>
</Modal>
</div>
+410 -140
View File
@@ -1,202 +1,472 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import { getCreditRecords } from '../api';
import { formatDate } from '../utils/formatDate';
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 { formatDate } from '../utils/formatDate';
interface CreditRecord {
id: string;
username: string;
type: string;
amount: number;
balanceAfter: number;
description: string;
createdAt: string;
}
const TEAM_UNASSIGNED_VALUE = '__none__';
const CREDIT_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
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,
imageGenerationCount: 0,
videoGenerationCount: 0,
imageConsume: 0,
videoConsume: 0,
textConsume: 0,
analysisConsume: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
};
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', 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' },
hold: { text: '预扣占用', color: 'gold' },
hold_release: { text: '预扣释放', color: 'green' },
};
const userScopeOptions = [
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' },
{ value: 'frontend_external', label: '前台外部用户' },
];
const recordTypeOptions = [
{ value: '', label: '全部流水' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
];
const creditSubjectOptions = [
{ value: '', label: '全部积分类型' },
{ value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' },
{ value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' },
];
const mediaTypeOptions = [
{ value: '', label: '全部媒体' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
];
const chargeKindOptions = [
{ value: '', label: '全部扣费子类' },
{ value: 'media', label: '媒体生成' },
{ value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' },
{ value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' },
{ value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
];
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实扣费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
{ value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
{ value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' },
];
const sourceStepOptions = [
{ value: '', label: '全部步骤' },
{ value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' },
{ value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' },
{ value: 'video_analysis', label: '视频分析' },
];
const billingSceneOptions = [
{ value: '', label: '全部计费场景' },
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
{ value: 'ai_creation_video_generate', label: 'AI创作视频生成' },
{ value: 'generation_record_text_prompt_optimize', label: '项目记录提词优化' },
{ value: 'generation_record_image_generate', label: '项目记录图片生成' },
{ value: 'generation_record_video_generate', label: '项目记录视频生成' },
{ value: 'generation_record_file_parse', label: '项目记录文件解析' },
{ value: 'generation_record_vision_input', label: '项目记录图片理解' },
{ value: 'hot_opening_project_create', label: '爆款开头复刻创建项目' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻图片提词优化' },
{ value: 'hot_opening_image_generate', label: '爆款开头复刻图片生成' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻视频提词优化' },
{ value: 'hot_opening_video_generate', label: '爆款开头复刻视频生成' },
{ value: 'shot_video_analysis', label: '拆镜视频分析' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻原视频分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻片段视频分析' },
{ value: 'shot_video_split', label: '拆镜切片' },
{ value: 'shot_segment_replicate_create', label: '从切片创建复刻项目' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻图片提词优化' },
{ value: 'shot_image_generate', label: '拆镜复刻图片生成' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻视频提词优化' },
{ value: 'shot_video_generate', label: '拆镜复刻视频生成' },
{ value: 'recharge', label: '充值' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' },
];
function n(value: number | undefined | null): string {
return Number(value || 0).toLocaleString();
}
function engineTypeLabel(type?: string): string {
if (type === 'model') return '提词/分析模型';
if (type === 'image') return '图片引擎';
if (type === 'video') return '视频引擎';
return '执行配置';
}
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
if (scope === 'admin') return { userType: 'admin' };
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
return {};
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<CreditRecord[]>([]);
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [typeFilter, setTypeFilter] = useState<string>('');
const [userNameFilter, setUserNameFilter] = useState<string>('');
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = 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('');
const [userNameFilter, setUserNameFilter] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
page,
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || 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, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
const load = async () => {
setLoading(true);
try {
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string; page?: number; page_size?: number } = {};
if (typeFilter) filters.type = typeFilter;
if (userNameFilter) filters.user_name = userNameFilter;
if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD');
if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD');
filters.page = page;
filters.page_size = pageSize;
const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined);
const res = await getCreditRecords(query);
setRecords(res.items || []);
setTotal(res.total || 0);
} catch {
message.error('加载积分记录失败');
setSummary(res.summary || DEFAULT_SUMMARY);
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page, pageSize, typeFilter, userNameFilter, dateRange]);
useEffect(() => { load(); }, [query]);
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
useEffect(() => { load(); }, []);
const handleSearch = () => {
load();
};
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
const handleReset = () => {
setTypeFilter('');
setUserScope('');
setTeamFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setChargeAction('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
setUserNameFilter('');
setDateRange([null, null]);
load();
setPage(1);
};
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
const exportExcel = async () => {
setExporting(true);
setExportProgress('准备导出...');
try {
const exportPageSize = 500;
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
const first = await getCreditRecords(baseQuery);
const all: AdminCreditRecord[] = [...(first.items || [])];
const exportSummary = first.summary || DEFAULT_SUMMARY;
const totalRows = first.total || 0;
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
for (let p = 2; p <= totalPages; p += 1) {
const res = await getCreditRecords({ ...baseQuery, page: p });
all.push(...(res.items || []));
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
}
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ 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: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ 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 },
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
{ 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: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
exportStyledExcel<AdminCreditRecord>({
filename,
sheetName: '积分流水',
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge],
[' · 预扣占用(独立统计:type=消费 & action=hold', exportSummary.totalHold],
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
[' · 真实退款(独立统计:type=回退 & action=refund/NULL', exportSummary.totalRefundReal],
[' · 预扣释放(独立统计:type=回退 & action=hold_release', exportSummary.totalHoldRelease],
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分(仅真实扣费)', exportSummary.imageConsume],
['视频消费积分(仅真实扣费)', exportSummary.videoConsume],
['提词消费积分(仅真实扣费)', exportSummary.textConsume],
['视频分析积分(仅真实扣费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
],
columns: detailColumns,
rows: all,
});
message.success('Excel 已导出');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
setExporting(false);
setExportProgress('');
}
};
const columns = [
{
title: '用户', dataIndex: 'username', width: 120,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'type', width: 100,
render: (v: string) => {
const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.text}
</Tag>
);
},
},
{
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v > 0 ? '+' : ''}{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
},
{
title: '说明', dataIndex: 'description', ellipsis: true,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{ 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: '流水类型', 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: '媒体', 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: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ 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> },
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
];
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(16,185,129,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#10b981',
}}><ArrowUpOutlined /></div>
<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', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div>
</div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#ef4444',
}}><ArrowDownOutlined /></div>
<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' }}>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}> + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
<span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
</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' }}>
退
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>退 + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
退 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
</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: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</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>
</div>
</Space>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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',
}}><WalletOutlined /></div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
<div style={{ color: '#94a3b8' }}> / </div>
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
</div>
</div>
</Space>
</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select
value={typeFilter}
onChange={(value) => setTypeFilter(value)}
style={{ width: 120 }}
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '退回' },
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input
placeholder="用户名搜索"
value={userNameFilter}
onChange={(e) => setUserNameFilter(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
placeholder={['开始日期', '结束日期']}
style={{ width: 250 }}
/>
<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} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
@@ -204,13 +474,13 @@ const AdminCreditRecords: React.FC = () => {
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 800 }}
scroll={{ x: 2160 }}
/>
</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;
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -14,19 +15,32 @@ import {
Typography,
} from 'antd';
import {
AudioOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
FileImageOutlined,
LinkOutlined,
LoadingOutlined,
PlayCircleOutlined,
RobotOutlined,
SearchOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import dayjs from 'dayjs';
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;
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
@@ -34,6 +48,15 @@ const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20;
/** 从 engineSnapshot 中提取引擎展示名称。 */
const getEngineName = (snapshot: Record<string, unknown> | null | undefined): string | null => {
if (!snapshot) return null;
return (snapshot.name as string)
|| (snapshot.modelName as string)
|| (snapshot.id as string)
|| null;
};
type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid';
interface PreviewResourceState {
@@ -50,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 /> },
@@ -200,6 +202,10 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationAiRecords: React.FC = () => {
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
const [total, setTotal] = useState(0);
@@ -208,17 +214,31 @@ const AdminGenerationAiRecords: React.FC = () => {
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterGenType, setFilterGenType] = useState<string>('');
const [filterEngineId, setFilterEngineId] = useState<string>('');
const [inputUserId, setInputUserId] = useState<string>('');
const [inputUserName, setInputUserName] = useState<string>('');
const [queryUserId, setQueryUserId] = useState<string>('');
const [queryUserName, setQueryUserName] = useState<string>('');
const [queryEngineId, setQueryEngineId] = useState<string>('');
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);
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
// 资源预览弹窗(附件 / 最终结果)
const [resourcePreview, setResourcePreview] = useState<{
url: string;
type: 'image' | 'video';
title: string;
} | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -227,6 +247,9 @@ const AdminGenerationAiRecords: React.FC = () => {
status: filterStatus || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
engineId: queryEngineId || undefined,
createdStart: queryCreatedRange?.[0]?.format?.('YYYY-MM-DDTHH:mm:ss'),
createdEnd: queryCreatedRange?.[1]?.format?.('YYYY-MM-DDTHH:mm:ss'),
page,
pageSize: PAGE_SIZE,
});
@@ -237,12 +260,70 @@ const AdminGenerationAiRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterGenType, filterStatus, page, queryUserId, queryUserName]);
}, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]);
useEffect(() => {
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);
@@ -274,6 +355,24 @@ const AdminGenerationAiRecords: React.FC = () => {
setPage(1);
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryEngineId(filterEngineId);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterGenType('');
setFilterEngineId('');
setInputUserId('');
setInputUserName('');
setQueryUserId('');
setQueryUserName('');
setQueryEngineId('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1);
};
@@ -341,6 +440,21 @@ const AdminGenerationAiRecords: React.FC = () => {
window.open(apiUrl(url), '_blank', 'noopener,noreferrer');
};
/** 弹窗预览资源(图片/视频) */
const handlePreviewResource = (url: string, type: 'image' | 'video', title: string) => {
if (isBlobUrl(url)) {
message.warning('本地临时素材已失效,暂无法查看');
return;
}
if (isUrlExpired(url)) {
message.warning('链接已超时,请刷新列表或重新搜索后再查看');
return;
}
setResourcePreview({ url: apiUrl(url), type, title });
};
const handleCloseResourcePreview = () => setResourcePreview(null);
const columns = useMemo(() => [
{
title: '用户', key: 'user', width: 150,
@@ -358,6 +472,107 @@ 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) => {
const name = getEngineName(r.engineSnapshot as any);
return (
<Tooltip title={r.engineId || '未知引擎'} placement="topLeft">
<Tag icon={<RobotOutlined />} color="cyan">{name || r.engineId || '-'}</Tag>
</Tooltip>
);
},
},
{
title: '附件', key: 'references', width: 100,
render: (_: any, r: GenerationAITaskOut) => {
const refs = r.mediaReferences || [];
if (refs.length === 0) return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
return (
<Space size={2} wrap>
{refs.map((ref, idx) => {
const refUrl = getReferenceUrl(ref);
const refType = getReferenceType(ref);
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
if (!refUrl) return null;
const icon = refType === 'video'
? <VideoCameraOutlined style={{ color: '#6366f1' }} />
: refType === 'audio'
? <AudioOutlined style={{ color: '#f59e0b' }} />
: <FileImageOutlined style={{ color: '#8b5cf6' }} />;
return (
<Tooltip key={idx} title={title}>
<Button
size="small"
type="text"
icon={icon}
style={{ width: 28, height: 28, padding: 0 }}
onClick={(e) => {
e.stopPropagation();
handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title);
}}
/>
</Tooltip>
);
})}
</Space>
);
},
},
{
title: '结果', key: 'result', width: 90,
render: (_: any, r: GenerationAITaskOut) => {
if (!resolveGenerationUiState(r).isSuccess) {
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
}
if (r.genType === 'video' && r.videoUrl) {
return (
<Button
size="small"
icon={<PlayCircleOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handlePreviewResource(r.videoUrl!, 'video', '生成视频'); }}
>
</Button>
);
}
if (r.genType === 'image' && r.imageUrl) {
return (
<Button
size="small"
icon={<FileImageOutlined />}
type="link"
style={{ padding: 0 }}
onClick={(e) => { e.stopPropagation(); handlePreviewResource(r.imageUrl!, 'image', '生成图片'); }}
>
</Button>
);
}
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}></Typography.Text>;
},
},
{
title: '提示词', key: 'prompt', ellipsis: true,
render: (_: any, r: GenerationAITaskOut) => (
@@ -402,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,
@@ -430,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} />;
@@ -573,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} />;
@@ -764,7 +980,7 @@ const AdminGenerationAiRecords: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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' }} />
@@ -795,10 +1011,37 @@ const AdminGenerationAiRecords: React.FC = () => {
{ value: 'video', label: '视频' },
]}
/>
<Select
allowClear
showSearch
loading={engineListLoading}
placeholder="引擎筛选"
value={filterEngineId || undefined}
style={{ width: 260 }}
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
filterOption={(input, option: any) =>
String(option?.searchText || option?.label || '')
.toLowerCase()
.includes(input.trim().toLowerCase())
}
options={engineOptions}
/>
<RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
// 自动将结束时间补到当天 23:59:59,确保选一天也能看到整天数据
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Input
placeholder="用户ID搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 180 }}
style={{ width: 150 }}
value={inputUserId}
onChange={(e) => setInputUserId(e.target.value)}
onPressEnter={handleSearch}
@@ -807,7 +1050,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<Input
placeholder="用户名搜索"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
style={{ width: 160 }}
style={{ width: 150 }}
value={inputUserName}
onChange={(e) => setInputUserName(e.target.value)}
onPressEnter={handleSearch}
@@ -816,6 +1059,9 @@ const AdminGenerationAiRecords: React.FC = () => {
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
@@ -824,7 +1070,7 @@ const AdminGenerationAiRecords: React.FC = () => {
loading={loading}
columns={columns as any}
dataSource={records}
scroll={{ x: 1180 }}
scroll={{ x: 1650 }}
pagination={{
current: page,
pageSize: PAGE_SIZE,
@@ -853,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>
@@ -862,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>
@@ -912,16 +1159,45 @@ const AdminGenerationAiRecords: React.FC = () => {
{renderReferences()}
{preview.status === 'completed' ? (
<div>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
{preview.genType === 'video' ? '生成视频' : '生成图片'}
{/* 附件快速查看区 */}
{(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>
</Typography.Text>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
<Space size={8} wrap>
{preview.mediaReferences.map((ref, idx) => {
const refUrl = getReferenceUrl(ref);
const refType = getReferenceType(ref);
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
if (!refUrl) return null;
return (
<Button
key={idx}
size="small"
icon={refType === 'video' ? <PlayCircleOutlined /> : <LinkOutlined />}
onClick={() => handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title)}
>
{title}
</Button>
);
})}
</Space>
</div>
) : null}
{preview.status === 'failed' && preview.errorMessage ? (
<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>
{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>
@@ -936,6 +1212,34 @@ const AdminGenerationAiRecords: React.FC = () => {
<Empty description="暂无详情" />
)}
</Modal>
{/* 资源预览弹窗(附件 / 最终结果) */}
<Modal
title={resourcePreview?.title || '资源预览'}
open={!!resourcePreview}
onCancel={handleCloseResourcePreview}
footer={null}
width={resourcePreview?.type === 'video' ? 800 : 600}
destroyOnHidden
>
{resourcePreview ? (
resourcePreview.type === 'video' ? (
<video
key={resourcePreview.url}
src={resourcePreview.url}
controls
autoPlay
style={{ width: '100%', maxHeight: 600, background: '#000', borderRadius: 8 }}
/>
) : (
<img
src={resourcePreview.url}
alt={resourcePreview.title}
style={{ width: '100%', maxHeight: 600, objectFit: 'contain', borderRadius: 8 }}
/>
)
) : null}
</Modal>
</div>
);
};
@@ -22,12 +22,12 @@ import {
CloseCircleOutlined,
SearchOutlined,
VideoCameraOutlined,
ExclamationCircleOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
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 +50,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 /> },
@@ -194,13 +186,14 @@ 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 load = useCallback(async () => {
setLoading(true);
@@ -208,6 +201,8 @@ const AdminGenerationRecords: React.FC = () => {
const res = await getAdminGenerationRecords({
userId: filterUserId.trim() || undefined,
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
page,
pageSize,
});
@@ -217,6 +212,7 @@ const AdminGenerationRecords: React.FC = () => {
username: item.username,
projectId: item.projectId,
projectName: item.projectName,
industry: item.industry,
originalPrompt: item.originalPrompt,
optimizedPrompt: item.optimizedPrompt,
duration: item.duration,
@@ -239,6 +235,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 {
@@ -246,12 +246,32 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
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);
@@ -348,33 +368,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(() => [
{
@@ -390,6 +384,10 @@ const AdminGenerationRecords: React.FC = () => {
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
},
{
title: '行业', dataIndex: 'industry', width: 100, ellipsis: true,
render: (v: string) => <Tag color="cyan">{v || '-'}</Tag>,
},
{
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
render: (v: string) => {
@@ -411,21 +409,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>
)
),
},
@@ -448,10 +452,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) => (
@@ -462,78 +471,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} />;
@@ -676,7 +627,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} />;
@@ -867,7 +818,7 @@ const AdminGenerationRecords: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space>
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
@@ -889,6 +840,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' }} />}
@@ -951,7 +923,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>
@@ -1030,7 +1004,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' ? '生成视频' : '生成图片'}
@@ -1040,7 +1014,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>
@@ -1057,73 +1031,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>
);
};
@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, Input, Space, Switch, Tabs, message } from 'antd';
import { PlusOutlined, SaveOutlined } from '@ant-design/icons';
import {
getHomeMaterialCategories,
getHomeMaterialConfig,
getHomeMaterialWatermarks,
saveHomeMaterialConfig,
} from '../api';
import type { HomeMaterialCategory, HomeMaterialConfig, HomeMaterialWatermark } from '../types';
import HomeMaterialAssetTable from './homeMaterials/HomeMaterialAssetTable';
import HomeMaterialCategoryPanel from './homeMaterials/HomeMaterialCategoryPanel';
import HomeMaterialUploadModal from './homeMaterials/HomeMaterialUploadModal';
import WatermarkLibraryModal from './homeMaterials/WatermarkLibraryModal';
const AdminHomeMaterials: React.FC = () => {
const [form] = Form.useForm<HomeMaterialConfig>();
const [configLoading, setConfigLoading] = useState(false);
const [categories, setCategories] = useState<HomeMaterialCategory[]>([]);
const [watermarks, setWatermarks] = useState<HomeMaterialWatermark[]>([]);
const [uploadOpen, setUploadOpen] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const loadBase = async () => {
const [catRes, wmRes] = await Promise.all([
getHomeMaterialCategories({ page: 1, pageSize: 200 }),
getHomeMaterialWatermarks({ page: 1, pageSize: 200, isActive: true }),
]);
setCategories(catRes.items);
setWatermarks(wmRes.items);
};
const loadConfig = async () => {
setConfigLoading(true);
try {
const cfg = await getHomeMaterialConfig();
form.setFieldsValue(cfg);
} finally {
setConfigLoading(false);
}
};
useEffect(() => {
loadConfig();
loadBase();
}, []);
const saveConfig = async () => {
const values = await form.validateFields();
await saveHomeMaterialConfig(values);
message.success('配置已保存');
};
const refreshAll = async () => {
await loadBase();
setReloadKey(k => k + 1);
};
return (
<div>
<Card style={{ marginBottom: 16 }} loading={configLoading}>
<Form form={form} layout="inline" initialValues={{ enabled: false, title: '行业素材案例', subtitle: '精选图片与视频素材展示', showOriginalInAdmin: true }}>
<Form.Item name="enabled" label="首页展示" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}><Input style={{ width: 220 }} /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input style={{ width: 320 }} /></Form.Item>
<Form.Item name="showOriginalInAdmin" label="后台展示原素材" valuePropName="checked"><Switch /></Form.Item>
<Form.Item>
<Button type="primary" icon={<SaveOutlined />} onClick={saveConfig}></Button>
</Form.Item>
</Form>
</Card>
<Card
title="首页素材行业装修"
extra={(
<Space>
<Button onClick={refreshAll}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadOpen(true)}></Button>
</Space>
)}
>
<Tabs
items={[
{
key: 'assets',
label: '素材管理',
children: <HomeMaterialAssetTable categories={categories} watermarks={watermarks} reloadKey={reloadKey} />,
},
{
key: 'categories',
label: '行业类别',
children: <HomeMaterialCategoryPanel onChanged={refreshAll} />,
},
{
key: 'watermarks',
label: '水印库',
children: <WatermarkLibraryModal embedded onChanged={refreshAll} />,
},
]}
/>
</Card>
<HomeMaterialUploadModal
open={uploadOpen}
onClose={() => setUploadOpen(false)}
categories={categories}
watermarks={watermarks}
onSuccess={refreshAll}
/>
</div>
);
};
export default AdminHomeMaterials;
@@ -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>
);
};
+104 -20
View File
@@ -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,
@@ -17,9 +17,15 @@ interface ImageEngine {
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
maxImageCount: number;
generateUrl: string;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
}
function parseJsonArray(val: unknown): any[] {
@@ -40,25 +46,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"1K": {
"1:1": "1024x1024",
"4:3": "1152x864",
"3:4": "864x1152",
"16:9": "1312x736",
"9:16": "736x1312",
"3:2": "1248x832",
"2:3": "832x1248",
"21:9": "1568x672",
},
"2K": {
"1:1": "2048×2048",
"4:3": "2304×1728",
"3:4": "1728×2304",
"16:9": "2560×1440",
"9:16": "1600×2848",
"3:2": "2496×1664",
"2:3": "1664×2496",
"21:9": "3024×1296",
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"16:9": "2848x1600",
"9:16": "1600x2848",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3136x1344",
},
"4K": {
"1:1": "4096×4096",
"4:3": "4608×3456",
"3:4": "3520×4704",
"16:9": "5404×3040",
"9:16": "3040×5504",
"3:2": "4992×3328",
"2:3": "3328×4992",
"21:9": "6197×2656",
"1:1": "4096x4096",
"4:3": "4704x3520",
"3:4": "3520x4704",
"16:9": "5504x3040",
"9:16": "3040x5504",
"3:2": "4992x3328",
"2:3": "3328x4992",
"21:9": "6197x2656",
},
};
@@ -69,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);
@@ -93,7 +110,7 @@ const AdminImageEngines: React.FC = () => {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
@@ -111,9 +128,15 @@ const AdminImageEngines: React.FC = () => {
supported_models: JSON.stringify(values.supportedModels || []),
supported_sizes: JSON.stringify(sizes),
default_size: values.defaultSize || '2K',
max_image_count: values.maxImageCount ?? 0,
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 });
@@ -145,7 +168,7 @@ const AdminImageEngines: React.FC = () => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
@@ -156,8 +179,12 @@ 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,
size_1K: ALL_RATIOS,
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
@@ -184,6 +211,16 @@ const AdminImageEngines: React.FC = () => {
</div>
),
},
{
title: '1K 支持比例', key: 'sizes_1k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
@@ -204,6 +241,22 @@ const AdminImageEngines: React.FC = () => {
))}</Space>;
},
},
{
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>,
@@ -287,7 +340,7 @@ const AdminImageEngines: React.FC = () => {
</Typography.Text>
</div>
{["2K", "4K"].map(tier => (
{["1K", "2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
@@ -311,13 +364,44 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '1K', label: '1K' },
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<Form.Item name="maxImageCount" label="最大图片数量">
<Input type="number" size="large" />
</Form.Item>
<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={[
+8 -9
View File
@@ -67,7 +67,7 @@ const AdminLayout: React.FC = () => {
const logo = info.siteLogo || '';
setSiteName(name);
setSiteLogo(logo);
document.title = name;
document.title = name === '管理后台' ? name : name + ' - 管理后台';
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
if (logo) {
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
@@ -230,18 +230,18 @@ const AdminLayout: React.FC = () => {
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
}}>
<div style={{
width: 42,
height: 42,
width: 42,
height: 42,
borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex',
alignItems: 'center',
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' }} />
)}
@@ -287,7 +287,6 @@ const AdminLayout: React.FC = () => {
bottom: 16,
left: 16,
right: 16,
padding: 12,
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
borderRadius: 12,
border: '1px solid rgba(0, 0, 0, 0.06)',
+4 -3
View File
@@ -41,8 +41,9 @@ const AdminLoginPage = () => {
await login(values.username, values.password, values.rememberMe);
message.success('登录成功');
navigate('/');
} catch {
message.error('登录失败');
} catch (error: any) {
const errorMsg = error?.message || '登录失败';
message.error(errorMsg);
} finally {
setLoading(false);
}
@@ -80,7 +81,7 @@ const AdminLoginPage = () => {
bottom: -100, left: -80, filter: 'blur(50px)',
}} />
<Card bordered={false} style={{
<Card variant="outlined" style={{
width: 440,
borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
@@ -0,0 +1,272 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography, Tooltip } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { getMaterialList } from '../api';
import PreResultDisplay from '../components/PreResultDisplay';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const { Text } = Typography;
const AdminMaterialList: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchMaterialId, setSearchMaterialId] = useState('');
const [searchUploadId, setSearchUploadId] = useState('');
const [searchResourceType, setSearchResourceType] = useState('');
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '广告主账户ID',
dataIndex: 'advertiserId',
key: 'advertiserId',
},
{
title: '素材ID',
dataIndex: 'materialId',
key: 'materialId',
width: 180,
},
{
title: '上传ID',
dataIndex: 'uploadId',
key: 'uploadId',
render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
},
{
title: '预测试模板ID',
dataIndex: 'preTestTemplateId',
key: 'preTestTemplateId',
},
{
title: '授权ID',
dataIndex: 'oauthId',
key: 'oauthId',
},
{
title: '目标标ID',
dataIndex: 'targetId',
key: 'targetId',
},
{
title: '目标表',
dataIndex: 'targetTable',
key: 'targetTable',
},
{
title: '任务ID',
dataIndex: 'taskId',
key: 'taskId',
},
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
},
{
title: '备注',
dataIndex: 'note',
key: 'note',
width: 200,
render: (text: string) => (
<Input.TextArea
value={text || ''}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
placeholder="-"
/>
),
},
{
title: '预测试结果',
dataIndex: 'preResult',
key: 'preResult',
width: 120,
render: (text: string) => <PreResultDisplay preResult={text} />,
},
{
title: '资源类型',
dataIndex: 'resourceType',
key: 'resourceType',
width: 100,
render: (text: string) => (
<Tag color={text === 'video' ? 'blue' : 'green'}>
{text === 'video' ? '视频' : '图片'}
</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (text: string) => (
<Tag color={text === 'SUCCESS' ? 'green' : text === 'PENDING' ? 'orange' : 'red'}>
{text === 'SUCCESS' ? '成功' : text === 'PENDING' ? '处理中' : '失败'}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
},
];
const loadMaterialList = async () => {
setLoading(true);
try {
const res = await getMaterialList({
material_id: searchMaterialId || undefined,
upload_id: searchUploadId || undefined,
resource_type: searchResourceType || undefined,
page: currentPage,
page_size: pageSize,
});
const data = res.data || [];
const tableData = data.map((item: any, index: number) => ({
...item,
index: (currentPage - 1) * pageSize + index + 1,
}));
setTableData(tableData);
setTotal(res.pagination?.total || 0);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMaterialList();
}, [currentPage, pageSize]);
const handleSearch = () => {
setCurrentPage(1);
loadMaterialList();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}>ID列表</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input
placeholder="素材ID"
value={searchMaterialId}
onChange={(e) => setSearchMaterialId(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<Input
placeholder="上传ID"
value={searchUploadId}
onChange={(e) => setSearchUploadId(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<Input
placeholder="文件名"
value={searchResourceType}
onChange={(e) => setSearchResourceType(e.target.value)}
style={{ width: 140 }}
allowClear
onPressEnter={handleSearch}
/>
<Select
placeholder="资源类型"
value={searchResourceType}
onChange={(value) => setSearchResourceType(value)}
style={{ width: 120 }}
allowClear
options={[
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
]}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
onChange={(page, size) => {
setCurrentPage(page);
setPageSize(size);
}}
size="small"
/>
</div>
</div>
</div>
);
};
export default AdminMaterialList;
@@ -198,7 +198,7 @@ const AdminMenuConfig: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
+1 -1
View File
@@ -157,7 +157,7 @@ const AdminModels: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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}
@@ -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 bordered={false} 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 }}
@@ -264,4 +450,4 @@ const AdminNotificationManager: React.FC = () => {
);
};
export default AdminNotificationManager;
export default AdminNotificationManager;
@@ -0,0 +1,284 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
import { LockOutlined, SearchOutlined } from '@ant-design/icons';
import { getOAuthAccountList, getOpenTypeAll } from '../api';
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const { Text } = Typography;
const AdminOAuthList: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchAccountId, setSearchAccountId] = useState('');
const [searchUserId, setSearchUserId] = useState('');
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
const [searchOpenType, setSearchOpenType] = useState<number | undefined>(undefined);
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '前台用户ID',
dataIndex: 'userId',
key: 'userId',
},
{
title: '前台用户手机号',
dataIndex: 'userPhone',
key: 'userPhone',
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
},
{
title: '授权绑定账户ID',
dataIndex: 'accountId',
key: 'accountId',
},
{
title: '授权绑定账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权绑定账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权登录账户用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
},
{
title: '授权登录账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
width: 120,
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
},
{
title: '平台端口',
dataIndex: 'portType',
key: 'portType',
render: (role: string) => {
const roleMap: Record<string, string> = {
1: '巨量',
2: '磁力',
3: '巨量星图',
4: '服务单',
5: '腾讯',
};
return roleMap[role] || role;
},
// 1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯
// render: (text: number) => <span style={{ color: '#1e293b' }}>{text}</span>,
},
{
title: '授权刷新Token',
dataIndex: 'refreshToken',
key: 'refreshToken',
},
{
title: '刷新Token过期时间',
dataIndex: 'refreshTokenExpired',
key: 'refreshTokenExpired',
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '授权Token',
dataIndex: 'accessToken',
key: 'accessToken',
},
{
title: 'Token过期时间',
dataIndex: 'accessTokenExpired',
key: 'accessTokenExpired',
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
];
const loadOpenTypeList = async () => {
try {
const res = await getOpenTypeAll();
const data = res.data || [];
const map: Record<number, string> = {};
const options: { value: number; label: string }[] = [];
data.forEach(item => {
map[item.openType] = item.typeName;
options.push({ value: item.openType, label: item.typeName });
});
setOpenTypeMap(map);
setOpenTypeOptions(options);
} catch (error) {
console.error('加载开户方式列表失败:', error);
}
};
const loadOAuthList = async () => {
setLoading(true);
try {
const res = await getOAuthAccountList({
account_id: searchAccountId || undefined,
account_userid: searchUserId || undefined,
open_type: searchOpenType,
page: currentPage,
page_size: pageSize,
});
const data = res.data || [];
const tableData = data.map((item: any, index: number) => ({
...item,
index: (currentPage - 1) * pageSize + index + 1,
}));
setTableData(tableData);
setTotal(res.pagination?.total || 0);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOpenTypeList();
loadOAuthList();
}, [currentPage, pageSize]);
const handleSearch = () => {
setCurrentPage(1);
loadOAuthList();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<LockOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}></Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input
placeholder="授权绑定账户ID"
value={searchAccountId}
onChange={(e) => setSearchAccountId(e.target.value)}
style={{ width: 180 }}
allowClear
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
/>
<Input
placeholder="授权登录账户用户ID"
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
style={{ width: 180 }}
allowClear
prefix={<SearchOutlined />}
onPressEnter={handleSearch}
/>
<Select
placeholder="开户方式"
value={searchOpenType}
onChange={(value) => setSearchOpenType(value)}
style={{ width: 140 }}
allowClear
options={openTypeOptions}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
}
}}
/>
</div>
</div>
);
};
export default AdminOAuthList;
+50 -28
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, Select, InputNumber,
Button, Card, Space, Table, Tag, Tooltip, Typography, message, Modal, Form, Input, Select, InputNumber,
} from 'antd';
import {
MenuOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
@@ -13,7 +13,7 @@ interface OAuthApp {
appId: string;
secret: string;
status: number;
count: number;
maxCount: number;
openType: number;
authUrl?: string;
company?: string;
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
app_id: values.app_id,
secret: values.secret,
open_type: values.open_type,
count: values.count,
max_count: values.max_count,
auth_url: values.auth_url,
company: values.company,
});
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
const app = await getOauthApp(id);
setCurrentApp(app);
updateForm.setFieldsValue({
app_id: app.appId,
appId: app.appId,
secret: app.secret,
open_type: app.openType,
count: app.count,
auth_url: app.authUrl,
openType: app.openType,
maxCount: app.maxCount,
authUrl: app.authUrl,
company: app.company,
});
setUpdateModalVisible(true);
@@ -137,16 +137,27 @@ const AdminOauthAppList: React.FC = () => {
useEffect(() => { load(); }, []);
const columns = [
{ title: 'ID', dataIndex: 'id',
{ title: 'ID', dataIndex: 'id', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '应用ID', dataIndex: 'appId',
{ title: '应用ID', dataIndex: 'appId', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '应用密钥', dataIndex: 'secret',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// { title: '应用密钥', dataIndex: 'secret', width: 120,
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// },
{ title: '应用密钥', dataIndex: 'secret', width: 120,
render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
},
{ title: '开户方式', dataIndex: 'openType',
{ title: '开户方式', dataIndex: 'openType', width: 90,
render: (v: number) => {
const typeMap: Record<number, string> = {
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
@@ -155,29 +166,40 @@ const AdminOauthAppList: React.FC = () => {
return <Typography.Text>{typeMap[v] || v}</Typography.Text>;
},
},
{ title: '归属公司', dataIndex: 'company',
{ title: '归属公司', dataIndex: 'company', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '授权次数', dataIndex: 'count',
{ title: '授权次数', dataIndex: 'maxCount', width: 90,
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '状态', dataIndex: 'status',
{ title: '状态', dataIndex: 'status', width: 80,
render: (v: number) => <Tag color={v === 1 ? 'green' : 'red'}>{v === 1 ? '正常' : '禁用'}</Tag>,
},
{ title: '授权URL', dataIndex: 'authUrl',
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// { title: '授权URL', dataIndex: 'authUrl', width: 180,
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
// },
{ title: '授权URL', dataIndex: 'authUrl', width: 200,
render: (v: string) => {
if (!v) return '-';
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }} ellipsis>{v.length > 30 ? `${v.slice(0, 27)}...` : v}</Typography.Text>
</Tooltip>
);
},
},
{ title: '创建人', dataIndex: 'createBy',
{ title: '创建人', dataIndex: 'createBy', width: 120,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{ title: '创建时间', dataIndex: 'createdAt',
{ title: '创建时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
{ title: '更新时间', dataIndex: 'updatedAt',
{ title: '更新时间', dataIndex: 'updatedAt', width: 160,
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
},
{ title: '操作',
{ title: '操作', width: 120, fixed: 'right' as const,
render: (_: any, record: OAuthApp) => (
<Space>
<Button
@@ -226,7 +248,7 @@ const AdminOauthAppList: React.FC = () => {
showTotal: (t) => `${t} 条记录`,
onChange: (p) => { setPage(p); load(p); },
}}
scroll={{ x: 800 }}
scroll={{ x: 'max-content' }}
/>
</Card>
@@ -276,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
</Select>
</Form.Item>
<Form.Item
name="count"
name="max_count"
label="最大授权用户数"
initialValue={100}
>
@@ -322,7 +344,7 @@ const AdminOauthAppList: React.FC = () => {
return typeMap[currentApp.openType] || currentApp.openType;
})()}</p>
<p><strong>:</strong> {currentApp.company || '-'}</p>
<p><strong>:</strong> {currentApp.count}</p>
<p><strong>:</strong> {currentApp.maxCount}</p>
<p><strong>:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
<p><strong>URL:</strong> {currentApp.authUrl || '-'}</p>
<p><strong>:</strong> {currentApp.createBy}</p>
@@ -347,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
>
<Form form={updateForm} layout="vertical">
<Form.Item
name="app_id"
name="appId"
label="应用ID"
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
>
@@ -361,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
<Input placeholder="请输入应用密钥" />
</Form.Item>
<Form.Item
name="open_type"
name="openType"
label="开户方式"
rules={[{ required: true, message: '请选择开户方式' }]}
>
@@ -379,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
</Select>
</Form.Item>
<Form.Item
name="count"
name="maxCount"
label="最大授权用户数"
>
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
</Form.Item>
<Form.Item
name="auth_url"
name="authUrl"
label="应用授权链接"
>
<Input placeholder="请输入应用授权链接" />
@@ -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 {
@@ -68,7 +68,7 @@ const AdminOperationLogs: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
Button, Card, Form, Input, message, Switch, Typography, InputNumber, Space,
} from 'antd';
import {
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
@@ -85,7 +85,7 @@ const AdminPaymentConfig: React.FC = () => {
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 24 }}>
{/* 通用设置:订单超时时间 */}
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
@@ -100,14 +100,16 @@ const AdminPaymentConfig: React.FC = () => {
</div>
<Form form={form} layout="inline">
<Form.Item name="order_timeout" style={{ margin: 0 }}>
<InputNumber
min={30}
max={86400}
placeholder="180"
style={{ width: 140 }}
size="middle"
addonAfter=""
/>
<Space.Compact style={{ width: 140 }}>
<InputNumber
min={30}
max={86400}
placeholder="180"
style={{ width: '100%' }}
size="middle"
/>
<Typography.Text></Typography.Text>
</Space.Compact>
</Form.Item>
</Form>
</div>
@@ -116,7 +118,7 @@ const AdminPaymentConfig: React.FC = () => {
{/* 模拟支付模式 */}
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
@@ -139,7 +141,7 @@ const AdminPaymentConfig: React.FC = () => {
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{/* 微信支付 */}
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
@@ -216,7 +218,7 @@ const AdminPaymentConfig: React.FC = () => {
{/* 支付宝 */}
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
+35 -19
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
@@ -22,6 +22,7 @@ const AdminPaymentStats: React.FC = () => {
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
@@ -36,6 +37,7 @@ const AdminPaymentStats: React.FC = () => {
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
@@ -61,6 +63,7 @@ const AdminPaymentStats: React.FC = () => {
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
@@ -101,6 +104,7 @@ const AdminPaymentStats: React.FC = () => {
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
@@ -171,7 +175,7 @@ const AdminPaymentStats: React.FC = () => {
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
@@ -186,7 +190,7 @@ const AdminPaymentStats: React.FC = () => {
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
@@ -203,7 +207,7 @@ const AdminPaymentStats: React.FC = () => {
</Row>
{/* Status breakdown */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
<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 => {
@@ -237,28 +241,28 @@ const AdminPaymentStats: React.FC = () => {
<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>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={12} md={6}>
<span style={{ marginRight: 8 }}></span>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: 150 }}
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
@@ -266,9 +270,21 @@ const AdminPaymentStats: React.FC = () => {
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={12} md={8}>
<span style={{ marginRight: 8 }}></span>
<Col xs={24} sm={8} md={5}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Input
placeholder="搜索手机号"
allowClear
value={filters.phone}
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
onPressEnter={() => { setOrderPage(1); load(); }}
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
/>
</Col>
<Col xs={24} sm={12} md={7}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<RangePicker
style={{ width: '100%' }}
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
@@ -276,9 +292,9 @@ const AdminPaymentStats: React.FC = () => {
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4}>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
+517
View File
@@ -0,0 +1,517 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, InputNumber, Upload,
} from 'antd';
import {
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getOpenTypeList, getOpenType, createOpenType, updateOpenType, deleteOpenType, uploadImage } from '../api';
import { formatDate } from '../utils/formatDate';
interface OpenType {
id: string;
openType: number;
typeName: string;
description: string;
thumb?: string;
createdAt: string;
updatedAt: string;
}
const normalizeUploadError = (e: unknown): Error => {
if (e instanceof Error) {
return e;
}
if (typeof e === 'string') {
return new Error(e);
}
return new Error('上传失败');
};
const assertUploadFile = (file: unknown): File => {
if (file instanceof File) {
return file;
}
throw new Error('请选择有效图片文件');
};
const AdminPlatform: React.FC = () => {
const [openTypes, setOpenTypes] = useState<OpenType[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [updateModalVisible, setUpdateModalVisible] = useState(false);
const [currentOpenType, setCurrentOpenType] = useState<OpenType | null>(null);
const [createThumbUrl, setCreateThumbUrl] = useState('');
const [updateThumbUrl, setUpdateThumbUrl] = useState('');
const [createForm] = Form.useForm();
const [updateForm] = Form.useForm();
const load = async (p?: number, ps?: number) => {
setLoading(true);
try {
const res = await getOpenTypeList({
page: p || page,
page_size: ps || pageSize,
});
setOpenTypes(res.data || []);
setTotal(res.pagination.total || 0);
} catch {
message.error('加载开户方式列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleCreate = async () => {
try {
const values = await createForm.validateFields();
const thumbUrl = createThumbUrl;
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
await createOpenType({
open_type: values.open_type,
type_name: values.type_name,
description: values.description,
thumb: thumbPath,
});
message.success('创建成功');
setCreateModalVisible(false);
createForm.resetFields();
setCreateThumbUrl('');
load();
} catch (e: any) {
message.error(e?.message || '创建失败');
}
};
const handleDetail = async (id: string) => {
try {
const openType = await getOpenType(id);
setCurrentOpenType(openType.data || {});
setDetailModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
}
};
const handleUpdate = async (id: string) => {
try {
const openType = await getOpenType(id);
const thumb = openType.data?.thumb || '';
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const fullThumbUrl = thumb.startsWith('http') ? thumb : `${baseUrl}${thumb}`;
setCurrentOpenType(openType.data || {});
setUpdateThumbUrl(fullThumbUrl);
setUpdateModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
}
};
const handleSaveUpdate = async () => {
if (!currentOpenType) return;
try {
const values = await updateForm.validateFields();
const thumbUrl = updateThumbUrl;
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
await updateOpenType(currentOpenType.id, {
open_type: values.open_type,
type_name: values.type_name,
description: values.description,
thumb: thumbPath,
});
message.success('更新成功');
setUpdateModalVisible(false);
updateForm.resetFields();
setCurrentOpenType(null);
setUpdateThumbUrl('');
load();
} catch (e: any) {
message.error(e?.message || '更新失败');
}
};
const handleDelete = (id: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个开户方式吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await deleteOpenType(id);
message.success('删除成功');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
},
});
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
width: 100,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '开户类型',
dataIndex: 'openType',
width: 100,
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '类型名称',
dataIndex: 'typeName',
width: 150,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '描述',
dataIndex: 'description',
width: 250,
ellipsis: true,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{v}
</Typography.Text>
),
},
{
title: '缩略图',
dataIndex: 'thumb',
width: 120,
render: (v: string) => {
if (!v) return '-';
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const fullUrl = v.startsWith('http') ? v : `${baseUrl}${v}`;
return <img src={fullUrl} alt="thumb" style={{ width: '100%', height: 48, display: 'block' }} />;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(v)}
</Typography.Text>
),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 160,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(v)}
</Typography.Text>
),
},
{
title: '操作',
width: 240,
render: (_: any, record: OpenType) => (
<Space>
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleDetail(record.id)}
>
</Button>
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleUpdate(record.id)}
>
</Button>
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(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>
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => load()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={openTypes}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
showTotal: (t) => `${t} 条记录`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
load(p, ps);
},
}}
scroll={{ x: 1000 }}
/>
</Card>
<Modal
title="新增开户方式"
open={createModalVisible}
onOk={handleCreate}
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields();
setCreateThumbUrl('');
}}
mask={{ closable: false }}
okText="创建"
cancelText="取消"
width={600}
>
<Form form={createForm} layout="vertical">
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="open_type"
label="开户方式Id"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入开户方式Id' }]}
>
<InputNumber style={{ width: '100%' }} min={1} placeholder="请输入开户方式Id" />
</Form.Item>
<Form.Item
name="type_name"
label="类型名称"
style={{ flex: 1 }}
rules={[
{ required: true, message: '请输入类型名称' },
{ max: 100, message: '类型名称不能超过100个字符' },
]}
>
<Input style={{ width: '100%' }} placeholder="请输入类型名称" />
</Form.Item>
</div>
<Form.Item
name="description"
label="描述"
rules={[{ required: true, message: '请输入描述' }]}
>
<Input.TextArea placeholder="请输入描述" rows={4} />
</Form.Item>
<Form.Item label="缩略图">
<Upload
listType="picture-card"
fileList={createThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: createThumbUrl }] : []}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
setCreateThumbUrl(fullUrl);
onSuccess?.({ url: fullUrl });
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
onRemove={() => setCreateThumbUrl('')}
>
{!createThumbUrl && (
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
)}
</Upload>
</Form.Item>
</Form>
</Modal>
<Modal
title="开户方式详情"
open={detailModalVisible}
onOk={() => setDetailModalVisible(false)}
onCancel={() => {
setDetailModalVisible(false);
setCurrentOpenType(null);
}}
mask={{ closable: false }}
okText="关闭"
cancelText="取消"
width={520}
>
{currentOpenType && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8fafc', borderRadius: 8 }}>
<div style={{
width: 80, height: 48, borderRadius: 6, overflow: 'hidden',
background: currentOpenType.thumb ? 'transparent' : '#e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{currentOpenType.thumb ? (
<img
src={currentOpenType.thumb.startsWith('http') ? currentOpenType.thumb : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${currentOpenType.thumb}`}
alt="thumb"
style={{ width: '100%', height: '100%', display: 'block' }}
/>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
)}
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600 }}>{currentOpenType.typeName}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>: {currentOpenType.openType}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>ID</Typography.Text>
<Typography.Text>{currentOpenType.id}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{currentOpenType.openType}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{currentOpenType.typeName}</Typography.Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Typography.Text type="secondary" style={{ width: 80 }}></Typography.Text>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 6, fontSize: 13, lineHeight: 1.6 }}>
{currentOpenType.description || '-'}
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{formatDate(currentOpenType.createdAt)}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{formatDate(currentOpenType.updatedAt)}</Typography.Text>
</div>
</div>
</div>
)}
</Modal>
<Modal
title="更新开户方式"
open={updateModalVisible}
onOk={handleSaveUpdate}
onCancel={() => {
setUpdateModalVisible(false);
updateForm.resetFields();
setCurrentOpenType(null);
}}
afterOpenChange={(open) => {
if (open && currentOpenType) {
updateForm.setFieldsValue({
open_type: currentOpenType.openType,
type_name: currentOpenType.typeName,
description: currentOpenType.description,
});
}
}}
mask={{ closable: false }}
okText="更新"
cancelText="取消"
width={600}
>
<Form form={updateForm} layout="vertical">
<Form.Item
name="open_type"
label="开户类型"
rules={[{ required: true, message: '请输入开户类型' }]}
>
<InputNumber min={1} placeholder="请输入开户类型编号" />
</Form.Item>
<Form.Item
name="type_name"
label="类型名称"
rules={[
{ required: true, message: '请输入类型名称' },
{ max: 100, message: '类型名称不能超过100个字符' },
]}
>
<Input placeholder="请输入类型名称" />
</Form.Item>
<Form.Item
name="description"
label="描述"
rules={[{ required: true, message: '请输入描述' }]}
>
<Input.TextArea placeholder="请输入描述" rows={4} />
</Form.Item>
<Form.Item label="缩略图">
<Upload
listType="picture-card"
fileList={updateThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: updateThumbUrl }] : []}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
setUpdateThumbUrl(fullUrl);
onSuccess?.({ url: fullUrl });
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
onRemove={() => setUpdateThumbUrl('')}
>
{!updateThumbUrl && (
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
)}
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminPlatform;
@@ -0,0 +1,502 @@
import React, { useEffect, useState } from 'react';
import { Button, Input, Table, Pagination, Tag, Typography, DatePicker, message, Tooltip } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { getPreTestTemplateList, getArea } from '../api';
import dayjs from 'dayjs';
interface PreTestField {
id: string;
name: string;
label: string;
description: string;
}
const PRETEST_FIELDS: PreTestField[] = [
{ id: 'AD_APP_ACTIVATE', name: 'AD_APP_ACTIVATE', label: '应用-激活', description: '应用激活行为' },
{ id: 'AD_APP_AUTH', name: 'AD_APP_AUTH', label: '应用-授信', description: '应用授信行为' },
{ id: 'AD_APP_BOOK', name: 'AD_APP_BOOK', label: '应用-预约表单', description: '应用预约表单' },
{ id: 'AD_APP_BUY', name: 'AD_APP_BUY', label: '应用-APP内付费', description: 'APP内付费行为' },
{ id: 'AD_APP_CLICKS', name: 'AD_APP_CLICKS', label: '应用-点击量', description: '应用点击量' },
{ id: 'AD_APP_DETAIL', name: 'AD_APP_DETAIL', label: '应用-APP内详情页到站UV', description: 'APP内详情页到站UV' },
{ id: 'AD_APP_DOWNLOADED', name: 'AD_APP_DOWNLOADED', label: '应用-下载完成', description: '应用下载完成' },
{ id: 'AD_APP_INSTALLED', name: 'AD_APP_INSTALLED', label: '应用-安装完成', description: '应用安装完成' },
{ id: 'AD_APP_KEY_BEHAVIOR', name: 'AD_APP_KEY_BEHAVIOR', label: '应用-关键行为', description: '应用关键行为' },
{ id: 'AD_APP_ORDER', name: 'AD_APP_ORDER', label: '应用-APP内下单', description: 'APP内下单行为' },
{ id: 'AD_APP_PAY', name: 'AD_APP_PAY', label: '应用-付费', description: '应用付费行为' },
{ id: 'AD_APP_PRE_AUTH', name: 'AD_APP_PRE_AUTH', label: '应用-预授信', description: '应用预授信行为' },
{ id: 'AD_APP_PRE_DOWNLOAD', name: 'AD_APP_PRE_DOWNLOAD', label: '应用-预约下载', description: '应用预约下载' },
{ id: 'AD_APP_PUSH_ORDER', name: 'AD_APP_PUSH_ORDER', label: '应用-首次发单(乘客)', description: '首次发单行为' },
{ id: 'AD_APP_REGISTER', name: 'AD_APP_REGISTER', label: '应用-注册', description: '应用注册行为' },
{ id: 'AD_APP_SHOW', name: 'AD_APP_SHOW', label: '应用-展示量', description: '应用展示量' },
{ id: 'AD_APP_SUBMIT', name: 'AD_APP_SUBMIT', label: '应用-提交认证', description: '应用提交认证' },
{ id: 'AD_APP_VIEW', name: 'AD_APP_VIEW', label: '应用-APP内访问', description: 'APP内访问行为' },
{ id: 'AD_CLUE_AUTH', name: 'AD_CLUE_AUTH', label: '销售线索收集-授信', description: '销售线索授信' },
{ id: 'AD_CLUE_BUTTON', name: 'AD_CLUE_BUTTON', label: '销售线索收集-按钮跳转', description: '按钮跳转' },
{ id: 'AD_CLUE_CLICKS', name: 'AD_CLUE_CLICKS', label: '销售线索收集-点击量', description: '点击量' },
{ id: 'AD_CLUE_CONFIRM', name: 'AD_CLUE_CONFIRM', label: '销售线索收集-回访-信息确认', description: '回访信息确认' },
{ id: 'AD_CLUE_CONSULT', name: 'AD_CLUE_CONSULT', label: '销售线索收集-有效咨询', description: '有效咨询' },
{ id: 'AD_CLUE_CONSULT_MSG', name: 'AD_CLUE_CONSULT_MSG', label: '销售线索收集-留资咨询', description: '留资咨询' },
{ id: 'AD_CLUE_COUPON', name: 'AD_CLUE_COUPON', label: '销售线索收集-卡券领取', description: '卡券领取' },
{ id: 'AD_CLUE_CUSTOMER', name: 'AD_CLUE_CUSTOMER', label: '销售线索收集-有效获客', description: '有效获客' },
{ id: 'AD_CLUE_CVT', name: 'AD_CLUE_CVT', label: '销售线索收集-多转化', description: '多转化' },
{ id: 'AD_CLUE_DONE', name: 'AD_CLUE_DONE', label: '销售线索收集-完件', description: '完件' },
{ id: 'AD_CLUE_FORM', name: 'AD_CLUE_FORM', label: '销售线索收集-表单提交', description: '表单提交' },
{ id: 'AD_CLUE_FRIENDS', name: 'AD_CLUE_FRIENDS', label: '销售线索收集-回访-加为好友', description: '回访加为好友' },
{ id: 'AD_CLUE_INSURANCE', name: 'AD_CLUE_INSURANCE', label: '销售线索收集-保险支付', description: '保险支付' },
{ id: 'AD_CLUE_INTENTION', name: 'AD_CLUE_INTENTION', label: '销售线索收集-存在意向', description: '存在意向' },
{ id: 'AD_CLUE_INTENTION_FORM', name: 'AD_CLUE_INTENTION_FORM', label: '销售线索收集-意向表单', description: '意向表单' },
{ id: 'AD_CLUE_INTENTION_TEL', name: 'AD_CLUE_INTENTION_TEL', label: '销售线索收集-意向话单', description: '意向话单' },
{ id: 'AD_CLUE_MESSAGE', name: 'AD_CLUE_MESSAGE', label: '销售线索收集-私信消息', description: '私信消息' },
{ id: 'AD_CLUE_MONEY', name: 'AD_CLUE_MONEY', label: '销售线索收集-放款', description: '放款' },
{ id: 'AD_CLUE_MSG', name: 'AD_CLUE_MSG', label: '销售线索收集-私信留资', description: '私信留资' },
{ id: 'AD_CLUE_PAGE', name: 'AD_CLUE_PAGE', label: '销售线索收集-访问目标页面', description: '访问目标页面' },
{ id: 'AD_CLUE_PAY', name: 'AD_CLUE_PAY', label: '销售线索收集-付费', description: '付费' },
{ id: 'AD_CLUE_PRE_AUTH', name: 'AD_CLUE_PRE_AUTH', label: '销售线索收集-预授信', description: '预授信' },
{ id: 'AD_CLUE_PROTENTIAL_DEAL', name: 'AD_CLUE_PROTENTIAL_DEAL', label: '销售线索收集-回访-高潜成交', description: '回访高潜成交' },
{ id: 'AD_CLUE_PUSH_ORDER', name: 'AD_CLUE_PUSH_ORDER', label: '销售线索收集-首次发单(乘客)', description: '首次发单' },
{ id: 'AD_CLUE_REGISTER', name: 'AD_CLUE_REGISTER', label: '销售线索收集-注册', description: '注册' },
{ id: 'AD_CLUE_SHOW', name: 'AD_CLUE_SHOW', label: '销售线索收集-展示量', description: '展示量' },
{ id: 'AD_CLUE_SUBMIT', name: 'AD_CLUE_SUBMIT', label: '销售线索收集-提交认证', description: '提交认证' },
{ id: 'AD_CLUE_TEL', name: 'AD_CLUE_TEL', label: '销售线索收集-智能电话确认接通', description: '智能电话确认接通' },
{ id: 'AD_CLUE_TEL_CALL', name: 'AD_CLUE_TEL_CALL', label: '销售线索收集-电话接通', description: '电话接通' },
{ id: 'AD_CLUE_WX_ADD', name: 'AD_CLUE_WX_ADD', label: '销售线索收集-微信-添加企业微信', description: '添加企业微信' },
{ id: 'AD_CLUE_WX_COPY', name: 'AD_CLUE_WX_COPY', label: '销售线索收集-微信复制', description: '微信复制' },
{ id: 'AD_CLUE_WX_MSG', name: 'AD_CLUE_WX_MSG', label: '销售线索收集-微信-用户首次消息', description: '用户首次消息' },
{ id: 'AD_ECP_APP_BUY', name: 'AD_ECP_APP_BUY', label: '电商-app内下单', description: '电商app内下单' },
{ id: 'AD_ECP_APP_DETAIL', name: 'AD_ECP_APP_DETAIL', label: '电商-app内详情页到站uv', description: '电商app内详情页到站uv' },
{ id: 'AD_ECP_APP_VIEW', name: 'AD_ECP_APP_VIEW', label: '电商-app内访问', description: '电商app内访问' },
{ id: 'AD_ECP_BUTTON', name: 'AD_ECP_BUTTON', label: '电商-按钮跳转', description: '电商按钮跳转' },
{ id: 'AD_ECP_INTEREST', name: 'AD_ECP_INTEREST', label: '电商-引流电商种草', description: '引流电商种草' },
{ id: 'AD_ECP_SHOP', name: 'AD_ECP_SHOP', label: '电商-调起店铺', description: '调起店铺' },
{ id: 'AD_ECP_SHOP_STAY', name: 'AD_ECP_SHOP_STAY', label: '电商-店铺停留', description: '店铺停留' },
{ id: 'AD_MINIAPP_ACTIVATE', name: 'AD_MINIAPP_ACTIVATE', label: '快应用-激活', description: '快应用激活' },
{ id: 'AD_MINIAPP_KEY_BEHAVIOR', name: 'AD_MINIAPP_KEY_BEHAVIOR', label: '快应用-关键行为', description: '快应用关键行为' },
{ id: 'AD_MINIAPP_PAY', name: 'AD_MINIAPP_PAY', label: '快应用-付费', description: '快应用付费' },
{ id: 'AD_MINIAPP_REGISTER', name: 'AD_MINIAPP_REGISTER', label: '快应用-注册', description: '快应用注册' },
{ id: 'AD_NATIVE_ACTIVATE', name: 'AD_NATIVE_ACTIVATE', label: '原声互动-激活', description: '原声互动激活' },
{ id: 'AD_NATIVE_CLICKS', name: 'AD_NATIVE_CLICKS', label: '原声互动-组件点击', description: '组件点击' },
{ id: 'AD_NATIVE_FANS_GROUP', name: 'AD_NATIVE_FANS_GROUP', label: '原声互动-粉丝入群', description: '粉丝入群' },
{ id: 'AD_NATIVE_FOLLOW', name: 'AD_NATIVE_FOLLOW', label: '原声互动-帐号关注', description: '帐号关注' },
{ id: 'AD_NATIVE_INTERACTIVE', name: 'AD_NATIVE_INTERACTIVE', label: '原声互动-互动', description: '互动' },
{ id: 'AD_NATIVE_LIVE', name: 'AD_NATIVE_LIVE', label: '原声互动-预约直播', description: '预约直播' },
{ id: 'AD_NATIVE_LIVE_DONATE', name: 'AD_NATIVE_LIVE_DONATE', label: '原声互动-直播间营销捐赠', description: '直播间营销捐赠' },
{ id: 'AD_NATIVE_LIVE_PAY', name: 'AD_NATIVE_LIVE_PAY', label: '原声互动-直播间打赏', description: '直播间打赏' },
{ id: 'AD_NATIVE_LIVE_STAY', name: 'AD_NATIVE_LIVE_STAY', label: '原声互动-直播间停留', description: '直播间停留' },
{ id: 'AD_NATIVE_LIVE_VIEW', name: 'AD_NATIVE_LIVE_VIEW', label: '原声互动-直播间观看', description: '直播间观看' },
{ id: 'AD_NATIVE_PAY', name: 'AD_NATIVE_PAY', label: '原声互动-付费', description: '原声互动付费' },
{ id: 'AD_PRODUCT_ACTIVATE', name: 'AD_PRODUCT_ACTIVATE', label: '商品-激活', description: '商品激活' },
{ id: 'AD_PRODUCT_APP_BUY', name: 'AD_PRODUCT_APP_BUY', label: '商品-app内下单', description: '商品app内下单' },
{ id: 'AD_PRODUCT_APP_DETAIL', name: 'AD_PRODUCT_APP_DETAIL', label: '商品-app内详情页到站uv', description: '商品app内详情页到站uv' },
{ id: 'AD_PRODUCT_APP_PAY', name: 'AD_PRODUCT_APP_PAY', label: '商品-app内付费', description: '商品app内付费' },
{ id: 'AD_PRODUCT_APP_VIEW', name: 'AD_PRODUCT_APP_VIEW', label: '商品-app内访问', description: '商品app内访问' },
{ id: 'AD_PRODUCT_FORM', name: 'AD_PRODUCT_FORM', label: '商品-表单提交', description: '商品表单提交' },
{ id: 'AD_PRODUCT_KEY_BEHAVIOR', name: 'AD_PRODUCT_KEY_BEHAVIOR', label: '商品-关键行为', description: '商品关键行为' },
{ id: 'AD_PRODUCT_PAY', name: 'AD_PRODUCT_PAY', label: '商品-付费', description: '商品付费' },
{ id: 'AD_TINYAPP_ACTIVATE', name: 'AD_TINYAPP_ACTIVATE', label: '小程序-激活', description: '小程序激活' },
{ id: 'AD_TINYAPP_KEY_BEHAVIOR', name: 'AD_TINYAPP_KEY_BEHAVIOR', label: '小程序-关键行为', description: '小程序关键行为' },
{ id: 'AD_TINYAPP_PAY', name: 'AD_TINYAPP_PAY', label: '小程序-付费', description: '小程序付费' },
{ id: 'QC_LIVE_BUY', name: 'QC_LIVE_BUY', label: '直播投放-直播间下单', description: '直播间下单' },
{ id: 'QC_LIVE_CHECK', name: 'QC_LIVE_CHECK', label: '直播投放-直播间结算', description: '直播间结算' },
{ id: 'QC_LIVE_COMMENTS', name: 'QC_LIVE_COMMENTS', label: '直播投放-直播间评论', description: '直播间评论' },
{ id: 'QC_LIVE_DEAL', name: 'QC_LIVE_DEAL', label: '直播投放-直播间成交', description: '直播间成交' },
{ id: 'QC_LIVE_ENTRY', name: 'QC_LIVE_ENTRY', label: '直播投放-进入直播间', description: '进入直播间' },
{ id: 'QC_LIVE_FANS', name: 'QC_LIVE_FANS', label: '直播投放-直播间粉丝提升', description: '直播间粉丝提升' },
{ id: 'QC_LIVE_HIT', name: 'QC_LIVE_HIT', label: '直播投放-直播加热', description: '直播加热' },
{ id: 'QC_LIVE_PRODUCT_CLICKS', name: 'QC_LIVE_PRODUCT_CLICKS', label: '直播投放-直播间商品点击', description: '直播间商品点击' },
{ id: 'QC_LIVE_ROI_CHECK', name: 'QC_LIVE_ROI_CHECK', label: '直播投放-结算roi', description: '结算roi' },
{ id: 'QC_LIVE_ROI_DEAL', name: 'QC_LIVE_ROI_DEAL', label: '直播投放-支付roi-直播间成交', description: '支付roi直播间成交' },
{ id: 'QC_LIVE_ROI_QC', name: 'QC_LIVE_ROI_QC', label: '直播投放-支付roi-千川直接+间接订单', description: '支付roi千川订单' },
{ id: 'QC_PRODUCT_BUY', name: 'QC_PRODUCT_BUY', label: '商品投放-商品购买', description: '商品购买' },
{ id: 'QC_PRODUCT_COMMENTS', name: 'QC_PRODUCT_COMMENTS', label: '商品投放-点赞评论', description: '点赞评论' },
{ id: 'QC_PRODUCT_FANS', name: 'QC_PRODUCT_FANS', label: '商品投放-粉丝提升', description: '粉丝提升' },
{ id: 'QC_PRODUCT_INTEREST', name: 'QC_PRODUCT_INTEREST', label: '商品投放-人群种草', description: '人群种草' },
{ id: 'QC_PRODUCT_QC', name: 'QC_PRODUCT_QC', label: '商品投放-千川直接+间接订单', description: '千川订单' },
{ id: 'QC_PRODUCT_ROI', name: 'QC_PRODUCT_ROI', label: '商品投放-商品支付roi', description: '商品支付roi' },
];
const formatDateTime = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const { Text } = Typography;
const AdminPreTestTemplates: React.FC = () => {
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchId, setSearchId] = useState('');
const [searchPhone, setSearchPhone] = useState('');
const [searchCreatedAt, setSearchCreatedAt] = useState<[dayjs.Dayjs, dayjs.Dayjs] | undefined>();
const [cityCodeMap, setCityCodeMap] = useState<Record<string, string>>({});
useEffect(() => {
loadProvinceOptions();
}, []);
const loadProvinceOptions = async () => {
try {
const cachedData = localStorage.getItem('preTestAreaData');
if (cachedData) {
const parsed = JSON.parse(cachedData);
setCityCodeMap(parsed.cityCodeMap);
return;
}
const res = await getArea({ level: 'ONE_LEVEL' });
const provinces = res.data || [];
const map: Record<string, string> = {};
await Promise.all(
provinces.map(async (province: any) => {
let cities: any[] = [];
try {
const cityRes = await getArea({ level: 'TWO_LEVEL', parent_code: province.code });
cities = cityRes.data || [];
cities.forEach((city: any) => {
map[city.code] = city.name;
});
} catch (error) {
console.error(`获取省份 ${province.name} 的城市失败`, error);
}
})
);
const cacheData = { cityCodeMap: map };
localStorage.setItem('preTestAreaData', JSON.stringify(cacheData));
setCityCodeMap(map);
} catch (error) {
console.error('加载区域数据失败:', error);
message.error('加载区域数据失败');
}
};
const columns = [
{
title: '序号',
dataIndex: 'index',
key: 'index',
width: 60,
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 120,
render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
},
// {
// title: '手机号',
// dataIndex: 'phone',
// key: 'phone',
// width: 150,
// },
{
title: '投放平台',
dataIndex: 'platform',
key: 'platform',
width: 100,
render: (text: string) => (
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'} style={{ borderRadius: 6, fontSize: 12 }}>
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
</Tag>
),
},
{
title: '优化目标',
dataIndex: 'externalAction',
key: 'externalAction',
ellipsis: true,
width: 200,
render: (text: string) => {
if (!text) return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
const field = PRETEST_FIELDS.find(f => f.name === text.trim());
return (
<span style={{ color: '#1e293b', fontSize: 13 }}>{field ? field.label : text}</span>
);
},
},
{
title: '目标转化成本',
dataIndex: 'cpaBid',
key: 'cpaBid',
width: 100,
},
{
title: '受众年龄',
dataIndex: 'audienceAge',
key: 'audienceAge',
width: 100,
},
{
title: '受众性别',
dataIndex: 'audienceGender',
key: 'audienceGender',
width: 100,
},
{
title: '受众网络',
dataIndex: 'audienceNetwork',
key: 'audienceNetwork',
width: 100,
},
{
title: '受众区域',
dataIndex: 'audienceRegion',
key: 'audienceRegion',
ellipsis: true,
width: 300,
render: (text: string) => {
const arr = JSON.parse(text);
if (!Array.isArray(arr) || arr.length === 0) {
return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
}
const cityNames = arr.map(code => {
const paddedCode = String(code).padEnd(6, '0');
return cityCodeMap[paddedCode] || paddedCode;
});
return (
<Input.TextArea
value={cityNames.join(', ') || '-'}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
placeholder="-"
/>
)
},
},
{
title: '客户主体名称',
dataIndex: 'cusName',
key: 'cusName',
width: 150,
},
{
title: '计费方式',
dataIndex: 'pricingType',
key: 'pricingType',
width: 100,
},
{
title: '是否最优成本出价',
dataIndex: 'costCap',
key: 'costCap',
width: 100,
},
{
title: '是否稳定成本出价',
dataIndex: 'targetCost',
key: 'targetCost',
width: 100,
},
{
title: '是否最大转化出价',
dataIndex: 'nobid',
key: 'nobid',
width: 100,
},
{
title: '目标点击成本',
dataIndex: 'cpcBid',
key: 'cpcBid',
width: 100,
},
{
title: '预算金额',
dataIndex: 'budget',
key: 'budget',
width: 100,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
},
{
title: '备注',
dataIndex: 'note',
key: 'note',
width: 150,
},
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 110,
render: (v: string) => {
if (!v) return '-';
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
return (
<Tooltip title={v} placement="topLeft">
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
</Tooltip>
);
},
},
{
title: '用户手机号',
dataIndex: 'userPhone',
key: 'userPhone',
width: 150,
},
{
title: '是否默认',
dataIndex: 'isDefault',
key: 'isDefault',
width: 100,
render: (text: boolean) => <Tag color={text ? 'green' : 'red'}>{text ? '是' : '否'}</Tag>,
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
},
];
const loadRecords = async () => {
setLoading(true);
try {
const res = await getPreTestTemplateList({
id: searchId || undefined,
phone: searchPhone || undefined,
created_at: searchCreatedAt ? [searchCreatedAt[0].format('YYYY-MM-DD'), searchCreatedAt[1].format('YYYY-MM-DD')] : undefined,
page: currentPage,
page_size: pageSize,
});
const data = res.data || [];
const tableData = data.map((item: any, index: number) => ({
...item,
index: (currentPage - 1) * pageSize + index + 1,
}));
setTableData(tableData);
setTotal(res.pagination.total || 0);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRecords();
}, [currentPage, pageSize]);
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
setPageSize(size);
};
const handleSearch = () => {
setCurrentPage(1);
loadRecords();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Text strong style={{ fontSize: 16 }}></Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Input
placeholder="ID"
value={searchId}
onChange={(e) => setSearchId(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<Input
placeholder="手机号"
value={searchPhone}
onChange={(e) => setSearchPhone(e.target.value)}
style={{ width: 160 }}
allowClear
onPressEnter={handleSearch}
/>
<DatePicker.RangePicker
placeholder={['开始日期', '结束日期']}
value={searchCreatedAt}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setSearchCreatedAt([dates[0], dates[1]]);
} else {
setSearchCreatedAt(undefined);
}
}}
style={{ width: 260 }}
/>
<Button
type="primary"
onClick={handleSearch}
>
</Button>
</div>
</div>
<div style={{
background: '#fff',
borderRadius: 16,
boxShadow: '0 4px 20px rgba(0,0,0,0.04)',
overflow: 'hidden',
}}>
<Table
dataSource={tableData}
columns={columns}
loading={loading}
pagination={false}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
onRow={() => ({
style: {
cursor: 'pointer',
transition: 'background-color 0.15s',
},
onMouseEnter: (e: React.MouseEvent) => {
(e.currentTarget as HTMLElement).style.backgroundColor = '#f8fafc';
},
onMouseLeave: (e: React.MouseEvent) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
},
})}
/>
<div style={{ padding: '16px', textAlign: 'right' }}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showSizeChanger
showTotal={(total) => `${total} 条记录`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={handlePageChange}
size="small"
/>
</div>
</div>
</div>
);
};
export default AdminPreTestTemplates;
@@ -0,0 +1,264 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
import { formatDate } from '../utils/formatDate';
import { apiUrl } from '../utils/resourceUrl';
const { Title, Text } = Typography;
const PAGE_SIZE = 20;
const LIBRARY_OPTIONS = [
{ label: '全部素材库', value: '' },
{ label: '真人认证', value: 'real_person' },
{ label: '虚拟人像', value: 'aigc_virtual' },
];
const ASSET_TYPE_OPTIONS = [
{ label: '全部类型', value: '' },
{ label: '图片', value: 'Image' },
{ label: '视频', value: 'Video' },
];
const PROJECT_STATUS_OPTIONS = [
{ label: '全部项目状态', value: '' },
{ label: '可用', value: 'active' },
{ label: '认证中', value: 'validating' },
{ label: '认证失败', value: 'validate_failed' },
{ label: '创建远端组中', value: 'creating_remote_group' },
{ label: '创建远端组失败', value: 'create_group_failed' },
{ label: '已删除', value: 'deleted' },
];
const ASSET_STATUS_OPTIONS = [
{ label: '全部素材状态', value: '' },
{ label: '创建中', value: 'creating' },
{ label: '处理中', value: 'Processing' },
{ label: '可用', value: 'Active' },
{ label: '失败', value: 'Failed' },
{ label: '本地已删', value: 'local_deleted' },
{ label: '远端已删', value: 'remote_deleted' },
{ label: '删除失败', value: 'delete_failed' },
];
const libraryLabel = (value?: string | null) => {
if (value === 'real_person') return '真人认证';
if (value === 'aigc_virtual') return '虚拟人像';
return value || '-';
};
const libraryColor = (value?: string | null) => {
if (value === 'real_person') return 'purple';
if (value === 'aigc_virtual') return 'cyan';
return 'default';
};
const statusColor = (status?: string) => {
if (status === 'Active' || status === 'active') return 'green';
if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
if (status?.includes('deleted')) return 'default';
return 'default';
};
const assetTypeLabel = (value?: string | null) => {
if (value === 'Image') return '图片';
if (value === 'Video') return '视频';
return value || '-';
};
const shortId = (value?: string | null, keep = 10) => {
if (!value) return '-';
if (value.length <= keep * 2 + 3) return value;
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
};
const copyText = async (value?: string | null) => {
if (!value) return;
await navigator.clipboard?.writeText(value);
message.success('已复制');
};
const toPreviewUrl = (value?: string | null) => {
if (!value) return '';
const trimmed = String(value).trim();
if (!trimmed || trimmed.startsWith('asset://')) return '';
return apiUrl(trimmed);
};
const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => {
const url = toPreviewUrl(asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl);
const cover = toPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.sourceUrl);
if (asset.assetType === 'Video') {
return (
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
</div>
);
}
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
};
const AdminPrivatePortraitProjects: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [projectTotal, setProjectTotal] = useState(0);
const [assetTotal, setAssetTotal] = useState(0);
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false);
const [keyword, setKeyword] = useState('');
const [libraryType, setLibraryType] = useState('');
const [assetType, setAssetType] = useState('');
const [projectStatus, setProjectStatus] = useState('');
const [assetStatus, setAssetStatus] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
const [projectPage, setProjectPage] = useState(1);
const [assetPage, setAssetPage] = useState(1);
const filters = useMemo(() => ({
keyword: keyword.trim() || undefined,
libraryType: libraryType || undefined,
assetType: assetType || undefined,
projectStatus: projectStatus || undefined,
assetStatus: assetStatus || undefined,
}), [keyword, libraryType, assetType, projectStatus, assetStatus]);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const res = await adminGetPrivatePortraitProjects({
keyword: filters.keyword,
libraryType: filters.libraryType,
status: filters.projectStatus,
page: projectPage,
pageSize: PAGE_SIZE,
});
setProjects(res.items || []);
setProjectTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '加载私域人像素材项目失败');
} finally {
setLoadingProjects(false);
}
};
const loadAssets = async () => {
setLoadingAssets(true);
try {
const res = await adminGetPrivatePortraitAssets({
projectId: selectedProjectId,
keyword: filters.keyword,
libraryType: filters.libraryType,
assetType: filters.assetType,
status: filters.assetStatus,
page: assetPage,
pageSize: PAGE_SIZE,
});
setAssets(res.items || []);
setAssetTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '加载私域人像素材失败');
} finally {
setLoadingAssets(false);
}
};
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]);
const searchAll = () => {
setProjectPage(1);
setAssetPage(1);
setTimeout(() => { loadProjects(); loadAssets(); }, 0);
};
const resetFilters = () => {
setKeyword('');
setLibraryType('');
setAssetType('');
setProjectStatus('');
setAssetStatus('');
setSelectedProjectId(undefined);
setProjectPage(1);
setAssetPage(1);
};
const projectSummary = useMemo(() => {
return projects.reduce((acc, item) => {
acc.asset += item.assetCount || 0;
acc.image += item.imageAssetCount || 0;
acc.video += item.videoAssetCount || 0;
acc.active += item.activeAssetCount || 0;
return acc;
}, { asset: 0, image: 0, video: 0, active: 0 });
}, [projects]);
const projectColumns: ColumnsType<PrivatePortraitProject> = [
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
{ title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag> {r.assetCount || 0}</Tag><Tag color="blue"> {r.imageAssetCount || 0}</Tag><Tag color="geekblue"> {r.videoAssetCount || 0}</Tag></Space> },
{ title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
];
const assetColumns: ColumnsType<PrivatePortraitAsset> = [
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
{ title: '轮询', width: 160, render: (_, r) => <div><Text>{r.pollCount || 0}</Text><br /><Text type="secondary">{formatDate((r as any).nextPollAt)}</Text></div> },
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
];
return (
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
<div>
<Title level={3} style={{ margin: 0 }}></Title>
<Text type="secondary">/</Text>
</div>
<Space wrap>
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}></Button>
<Button onClick={resetFilters}></Button>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}></Button>
</Space>
</Space>
<Space size={16} wrap style={{ marginBottom: 16 }}>
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
</Space>
<Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
<Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
</Card>
<Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}></Button> : null}</Space>}>
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
</Card>
</div>
);
};
export default AdminPrivatePortraitProjects;
@@ -177,7 +177,7 @@ const AdminRechargePackages: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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' }} />
@@ -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">
@@ -251,14 +261,21 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
<VideoPromptSchemaViewer
schema={detail.videoGeneration?.promptSchema}
finalPrompt={detail.videoGeneration?.finalPrompt}
schemaConfigSnapshot={detail.videoGeneration?.schemaConfigSnapshot}
schemaConfigSource={detail.videoGeneration?.schemaConfigSource}
schemaConfigVersion={detail.videoGeneration?.schemaConfigVersion}
schemaConfigIsFallback={detail.videoGeneration?.schemaConfigIsFallback}
/>
<StepRawJson step={videoPromptStep} />
</Space>
),
},
{
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">
@@ -293,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 }}>
@@ -315,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>
@@ -329,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>
@@ -341,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
<Collapse
items={[
+428 -89
View File
@@ -1,12 +1,27 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Space, Switch, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons';
import { getSystemConfigs, updateSystemConfig, uploadPdf, uploadLogo } from '../api';
import type { SystemConfig } from '../types';
import {
createSystemConfig,
getGlobalResourceCapacity,
getSystemConfigs,
saveGlobalResourceCapacity,
updateSystemConfig,
uploadLogo,
uploadPdf,
uploadLoginVideo,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 1024 × 1024 × 1024 字节)' },
];
const AdminSettings: React.FC = () => {
const [configs, setConfigs] = useState<SystemConfig[]>([]);
@@ -14,7 +29,6 @@ const AdminSettings: React.FC = () => {
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
const [logoPreview, setLogoPreview] = useState('');
useEffect(() => {
load();
@@ -22,31 +36,128 @@ const AdminSettings: React.FC = () => {
const load = async () => {
setLoading(true);
const data = await getSystemConfigs();
setConfigs(data);
const formValues: Record<string, string> = {};
data.forEach(c => { formValues[c.key] = c.value; });
form.setFieldsValue(formValues);
setLoading(false);
try {
const [data, capacity] = await Promise.all([
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 编码' });
}
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// LLM 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
form.setFieldsValue(formValues);
} catch (e: any) {
message.error(e?.message || '加载配置失败');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) {
await updateSystemConfig(config.id, newVal ?? '');
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
? !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled).trim().toLowerCase(),
)
: false;
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true);
const llmManagedKeys = new Set([
'optimize_hold_credits',
'llm_billing_enabled',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
]);
for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
if (value === undefined || value === null || value === '') return;
const normalizedValue = String(value);
const existing = configs.find(c => c.key === key);
if (existing) {
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
} else {
await createSystemConfig(key, normalizedValue, description);
}
};
const enabledConfig = [
'llm_billing_enabled',
values.llm_billing_enabled,
'是否启用 LLM 统一预扣与真实扣费结算',
] as const;
const llmHoldConfigs = [
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
] as const;
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
if (!llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
for (const [key, value, description] of llmHoldConfigs) {
await saveManagedConfig(key, value, description);
}
if (llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
limitUnit: values.resource_capacity_limit_unit || 'GB',
});
message.success('系统配置已保存');
const data = await getSystemConfigs();
setConfigs(data);
setSaving(false);
await load();
} catch (e: any) {
setSaving(false);
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
@@ -54,89 +165,130 @@ const AdminSettings: React.FC = () => {
setUploading(configKey);
try {
const res = await uploadPdf(file, configKey);
// Update local state
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
form.setFieldsValue({ [configKey]: res.url });
message.success('PDF上传成功');
// Find the config and update to database
const config = configs.find(c => c.key === configKey);
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('PDF上传成功并已保存');
} catch {
message.error('上传失败');
} finally {
setUploading('');
}
return false; // prevent default upload
return false;
};
const handleLogoUpload = async (file: File) => {
setUploading('site_logo');
try {
const res = await uploadLogo(file);
// Update local state
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
form.setFieldsValue({ site_logo: res.url });
setLogoPreview(res.url);
message.success('Logo上传成功');
const config = configs.find(c => c.key === 'site_logo');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('Logo上传成功并已保存');
} catch {
message.error('上传失败');
} finally {
setUploading('');
}
return false; // prevent default upload
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 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 === 'user_agreement_url' || c.key === 'privacy_policy_url'),
'站点信息': 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'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件',
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件',
site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表',
user_register_credits: '用户注册时赠送的初始积分,默认100',
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'site_logo') {
return <LogoUploadField config={config} />;
}
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
if (config.key === 'user_login_credits_enabled') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch defaultChecked={config.value === 'true'} />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{config.value === 'true' ? '已启用' : '已禁用'}
</Typography.Text>
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
const LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const hasLogo = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const logoUrl = hasLogo ? `${baseUrl}${config.value}` : '';
const handleRemove = () => {
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: '' } : c));
form.setFieldsValue({ site_logo: '' });
@@ -172,17 +324,17 @@ const AdminSettings: React.FC = () => {
</div>
{hasLogo ? (
<div style={{ textAlign: 'center' }}>
<img
src={logoUrl}
alt="Logo预览"
style={{
maxWidth: 200,
maxHeight: 80,
<img
src={logoUrl}
alt="Logo预览"
style={{
maxWidth: 200,
maxHeight: 80,
objectFit: 'contain',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: 8,
}}
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
200x40px PNGJPG
@@ -198,8 +350,9 @@ const AdminSettings: React.FC = () => {
};
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const label = config.key === 'user_agreement_url' ? '用户协议' : '隐私政策';
const label = config.key === 'user_agreement_privacy_url' ? '用户协议及隐私政策' : '协议文件';
const hasFile = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return (
<div style={{
padding: '16px', borderRadius: 10,
@@ -214,7 +367,7 @@ const AdminSettings: React.FC = () => {
<Space>
{hasFile && (
<Button size="small" icon={<EyeOutlined />}
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}>
onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
</Button>
)}
@@ -236,40 +389,53 @@ const AdminSettings: React.FC = () => {
);
};
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'site_logo') {
return <LogoUploadField config={config} />;
}
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
if (config.key === 'user_login_credits_enabled') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch defaultChecked={config.value === 'true'} />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{config.value === 'true' ? '已启用' : '已禁用'}
</Typography.Text>
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) {
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
}
return (
<div style={{ maxWidth: 720 }}>
<Card bordered={false} 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}
@@ -283,6 +449,179 @@ const AdminSettings: React.FC = () => {
</div>
))}
</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>
{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>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
>
<Input type="number" min={0} placeholder="默认5" size="large" />
</Form.Item>
{[
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
].map(([name, label, extra]) => (
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
</Form.Item>
))}
</div>
</Form>
),
},
{
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>
</Form>
),
},
];
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"></Typography.Text>
</div>
</div>
<Tabs items={tabItems} defaultActiveKey="basic" />
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -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>
+378
View File
@@ -0,0 +1,378 @@
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 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 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 [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 [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>
),
},
];
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>
<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>
<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
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>
);
};
export default AdminTeams;
+511 -28
View File
@@ -1,19 +1,70 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined,
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons';
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig } from '../api';
import type { AdminUser, SystemConfig } from '../types';
import {
adjustCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
deleteUserResourceCapacity,
getAdminUsers,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
getUserResourceCapacity,
resetUserPassword,
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateUserTeam,
updateSystemConfig,
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 1024 × 1024 × 1024 字节)' },
];
function formatBytes(bytes?: number | null): string {
if (bytes === null || bytes === undefined) return '-';
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let size = value;
let unitIndex = -1;
do {
size /= 1024;
unitIndex += 1;
} while (size >= 1024 && unitIndex < units.length - 1);
return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
function capacitySourceLabel(capacity?: ResourceCapacityUsage | null): string {
if (!capacity || !capacity.enabled) return '未开启';
if (capacity.source === 'user') return '个人';
if (capacity.source === 'global') return '全局';
return '未开启';
}
const AdminUsers: React.FC = () => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<string>('frontend');
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
@@ -21,9 +72,20 @@ const AdminUsers: React.FC = () => {
const [allMenus, setAllMenus] = useState<any[]>([]);
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
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 [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false);
const [portraitLoading, setPortraitLoading] = useState(false);
const [portraitSaving, setPortraitSaving] = useState(false);
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [resetPwdForm] = Form.useForm();
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -36,14 +98,25 @@ const AdminUsers: React.FC = () => {
const load = async () => {
setLoading(true);
try {
const data = await getAdminUsers(page, pageSize, search || undefined);
const data = await getAdminUsers(
page,
pageSize,
search || undefined,
activeTab,
activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined,
activeTab === 'frontend' ? (teamFilter || undefined) : undefined,
);
setUsers(data.items || []);
setTotal(data.total || 0);
} catch { /* auth error handled by client */ }
setLoading(false);
};
useEffect(() => { load(); }, [page, pageSize, search]);
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter, teamFilter]);
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
useEffect(() => {
const loadCreditConfigs = async () => {
@@ -65,17 +138,17 @@ const AdminUsers: React.FC = () => {
setConfigSaving(true);
for (const config of creditConfigs) {
const newVal = values[config.key];
if (newVal !== undefined && newVal !== config.value) {
await updateSystemConfig(config.id, newVal ?? '');
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
message.success('积分配置已保存');
const configs = await getSystemConfigs();
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
setConfigSaving(false);
} catch (e: any) {
setConfigSaving(false);
message.error(e?.message || '保存失败');
} finally {
setConfigSaving(false);
}
};
@@ -116,6 +189,9 @@ const AdminUsers: React.FC = () => {
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
credits: values.credits || 0,
user_type: userType,
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
frontend_user_kind: values.frontend_user_kind || 'external',
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
});
message.success('用户创建成功');
setCreateModal(false);
@@ -140,7 +216,125 @@ const AdminUsers: React.FC = () => {
}
};
// Build structured menu display: groups with children, and top-level pages
const openCapacityModal = async (user: AdminUser) => {
setCapacityLoading(true);
setCapacityModal({ open: true, user, detail: null });
try {
const detail = await getUserResourceCapacity(user.id);
const initialConfig = detail.userConfig;
capacityForm.setFieldsValue({
enabled: initialConfig?.enabled ?? false,
limitValue: initialConfig?.limitValue ?? detail.effective.limitValue ?? detail.globalConfig.limitValue ?? '1.000',
limitUnit: initialConfig?.limitUnit ?? detail.effective.limitUnit ?? detail.globalConfig.limitUnit ?? 'GB',
});
setCapacityModal({ open: true, user, detail });
} catch (e: any) {
message.error(e?.message || '加载容量配置失败');
setCapacityModal({ open: false, user: null, detail: null });
} finally {
setCapacityLoading(false);
}
};
const handleSaveCapacity = async () => {
const { user } = capacityModal;
if (!user) return;
try {
const values = await capacityForm.validateFields();
setCapacitySaving(true);
await saveUserResourceCapacity(user.id, {
enabled: !!values.enabled,
limitValue: String(values.limitValue ?? '1.000'),
limitUnit: values.limitUnit || 'GB',
});
message.success('用户容量配置已保存');
setCapacityModal({ open: false, user: null, detail: null });
capacityForm.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setCapacitySaving(false);
}
};
const handleRestoreGlobalCapacity = async () => {
const { user } = capacityModal;
if (!user) return;
try {
setCapacitySaving(true);
await deleteUserResourceCapacity(user.id);
message.success('已恢复为全局容量配置');
setCapacityModal({ open: false, user: null, detail: null });
capacityForm.resetFields();
load();
} catch (e: any) {
message.error(e?.message || '恢复失败');
} finally {
setCapacitySaving(false);
}
};
const openPortraitModal = async (user: AdminUser) => {
setPortraitLoading(true);
setPortraitModal({ open: true, user, config: null });
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
try {
const config = await adminGetPrivatePortraitConfig(user.id);
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
setPortraitModal({ open: true, user, config });
} catch (e: any) {
message.error(e?.message || '加载私域人像素材库配置失败');
setPortraitModal({ open: false, user: null, config: null });
} finally {
setPortraitLoading(false);
}
};
const handleSavePortraitConfig = async () => {
const { user } = portraitModal;
if (!user) return;
try {
const values = await portraitForm.validateFields();
const limit = Number(values.privatePortraitAssetLimit ?? 0);
setPortraitSaving(true);
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit}` : '私域人像素材库已关闭');
setPortraitModal({ open: false, user: null, config });
portraitForm.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存私域人像素材库配置失败');
} finally {
setPortraitSaving(false);
}
};
const openTeamModal = (user: AdminUser) => {
teamForm.setFieldsValue({ teamId: user.teamId || '' });
setTeamModal({ open: true, user });
};
const handleSaveTeam = async () => {
const { user } = teamModal;
if (!user) return;
try {
const values = await teamForm.validateFields();
setTeamSaving(true);
await updateUserTeam(user.id, values.teamId || null);
message.success('用户团队已更新');
setTeamModal({ open: false, user: null });
teamForm.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存团队失败');
} finally {
setTeamSaving(false);
}
};
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
const childMap: Record<string, any[]> = {};
@@ -176,7 +370,26 @@ const AdminUsers: React.FC = () => {
} catch { /* validation */ }
};
const filteredUsers = users.filter(u => u.userType === activeTab);
const handleUpdateFrontendKind = async (user: AdminUser, kind: 'internal' | 'external') => {
try {
await updateFrontendUserKind(user.id, kind);
message.success(kind === 'internal' ? '已设为前台内部用户' : '已设为前台外部用户');
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
}
};
const handleToggleAdminStatus = async (user: AdminUser) => {
try {
await updateUserAdminStatus(user.id, !user.isAdmin);
message.success(user.isAdmin ? '已取消超级管理员' : '已设为超级管理员');
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
}
};
const isAdminTab = activeTab === 'admin';
const columns = [
@@ -195,9 +408,10 @@ const AdminUsers: React.FC = () => {
<div>
<div style={{ fontWeight: 600 }}>
{r.username}
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
{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>
),
@@ -214,6 +428,51 @@ const AdminUsers: React.FC = () => {
title: '手机号', dataIndex: 'phone', width: 130,
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
},
...(!isAdminTab ? [{
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
}] : []),
...(!isAdminTab ? [{
title: '团队', dataIndex: 'teamName', width: 140,
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
}] : []),
...(!isAdminTab ? [{
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
render: (v: number) => {
const limit = Number(v || 0);
return limit > 0 ? <Tag color="purple">{limit} </Tag> : <Tag></Tag>;
},
}] : []),
...(!isAdminTab ? [{
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
render: (capacity: ResourceCapacityUsage | null | undefined) => {
const usedText = formatBytes(capacity?.usedBytes || 0);
if (!capacity || !capacity.enabled) {
return (
<div>
<Space size={6} style={{ marginBottom: 4 }}>
<Tag></Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> {usedText}</Typography.Text>
</Space>
<Progress percent={0} size="small" showInfo={false} />
</div>
);
}
const percent = Math.min(Number(capacity.usagePercent || 0), 100);
return (
<div>
<Space size={6} style={{ marginBottom: 4 }}>
<Tag color={capacity.source === 'user' ? 'blue' : 'purple'}>{capacitySourceLabel(capacity)}</Tag>
{capacity.exceeded && <Tag color="red"></Tag>}
</Space>
<Progress percent={percent} size="small" status={capacity.exceeded ? 'exception' : 'active'} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{usedText} / {formatBytes(capacity.totalBytes)} {formatBytes(capacity.availableBytes)}
</Typography.Text>
</div>
);
},
}] : []),
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
@@ -229,15 +488,50 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 320, fixed: 'right' as const,
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4}>
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PictureOutlined />}
onClick={() => openPortraitModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<TeamOutlined />}
onClick={() => openTeamModal(r)}>
</Button>
)}
{!isAdminTab && r.frontendUserKind !== 'internal' && (
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}></Button>
)}
{!isAdminTab && r.frontendUserKind === 'internal' && (
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}></Button>
)}
{isAdminTab && (
<Popconfirm
title={r.isAdmin ? '确定取消该用户的超级管理员权限?' : '确定将该用户设为超级管理员?'}
onConfirm={() => handleToggleAdminStatus(r)}
>
<Button type="link" size="small" style={{ color: r.isAdmin ? '#f59e0b' : '#6366f1' }}
icon={<SecurityScanOutlined />}>
{r.isAdmin ? '取消超级管理员' : '设为超级管理员'}
</Button>
</Popconfirm>
)}
<Button type="link" size="small" icon={<MenuOutlined />}
onClick={() => openMenuModal(r)}>
@@ -262,7 +556,7 @@ const AdminUsers: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
{/* <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,
@@ -314,10 +608,9 @@ const AdminUsers: React.FC = () => {
</Button>
</div>
</Form>
</Card>
</Card> */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
{/* Search bar */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12 }}>
<Input
@@ -329,6 +622,30 @@ const AdminUsers: React.FC = () => {
style={{ width: 280, borderRadius: 8 }}
allowClear
/>
{!isAdminTab && (
<Select
value={frontendKindFilter}
onChange={(v) => { setPage(1); setFrontendKindFilter(v); }}
style={{ width: 140 }}
options={[
{ value: '', label: '全部前台用户' },
{ value: 'internal', label: '内部用户' },
{ value: 'external', label: '外部用户' },
]}
/>
)}
{!isAdminTab && (
<Select
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
)}
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}></Button>
</div>
<Button type="primary" icon={<PlusOutlined />}
@@ -340,7 +657,7 @@ const AdminUsers: React.FC = () => {
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setTeamFilter(''); setPage(1); }}
items={[
{ key: 'frontend', label: '前台用户' },
{ key: 'admin', label: '后台用户' },
@@ -349,7 +666,7 @@ const AdminUsers: React.FC = () => {
<Table
columns={columns}
dataSource={filteredUsers}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
@@ -360,11 +677,10 @@ const AdminUsers: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 个用户`,
}}
scroll={{ x: 1000 }}
scroll={{ x: 1280 }}
/>
</Card>
{/* Adjust Credits Modal */}
<Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
@@ -378,6 +694,24 @@ const AdminUsers: React.FC = () => {
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
@@ -395,7 +729,133 @@ const AdminUsers: React.FC = () => {
</Form>
</Modal>
{/* Create User Modal */}
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
confirmLoading={teamSaving}
onOk={handleSaveTeam}
onCancel={() => { setTeamModal({ open: false, user: null }); teamForm.resetFields(); }}
okText="保存" cancelText="取消" width={460}
>
<Form form={teamForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="teamId" label="所属团队" extra="团队归属独立于前台内部/外部归类,不会改变用户内外部设置。">
<Select
size="large"
allowClear
placeholder="未分配团队"
options={[
{ value: '', label: '未分配团队' },
...teamOptions
.filter(t => t.status === 'active' || t.id === teamModal.user?.teamId)
.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><DatabaseOutlined /> - {capacityModal.user?.username}</Space>}
open={capacityModal.open}
confirmLoading={capacitySaving}
onOk={handleSaveCapacity}
onCancel={() => { setCapacityModal({ open: false, user: null, detail: null }); capacityForm.resetFields(); }}
okText="保存个人配置" cancelText="取消" width={560}
>
<Card loading={capacityLoading} variant="outlined" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text type="secondary">
{capacitySourceLabel(capacityModal.detail?.effective)}
{capacityModal.detail?.effective.hasUserConfig ? '(已单独设置)' : '(未单独设置)'}
</Typography.Text>
<Typography.Text>
{formatBytes(capacityModal.detail?.effective.usedBytes)}
{formatBytes(capacityModal.detail?.effective.totalBytes)}
{formatBytes(capacityModal.detail?.effective.availableBytes)}
</Typography.Text>
{capacityModal.detail?.effective.enabled && (
<Progress
percent={Math.min(Number(capacityModal.detail.effective.usagePercent || 0), 100)}
status={capacityModal.detail.effective.exceeded ? 'exception' : 'active'}
/>
)}
</Space>
</Card>
<Form form={capacityForm} layout="vertical">
<Form.Item
name="enabled"
label="启用个人容量限制"
valuePropName="checked"
extra="保存后会生成用户个人配置,优先级高于全局;关闭并保存表示该用户个人明确不限制,不再走全局。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="limitValue"
label="容量数值"
rules={[{ required: true, message: '请输入容量数值' }]}
extra="最小为1,不能为负数,最多支持3位小数。"
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="limitUnit"
label="容量单位"
rules={[{ required: true, message: '请选择容量单位' }]}
extra="MB / GB / TB 固定枚举"
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</Form>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<Popconfirm
title="确定恢复为全局容量配置?"
onConfirm={handleRestoreGlobalCapacity}
disabled={!capacityModal.detail?.hasUserConfig}
>
<Button disabled={!capacityModal.detail?.hasUserConfig} loading={capacitySaving}>
</Button>
</Popconfirm>
</div>
</Modal>
<Modal
title={<Space><PictureOutlined /> - {portraitModal.user?.username}</Space>}
open={portraitModal.open}
confirmLoading={portraitSaving}
onOk={handleSavePortraitConfig}
onCancel={() => { setPortraitModal({ open: false, user: null, config: null }); portraitForm.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Card loading={portraitLoading} variant="outlined" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Typography.Text>
{portraitModal.config?.enabled ? <Tag color="purple"></Tag> : <Tag></Tag>}
</Typography.Text>
<Typography.Text type="secondary">
{portraitModal.config?.usedAssetCount ?? '-'}
{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0}
</Typography.Text>
</Space>
</Card>
<Form form={portraitForm} layout="vertical">
<Form.Item
name="privatePortraitAssetLimit"
label="私域人像素材总量上限"
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
>
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><UserOutlined /></Space>}
open={createModal}
@@ -412,6 +872,14 @@ const AdminUsers: React.FC = () => {
{ value: 'admin', label: '后台管理员' },
]} />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="frontend_user_kind" label="前台归类" initialValue="external">
<Select size="large" options={[
{ value: 'external', label: '外部用户' },
{ value: 'internal', label: '内部用户' },
]} />
</Form.Item>
)}
{createType === 'frontend' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input placeholder="请输入手机号" maxLength={11} size="large" />
@@ -429,13 +897,31 @@ const AdminUsers: React.FC = () => {
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
{createType === 'frontend' && (
<Form.Item
name="private_portrait_asset_limit"
label="私域人像素材总量上限"
initialValue={50}
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
>
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
{createType === 'admin' && (
<Form.Item name="is_admin" label="超级管理员" valuePropName="checked" initialValue={false}>
<Switch
checkedChildren="是"
unCheckedChildren="否"
/>
</Form.Item>
)}
<Form.Item name="email" label="邮箱">
<Input placeholder="选填" size="large" />
</Form.Item>
</Form>
</Modal>
{/* Menu Permission Modal */}
<Modal
title={<Space><MenuOutlined /> - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
open={menuModal.open}
@@ -451,14 +937,12 @@ const AdminUsers: React.FC = () => {
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{/* Top-level pages */}
{topLevelPages.map((m: any) => (
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
{m.label}
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
</Checkbox>
))}
{/* Groups with their children */}
{menuGroups.map((g: any) => {
const children = childMap[g.id] || [];
if (children.length === 0) return null;
@@ -483,7 +967,6 @@ const AdminUsers: React.FC = () => {
</div>
</Modal>
{/* Reset Password Modal */}
<Modal
title={<Space><LockOutlined /> - {resetPwdModal.user?.username}</Space>}
open={resetPwdModal.open}
+121 -13
View File
@@ -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,
@@ -17,8 +17,16 @@ interface VideoEngine {
supportedRatios: string[];
supportedResolutions: string[];
supportedDurations: number[];
maxDuration: number;
maxImageCount: number;
maxVideoCount: number;
maxAudioCount: number;
supportsFirstLastFrame: boolean;
supportsUniversalReference: boolean;
isActive: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
}
function parseJsonArray(val: unknown): any[] {
@@ -34,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);
@@ -66,8 +75,16 @@ const AdminVideoEngines: React.FC = () => {
supported_ratios: JSON.stringify(values.supportedRatios || []),
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
supported_durations: JSON.stringify(values.supportedDurations || []),
max_duration: values.maxDuration ?? 15,
max_image_count: values.maxImageCount ?? 2,
max_video_count: values.maxVideoCount ?? 0,
max_audio_count: values.maxAudioCount ?? 0,
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
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 });
@@ -102,11 +119,18 @@ const AdminVideoEngines: React.FC = () => {
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
});
isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1,
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
maxAudioCount: 0,
supportsFirstLastFrame: false,
supportsUniversalReference: true,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
});
}
};
@@ -142,6 +166,38 @@ const AdminVideoEngines: React.FC = () => {
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
},
{
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>,
},
{
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
render: (v: number) => <Tag color="cyan">{v} </Tag>,
},
{
title: '最大音频', dataIndex: 'maxAudioCount', width: 100,
render: (v: number) => <Tag color={v > 0 ? 'geekblue' : 'default'}>{v || 0} </Tag>,
},
{
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
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>,
@@ -161,7 +217,7 @@ const AdminVideoEngines: React.FC = () => {
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
@@ -179,7 +235,7 @@ const AdminVideoEngines: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
scroll={{ x: 1200 }}
/>
</Card>
@@ -230,12 +286,67 @@ 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>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="maxImageCount" label="最大图片数量" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
<Form.Item name="maxVideoCount" label="最大视频数量" style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="maxAudioCount"
label="最大参考音频数"
style={{ flex: 1 }}
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 的整数'));
// }
return Promise.resolve();
},
},
]}
>
<Input type="number" size="large" min={0} max={3} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<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 (默认)' },
{ value: 1, label: '1' },
@@ -245,9 +356,6 @@ const AdminVideoEngines: React.FC = () => {
{ value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
@@ -0,0 +1,735 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button,
Card,
Col,
Collapse,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Switch,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
exportVideoPromptSchemaConfig,
getVideoPromptSchemaConfig,
importVideoPromptSchemaConfig,
previewVideoPromptSchemaConfig,
resetVideoPromptSchemaConfig,
saveVideoPromptSchemaConfig,
} from '../api';
import type {
VideoPromptFlowItemField,
VideoPromptSchemaConfigData,
VideoPromptSchemaNode,
VideoPromptSchemaNodeType,
VideoPromptTimePlanRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const NODE_TYPES: { label: string; value: VideoPromptSchemaNodeType }[] = [
{ label: '对象', value: 'object' },
{ label: '数组', value: 'array' },
{ label: '字符串', value: 'string' },
{ label: '数字', value: 'number' },
{ label: '布尔', value: 'boolean' },
{ label: '流程数组', value: 'flow' },
];
const VIDEO_SPEC_SECTION_KEY = '画面属性';
const VIDEO_SPEC_LOCKED_FIELDS = new Set(['视频时长', '视频比例', '清晰度', '帧率', '推荐分辨率']);
function isLockedVideoSpecField(sectionKey: string | undefined, fieldKey: string | undefined): boolean {
return sectionKey === VIDEO_SPEC_SECTION_KEY && !!fieldKey && VIDEO_SPEC_LOCKED_FIELDS.has(fieldKey);
}
function isLockedVideoSpecSection(sectionKey: string | undefined): boolean {
return sectionKey === VIDEO_SPEC_SECTION_KEY;
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function pick<T = any>(obj: any, camelKey: string, snakeKey: string, fallback?: T): T {
if (!obj || typeof obj !== 'object') return fallback as T;
if (obj[camelKey] !== undefined) return obj[camelKey] as T;
if (obj[snakeKey] !== undefined) return obj[snakeKey] as T;
return fallback as T;
}
function normalizeFlowField(field: any): VideoPromptFlowItemField {
return {
key: String(field?.key || field?.label || '新流程字段'),
label: String(field?.label || field?.key || '新流程字段'),
enabled: field?.enabled ?? true,
editable: field?.editable ?? true,
maxLength: Number(pick(field, 'maxLength', 'max_length', 800) || 800),
value: field?.value ?? '',
};
}
function normalizeNode(node: any): VideoPromptSchemaNode {
const type = String(node?.type || 'string') as VideoPromptSchemaNodeType;
const normalized: VideoPromptSchemaNode = {
key: String(node?.key || node?.label || '新字段'),
label: String(node?.label || node?.key || '新字段'),
type,
enabled: node?.enabled ?? true,
editable: node?.editable ?? true,
maxLength: Number(pick(node, 'maxLength', 'max_length', type === 'flow' ? 800 : 1000) || (type === 'flow' ? 800 : 1000)),
};
if (type === 'object') {
normalized.children = Array.isArray(node?.children) ? node.children.map(normalizeNode) : [];
} else if (type === 'flow') {
normalized.contentKey = String(pick(node, 'contentKey', 'content_key', node?.key === '镜头流程' ? '镜头内容' : '动作内容'));
const aliases = pick<any[]>(node, 'contentAliases', 'content_aliases', []);
normalized.contentAliases = Array.isArray(aliases) ? aliases.map(item => String(item)).filter(Boolean) : [];
const itemFields = pick<any[]>(node, 'itemFields', 'item_fields', []);
normalized.itemFields = Array.isArray(itemFields) && itemFields.length > 0
? itemFields.map(normalizeFlowField)
: [{ key: normalized.contentKey, label: normalized.contentKey, enabled: true, editable: true, maxLength: normalized.maxLength || 800, value: '' }];
} else {
normalized.value = node?.value ?? (type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无');
}
return normalized;
}
function normalizeTimeRule(rule: any): VideoPromptTimePlanRule {
const rawSegments = Array.isArray(rule?.segments) ? rule.segments : [];
const rawRatios = Array.isArray(rule?.ratios) ? rule.ratios : [];
return {
minDuration: Number(pick(rule, 'minDuration', 'min_duration', 1) || 1),
maxDuration: Number(pick(rule, 'maxDuration', 'max_duration', 5) || 5),
ratios: rawRatios.map((item: any) => Number(item || 0)),
segments: rawSegments.map((segment: any) => ({
stage: String(segment?.stage || '新阶段'),
description: String(segment?.description || segment?.desc || '阶段说明'),
})),
};
}
function normalizeConfig(data: any): VideoPromptSchemaConfigData {
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
const rawRules = Array.isArray(data?.timePlanRules)
? data.timePlanRules
: Array.isArray(data?.time_plan_rules)
? data.time_plan_rules
: [];
return {
version: data?.version || 'video_prompt_schema_config_v1',
enabled: data?.enabled ?? true,
sections: rawSections.map(normalizeNode),
timePlanRules: rawRules.map(normalizeTimeRule),
};
}
function toApiNode(node: VideoPromptSchemaNode, parentKey?: string): any {
const lockedSection = isLockedVideoSpecSection(node.key);
const lockedField = isLockedVideoSpecField(parentKey, node.key);
const base: any = {
key: node.key,
label: node.label,
type: lockedField ? 'string' : node.type,
enabled: lockedSection || lockedField ? true : !!node.enabled,
editable: lockedField ? false : !!node.editable,
max_length: node.maxLength,
};
if (node.type === 'object') {
base.children = (node.children || []).map(child => toApiNode(child, node.key));
} else if (node.type === 'flow') {
base.content_key = node.contentKey;
base.content_aliases = node.contentAliases || [];
base.item_fields = (node.itemFields || []).map(field => ({
key: field.key,
label: field.label,
enabled: !!field.enabled,
editable: !!field.editable,
max_length: field.maxLength,
value: field.value ?? '',
}));
} else {
base.value = node.value;
}
return base;
}
function toApiConfig(config: VideoPromptSchemaConfigData, enabled: boolean): any {
return {
version: config.version,
enabled,
sections: (config.sections || []).map(section => toApiNode(section)),
time_plan_rules: (config.timePlanRules || []).map(rule => ({
min_duration: rule.minDuration,
max_duration: rule.maxDuration,
ratios: rule.ratios || [],
segments: (rule.segments || []).map(segment => ({
stage: segment.stage,
description: segment.description,
})),
})),
};
}
function runtimePreviewSchema(result: any): any {
return result?.runtimeSchema || result?.runtime_schema || {};
}
function runtimePreviewTimePlan(result: any): any[] {
const value = result?.timePlan || result?.time_plan || runtimePreviewSchema(result)?. || [];
return Array.isArray(value) ? value : [];
}
function runtimePreviewSnapshot(result: any): any {
return result?.schemaConfigSnapshot || result?.schema_config_snapshot || {};
}
function downloadJson(filename: string, data: any): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const createNode = (type: VideoPromptSchemaNodeType = 'string'): VideoPromptSchemaNode => ({
key: '新字段',
label: '新字段',
type,
enabled: true,
editable: true,
maxLength: type === 'flow' ? 800 : 1000,
value: type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无',
children: type === 'object' ? [] : undefined,
contentKey: type === 'flow' ? '动作内容' : undefined,
contentAliases: type === 'flow' ? ['动作内容', '动作', '动作说明', '内容', '说明'] : undefined,
itemFields: type === 'flow' ? [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }] : undefined,
});
const createTimeRule = (): VideoPromptTimePlanRule => ({
minDuration: 1,
maxDuration: 5,
ratios: [0.2, 0.4, 0.4],
segments: [
{ stage: '开场吸引', description: '快速吸引注意力' },
{ stage: '核心展示', description: '展示主体动作、核心卖点或主要视觉内容' },
{ stage: '收尾引导', description: '给出行动引导并稳定落版' },
],
});
const AdminVideoPromptSchemaConfig: React.FC = () => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(true);
const [usingDefault, setUsingDefault] = useState(false);
const [config, setConfig] = useState<VideoPromptSchemaConfigData | null>(null);
const [defaultConfig, setDefaultConfig] = useState<VideoPromptSchemaConfigData | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewResult, setPreviewResult] = useState<any>(null);
const [previewForm] = Form.useForm();
const importInputRef = useRef<HTMLInputElement | null>(null);
const sections = config?.sections || [];
const timePlanRules = config?.timePlanRules || [];
const load = async () => {
setLoading(true);
try {
const res = await getVideoPromptSchemaConfig();
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
setConfig(normalizeConfig(res.data));
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
} catch (error: any) {
message.error(error?.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const updateConfig = (updater: (draft: VideoPromptSchemaConfigData) => void) => {
setConfig(prev => {
const next = normalizeConfig(clone(prev));
updater(next);
return next;
});
};
const save = async () => {
if (!config) return;
setSaving(true);
try {
const res = await saveVideoPromptSchemaConfig({ is_enabled: enabled, data: toApiConfig(config, enabled) });
setConfig(normalizeConfig(res.data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('保存成功');
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
const resetDefault = async () => {
setSaving(true);
try {
const res = await resetVideoPromptSchemaConfig();
setConfig(normalizeConfig(res.data));
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('已恢复默认配置');
} catch (error: any) {
message.error(error?.message || '恢复默认失败');
} finally {
setSaving(false);
}
};
const doExport = async () => {
try {
const res = await exportVideoPromptSchemaConfig();
downloadJson('video_prompt_schema_config.json', res);
} catch (error: any) {
message.error(error?.message || '导出失败');
}
};
const onImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
const data = json.data || json;
const isEnabled = json.isEnabled ?? json.is_enabled ?? enabled;
const res = await importVideoPromptSchemaConfig({ is_enabled: !!isEnabled, data });
setConfig(normalizeConfig(res.data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('导入成功');
} catch (error: any) {
message.error(error?.message || '导入失败,请检查 JSON 格式');
}
};
const openPreview = () => {
previewForm.setFieldsValue({ duration: 8, aspectRatio: '9:16', resolution: '1080p', frameRate: '30fps' });
setPreviewResult(null);
setPreviewOpen(true);
};
const doPreview = async () => {
if (!config) return;
const values = await previewForm.validateFields();
try {
const res = await previewVideoPromptSchemaConfig({
is_enabled: enabled,
data: toApiConfig(config, enabled),
duration: Number(values.duration || 8),
aspect_ratio: values.aspectRatio,
resolution: values.resolution,
frame_rate: values.frameRate,
});
setPreviewResult(res);
} catch (error: any) {
message.error(error?.message || '预览失败');
}
};
const addSection = () => {
updateConfig(draft => {
draft.sections.push(createNode('object'));
});
};
const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => {
const current = draft.sections[index];
if (isLockedVideoSpecSection(current?.key)) {
delete patch.key;
delete patch.type;
delete patch.enabled;
}
draft.sections[index] = { ...draft.sections[index], ...patch };
if (patch.type === 'object') {
draft.sections[index].children = draft.sections[index].children || [];
}
if (patch.type === 'flow') {
draft.sections[index].contentKey = draft.sections[index].contentKey || '动作内容';
draft.sections[index].contentAliases = draft.sections[index].contentAliases || ['动作内容', '动作', '动作说明', '内容', '说明'];
draft.sections[index].itemFields = draft.sections[index].itemFields || [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }];
}
});
};
const deleteSection = (index: number) => {
updateConfig(draft => {
draft.sections.splice(index, 1);
});
};
const addChild = (sectionIndex: number) => {
updateConfig(draft => {
const section = draft.sections[sectionIndex];
section.children = section.children || [];
section.children.push(createNode('string'));
});
};
const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => {
const section = draft.sections[sectionIndex];
const children = section.children || [];
const child = children[childIndex];
if (isLockedVideoSpecField(section?.key, child?.key)) {
delete patch.key;
delete patch.type;
delete patch.enabled;
patch.editable = false;
}
children[childIndex] = { ...children[childIndex], ...patch };
draft.sections[sectionIndex].children = children;
});
};
const deleteChild = (sectionIndex: number, childIndex: number) => {
updateConfig(draft => {
draft.sections[sectionIndex].children?.splice(childIndex, 1);
});
};
const addFlowField = (sectionIndex: number) => {
updateConfig(draft => {
const section = draft.sections[sectionIndex];
section.itemFields = section.itemFields || [];
section.itemFields.push({ key: '新流程字段', label: '新流程字段', enabled: true, editable: true, maxLength: 800, value: '' });
});
};
const updateFlowField = (sectionIndex: number, fieldIndex: number, patch: Partial<VideoPromptFlowItemField>) => {
updateConfig(draft => {
const fields = draft.sections[sectionIndex].itemFields || [];
fields[fieldIndex] = { ...fields[fieldIndex], ...patch };
draft.sections[sectionIndex].itemFields = fields;
});
};
const deleteFlowField = (sectionIndex: number, fieldIndex: number) => {
updateConfig(draft => {
draft.sections[sectionIndex].itemFields?.splice(fieldIndex, 1);
});
};
const addTimeRule = () => {
updateConfig(draft => {
draft.timePlanRules.push(createTimeRule());
});
};
const updateTimeRule = (index: number, patch: Partial<VideoPromptTimePlanRule>) => {
updateConfig(draft => {
draft.timePlanRules[index] = { ...draft.timePlanRules[index], ...patch };
});
};
const deleteTimeRule = (index: number) => {
updateConfig(draft => {
draft.timePlanRules.splice(index, 1);
});
};
const updateSegment = (ruleIndex: number, segmentIndex: number, patch: Partial<{ stage: string; description: string; ratio: number }>) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
if (patch.ratio !== undefined) rule.ratios[segmentIndex] = Number(patch.ratio || 0);
if (patch.stage !== undefined) rule.segments[segmentIndex].stage = patch.stage;
if (patch.description !== undefined) rule.segments[segmentIndex].description = patch.description;
});
};
const addSegment = (ruleIndex: number) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
rule.ratios.push(1);
rule.segments.push({ stage: '新阶段', description: '阶段说明' });
});
};
const deleteSegment = (ruleIndex: number, segmentIndex: number) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
rule.ratios.splice(segmentIndex, 1);
rule.segments.splice(segmentIndex, 1);
});
};
const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({
key: `section-${sectionIndex}`,
label: (
<Space>
<Text strong>{section.label || section.key || '未命名分组'}</Text>
{section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>}
<Tag>{section.type}</Tag>
{isLockedVideoSpecSection(section.key) && <Tag color="red"></Tag>}
{!section.enabled && !isLockedVideoSpecSection(section.key) && <Tag color="default"></Tag>}
</Space>
),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={12} align="middle">
<Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecSection(section.key)} onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col>
<Col span={4}>
<Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} disabled={isLockedVideoSpecSection(section.key)} onChange={value => updateSection(sectionIndex, { type: value })} />
</Col>
<Col span={3}><Space><Switch checked={isLockedVideoSpecSection(section.key) ? true : section.enabled} disabled={isLockedVideoSpecSection(section.key)} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col>
<Col span={3}><Space><Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col>
<Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={2} style={{ textAlign: 'right' }}>
<Popconfirm title="确认删除该分组?" disabled={isLockedVideoSpecSection(section.key)} onConfirm={() => deleteSection(sectionIndex)}>
<Button danger icon={<DeleteOutlined />} disabled={isLockedVideoSpecSection(section.key)} />
</Popconfirm>
</Col>
</Row>
{section.type === 'object' && (
<Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}></Button>}>
<Space direction="vertical" style={{ width: '100%' }}>
{(section.children || []).map((child, childIndex) => (
<Row gutter={8} align="middle" key={`child-${sectionIndex}-${childIndex}`}>
<Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecField(section.key, child.key)} onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col>
<Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col>
<Col span={2}><Space><Switch checked={isLockedVideoSpecField(section.key, child.key) ? true : child.enabled} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col>
<Col span={2}><Space><Switch checked={isLockedVideoSpecField(section.key, child.key) ? false : child.editable} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col>
<Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={5}>
<Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} />
</Col>
<Col span={1} style={{ textAlign: 'right' }}>
<Button danger size="small" icon={<DeleteOutlined />} disabled={isLockedVideoSpecField(section.key, child.key)} onClick={() => deleteChild(sectionIndex, childIndex)} />
</Col>
</Row>
))}
</Space>
</Card>
)}
{section.type === 'flow' && (
<Card size="small" title="流程数组对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addFlowField(sectionIndex)}></Button>}>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}><Input addonBefore="内容字段" value={section.contentKey} maxLength={64} onChange={e => updateSection(sectionIndex, { contentKey: e.target.value })} /></Col>
<Col span={18}><Input addonBefore="别名 / 分隔" value={(section.contentAliases || []).join('/')} onChange={e => updateSection(sectionIndex, { contentAliases: e.target.value.split('/').map(item => item.trim()).filter(Boolean) })} /></Col>
</Row>
<Space direction="vertical" style={{ width: '100%' }}>
{(section.itemFields || []).map((field, fieldIndex) => (
<Row gutter={8} align="middle" key={`flow-${sectionIndex}-${fieldIndex}`}>
<Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col>
<Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col>
<Col span={3}><Space><Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col>
<Col span={3}><Space><Switch checked={field.editable} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { editable: checked })} /></Space></Col>
<Col span={4}><InputNumber min={1} max={20000} value={field.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateFlowField(sectionIndex, fieldIndex, { maxLength: Number(value || 800) })} /></Col>
<Col span={3}><Input value={String(field.value ?? '')} maxLength={field.maxLength || 800} placeholder="默认值" onChange={e => updateFlowField(sectionIndex, fieldIndex, { value: e.target.value })} /></Col>
<Col span={1} style={{ textAlign: 'right' }}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteFlowField(sectionIndex, fieldIndex)} /></Col>
</Row>
))}
</Space>
</Card>
)}
{section.type !== 'object' && section.type !== 'flow' && (
<Card size="small" title="默认值">
<TextArea rows={3} value={Array.isArray(section.value) ? section.value.join('\n') : String(section.value ?? '')} maxLength={section.maxLength || 1000} showCount onChange={e => updateSection(sectionIndex, { value: section.type === 'array' ? e.target.value.split('\n').filter(Boolean) : e.target.value })} />
</Card>
)}
</Space>
),
})), [sections]);
if (loading || !config) {
return <Spin />;
}
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Row justify="space-between" align="middle">
<Col>
<Title level={3} style={{ margin: 0 }}> Schema </Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
4 AI AI schema
</Paragraph>
</Col>
<Col>
<Space>
<Text></Text>
<Switch checked={enabled} onChange={setEnabled} />
{usingDefault && <Tag color="orange">使</Tag>}
{defaultConfig && <Tag> {defaultConfig.sections.length}</Tag>}
</Space>
</Col>
</Row>
<Alert
type="info"
showIcon
message="长度限制策略"
description="只对管理后台字段和用户可编辑字段做长度校验,不会自动裁剪 AI 生成结果或第 5 步最终视频 prompt。若供应商因为 prompt 过长拒绝,会按真实失败进入现有日志/退款/重试流程。"
/>
<Alert
type="warning"
showIcon
message="预览说明"
description="预览会跟随右上角「启用自定义配置」开关。关闭时会按默认 CLIENT_SCHEMA_V1 兜底预览;打开后才会按当前页面编辑的 sections 和秒数切片规则预览。"
/>
<Space wrap>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={save}></Button>
<Button icon={<EyeOutlined />} onClick={openPreview}> Schema</Button>
<Button icon={<DownloadOutlined />} onClick={doExport}> JSON</Button>
<Button icon={<UploadOutlined />} onClick={() => importInputRef.current?.click()}> JSON</Button>
<Popconfirm title="确认恢复默认配置?" description="会用 CLIENT_SCHEMA_V1 生成的默认配置覆盖当前配置。" onConfirm={resetDefault}>
<Button icon={<ReloadOutlined />}></Button>
</Popconfirm>
<input ref={importInputRef} type="file" accept="application/json,.json" style={{ display: 'none' }} onChange={onImportFile} />
</Space>
<Tabs
items={[
{
key: 'fields',
label: 'Schema 字段配置',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="warning"
showIcon
message="字段说明"
description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。「画面属性」里的视频时长、视频比例、清晰度、帧率、推荐分辨率属于系统锁定参数,不能改 key、不能禁用、不能开放用户编辑。"
/>
<Button icon={<PlusOutlined />} onClick={addSection}></Button>
<Collapse items={sectionItems} />
</Space>
),
},
{
key: 'time',
label: '秒数切片规则',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="切片规则说明"
description="这里决定指定视频时长会被拆成几段,以及每段的阶段名称和说明。运行时会同步生成「动态时间规划」「动作流程」「镜头流程」三个数组,三者时间段保持一致。"
/>
<Button icon={<PlusOutlined />} onClick={addTimeRule}></Button>
{timePlanRules.map((rule, ruleIndex) => (
<Card
key={ruleIndex}
size="small"
title={`${rule.minDuration}-${rule.maxDuration} 秒:${rule.segments.length}`}
extra={<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteTimeRule(ruleIndex)}></Button>}
>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={4}><InputNumber min={1} value={rule.minDuration} addonBefore="最小秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { minDuration: Number(value || 1) })} /></Col>
<Col span={4}><InputNumber min={1} value={rule.maxDuration} addonBefore="最大秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { maxDuration: Number(value || 1) })} /></Col>
<Col span={16}><Button size="small" icon={<PlusOutlined />} onClick={() => addSegment(ruleIndex)}></Button></Col>
</Row>
<Space direction="vertical" style={{ width: '100%' }}>
{rule.segments.map((segment, segmentIndex) => (
<Row gutter={8} align="middle" key={segmentIndex}>
<Col span={3}><InputNumber min={0.01} step={0.01} value={rule.ratios[segmentIndex]} addonBefore="比例" style={{ width: '100%' }} onChange={value => updateSegment(ruleIndex, segmentIndex, { ratio: Number(value || 0.01) })} /></Col>
<Col span={5}><Input value={segment.stage} maxLength={64} placeholder="阶段" onChange={e => updateSegment(ruleIndex, segmentIndex, { stage: e.target.value })} /></Col>
<Col span={15}><Input value={segment.description} maxLength={300} placeholder="说明" onChange={e => updateSegment(ruleIndex, segmentIndex, { description: e.target.value })} /></Col>
<Col span={1}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteSegment(ruleIndex, segmentIndex)} /></Col>
</Row>
))}
</Space>
</Card>
))}
</Space>
),
},
{
key: 'json',
label: 'JSON 高级查看',
children: <TextArea rows={24} value={JSON.stringify(toApiConfig(config, enabled), null, 2)} readOnly />,
},
]}
/>
</Space>
</Card>
<Modal title="预览运行时 Schema" open={previewOpen} onCancel={() => setPreviewOpen(false)} width={980} footer={<Space><Button onClick={() => setPreviewOpen(false)}></Button><Button type="primary" onClick={doPreview}></Button></Space>}>
<Form form={previewForm} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="duration" label="时长" rules={[{ required: true }]}><InputNumber min={1} max={3600} /></Form.Item>
<Form.Item name="aspectRatio" label="比例" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="resolution" label="清晰度" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="frameRate" label="帧率" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
<Divider />
{previewResult ? (
<Tabs
items={[
{
key: 'runtime',
label: '运行时 Schema',
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSchema(previewResult), null, 2)} readOnly />,
},
{
key: 'timePlan',
label: `动态时间规划(${runtimePreviewTimePlan(previewResult).length} 段)`,
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewTimePlan(previewResult), null, 2)} readOnly />,
},
{
key: 'snapshot',
label: '配置快照',
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSnapshot(previewResult), null, 2)} readOnly />,
},
]}
/>
) : (
<Alert type="info" showIcon message="点击生成预览后,可以看到传给 AI 的纯 schema、动态时间规划和本次配置快照。" />
)}
</Modal>
</div>
);
};
export default AdminVideoPromptSchemaConfig;
@@ -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' },
@@ -1,171 +1,21 @@
import React from 'react';
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
import { Card, Collapse, Descriptions, Empty, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { JsonBlock } from './JsonCollapse';
const FIELD_LABELS: Record<string, string> = {
schema_version: 'Schema 版本',
generation_type: '生成类型',
business_type: '业务类型',
usage: '用途',
product_type: '产品类型',
target_audience: '目标受众',
style: '风格',
aspect_ratio: '画面比例',
resolution: '分辨率',
duration: '时长',
fps: '帧率',
final_prompt: '最终提示词',
prompt: '提示词',
description: '说明',
content: '内容',
time: '时间',
time_range: '时间段',
start_time: '开始时间',
end_time: '结束时间',
stage: '阶段',
scene: '场景',
action: '动作',
camera: '镜头',
shot: '镜头',
lens: '镜头',
subtitle: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
notes: '备注',
};
const SECTION_LABELS: Record<string, string> = {
basic: '基础信息',
basic_info: '基础信息',
material: '素材理解',
material_understanding: '素材理解',
business: '业务属性',
business_info: '业务属性',
visual: '画面与风格',
visual_style: '画面与风格',
time_plan: '时间规划',
timeline: '时间规划',
scene_timeline: '场景时间线',
action_flow: '动作流程',
motion_flow: '动作流程',
character_action_flow: '角色动作流程',
camera_flow: '镜头流程',
shot_flow: '镜头流程',
lens_flow: '镜头流程',
subtitle: '字幕',
subtitles: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
compliance: '合规控制',
final_prompt: '最终提示词',
};
import type {
VideoPromptSchemaFieldConfig,
VideoPromptSchemaSectionConfig,
} from '../../../utils/videoPromptSchema';
import {
hasSchemaConfigSnapshot,
inferSchemaSections,
normalizeSchemaSections,
stringifySchemaValue,
} from '../../../utils/videoPromptSchema';
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
const PLACEHOLDER_FLOW_TEXTS = new Set([
'展示主体动作、核心卖点或主要视觉内容',
'展示主要动作、核心卖点或主要视觉内容',
'展示核心卖点或主要视觉内容',
'展示主体动作',
'无',
]);
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
const toText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
const isActionFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
};
const isCameraFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
};
const isTimePlanSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
};
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
for (const key of keys) {
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
}
return '';
};
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
const cleanBase = baseContent.trim();
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
return uniqueExtras.join('');
}
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
uniqueExtras.forEach((item) => {
if (item && !parts.includes(item)) parts.push(item);
});
return parts.join('') || '-';
};
const normalizeFlowItemForDisplay = (
item: Record<string, unknown>,
contentKey: '动作内容' | '镜头内容',
contentKeys: string[],
): Record<string, unknown> => {
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
const extras: string[] = [];
Object.entries(item).forEach(([field, value]) => {
if (allowed.has(field)) return;
const fieldText = toText(field);
const valueText = toText(value);
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
});
return {
时间段: toText(item['时间段']) || '-',
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
};
};
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
if (isActionFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
}
if (isCameraFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
}
if (isTimePlanSection(sectionKey)) {
return {
时间段: toText(item['时间段']) || '-',
阶段: toText(item['阶段']) || '-',
说明: toText(item['说明']) || '-',
};
}
return item;
};
const renderValue = (value: unknown): React.ReactNode => {
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
if (typeof value === 'boolean') return value ? '是' : '否';
@@ -176,64 +26,55 @@ const renderValue = (value: unknown): React.ReactNode => {
return <JsonBlock value={value} maxHeight={220} />;
};
const getArrayMode = (key: string): 'card' | 'table' => {
const lower = key.toLowerCase();
if (
lower.includes('action') ||
lower.includes('motion') ||
lower.includes('camera') ||
lower.includes('shot') ||
lower.includes('lens') ||
lower.includes('flow') ||
lower.includes('动作') ||
lower.includes('镜头')
) {
return 'card';
}
return 'table';
const tagOfEditable = (editable?: boolean) => editable ? <Tag color="processing"></Tag> : <Tag color="default"></Tag>;
const readSectionValue = (schema: Record<string, any>, key: string): unknown => schema[key];
const renderObjectSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
const value = readSectionValue(schema, section.key);
if (!isRecord(value)) return <JsonBlock value={value ?? {}} maxHeight={220} />;
const fields = section.children.filter((field) => field.enabled);
if (!fields.length) return <JsonBlock value={value} maxHeight={220} />;
return (
<Descriptions size="small" column={2} bordered>
{fields.map((field) => (
<Descriptions.Item key={field.key} label={<Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>} span={isLongText(value[field.key]) || isRecord(value[field.key]) || Array.isArray(value[field.key]) ? 2 : 1}>
{renderValue(value[field.key])}
</Descriptions.Item>
))}
</Descriptions>
);
};
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{items.map((item, index) => {
const displayItem = normalizeRecordForSection(sectionKey, item);
return (
<Card
key={`${sectionKey}-${index}`}
size="small"
title={`${labelOf(sectionKey)} ${index + 1}`}
styles={{ body: { padding: 12 } }}
>
<Descriptions size="small" column={1} bordered>
{Object.entries(displayItem).map(([field, value]) => (
<Descriptions.Item key={field} label={labelOf(field)}>
{renderValue(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
);
})}
</Space>
);
const buildFlowFields = (section: VideoPromptSchemaSectionConfig, rows: Record<string, unknown>[]): VideoPromptSchemaFieldConfig[] => {
const configured = section.itemFields.filter((field) => field.enabled);
if (configured.length) {
return [{ key: '时间段', label: '时间段', enabled: true, editable: false }, ...configured];
}
const inferred = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
return inferred.map((key) => ({ key, label: key, enabled: true, editable: false }));
};
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
title: labelOf(field),
dataIndex: field,
key: field,
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
render: (value: unknown) => renderValue(value),
const renderFlowSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
const value = readSectionValue(schema, section.key);
if (!Array.isArray(value) || !value.every(isRecord)) return <JsonBlock value={value ?? []} maxHeight={260} />;
const rows = value as Record<string, unknown>[];
const fields = buildFlowFields(section, rows);
const columns: ColumnsType<Record<string, unknown>> = fields.map((field) => ({
title: <Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>,
dataIndex: field.key,
key: field.key,
width: isLongText(rows.find((row) => row[field.key])?.[field.key]) ? 320 : 160,
render: (cell: unknown) => renderValue(cell),
}));
return (
<Table
size="small"
rowKey={(_, index) => `${sectionKey}-${index}`}
rowKey={(_, index) => `${section.key}-${index}`}
columns={columns}
dataSource={displayItems}
dataSource={rows}
pagination={false}
scroll={{ x: Math.max(900, fields.length * 180) }}
tableLayout="fixed"
@@ -241,59 +82,64 @@ const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]):
);
};
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
if (value.every(isRecord)) {
return getArrayMode(sectionKey) === 'card'
? renderCardArray(sectionKey, value)
: renderTableArray(sectionKey, value);
}
return <JsonBlock value={value} maxHeight={260} />;
};
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
<Descriptions size="small" column={2} bordered>
{Object.entries(value).map(([field, fieldValue]) => (
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
{Array.isArray(fieldValue)
? renderArray(field, fieldValue)
: isRecord(fieldValue)
? renderRecord(fieldValue)
: renderValue(fieldValue)}
</Descriptions.Item>
))}
</Descriptions>
);
const renderSection = (key: string, value: unknown): React.ReactNode => {
if (Array.isArray(value)) return renderArray(key, value);
if (isRecord(value)) return renderRecord(value);
const renderPrimitiveSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
const value = readSectionValue(schema, section.key);
return renderValue(value);
};
interface VideoPromptSchemaViewerProps {
schema?: Record<string, any> | null;
finalPrompt?: string | null;
schemaConfigSnapshot?: Record<string, any> | null;
schemaConfigSource?: string | null;
schemaConfigVersion?: string | null;
schemaConfigIsFallback?: boolean | null;
}
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({
schema,
finalPrompt,
schemaConfigSnapshot,
schemaConfigSource,
schemaConfigVersion,
schemaConfigIsFallback,
}) => {
const hasSchema = !!schema && Object.keys(schema).length > 0;
if (!hasSchema && !finalPrompt) {
return <Empty description="暂无视频提词 schema" />;
}
const schemaItems = hasSchema
? Object.entries(schema || {}).map(([key, value]) => ({
key,
label: labelOf(key),
children: renderSection(key, value),
}))
: [];
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
const safeSchema = hasSchema ? schema as Record<string, any> : {};
const hasSnapshot = hasSchemaConfigSnapshot(schemaConfigSnapshot);
const sections = hasSnapshot ? normalizeSchemaSections(schemaConfigSnapshot) : inferSchemaSections(safeSchema);
const schemaItems = sections
.filter((section) => section.key in safeSchema)
.map((section) => ({
key: section.key,
label: (
<Space size={8} wrap>
<span>{section.label}</span>
{tagOfEditable(section.editable)}
{!hasSnapshot ? <Tag color="warning"></Tag> : null}
</Space>
),
children: section.type === 'object'
? renderObjectSection(safeSchema, section)
: section.type === 'flow'
? renderFlowSection(safeSchema, section)
: renderPrimitiveSection(safeSchema, section),
}));
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Card size="small" title="Schema 配置来源">
<Descriptions size="small" column={3} bordered>
<Descriptions.Item label="来源">{schemaConfigSource || schemaConfigSnapshot?.source || (hasSnapshot ? '-' : 'viewer_fallback')}</Descriptions.Item>
<Descriptions.Item label="版本">{schemaConfigVersion || schemaConfigSnapshot?.version || '-'}</Descriptions.Item>
<Descriptions.Item label="历史兜底">{schemaConfigIsFallback || !hasSnapshot ? <Tag color="warning"></Tag> : <Tag color="success"></Tag>}</Descriptions.Item>
</Descriptions>
</Card>
{finalPrompt ? (
<Card size="small" title="最终视频提示词">
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
@@ -301,10 +147,21 @@ const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schem
) : null}
{schemaItems.length ? (
<Collapse size="small" items={schemaItems} />
) : hasSchema ? (
<Card size="small" title="视频提词 Schema">
<JsonBlock value={safeSchema} maxHeight={360} />
</Card>
) : null}
{hasSchema ? (
<Collapse
size="small"
defaultActiveKey={defaultKeys}
items={schemaItems}
items={[{
key: 'raw_schema',
label: '原始 JSON / 历史脏字段排查',
children: <JsonBlock value={safeSchema} maxHeight={420} />,
}]}
/>
) : null}
</Space>
@@ -0,0 +1,465 @@
import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
import {
deleteHomeMaterialAsset,
getHomeMaterialAssetStatus,
getHomeMaterialAssets,
regenerateHomeMaterialWatermark,
updateHomeMaterialAsset,
} from '../../api';
import type {
HomeMaterialAsset,
HomeMaterialAssetQueryParams,
HomeMaterialCategory,
HomeMaterialMediaReference,
HomeMaterialWatermark,
HomeMaterialWatermarkConfig,
HomeMaterialTextWatermarkConfig,
} from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
import WatermarkEditor from './WatermarkEditor';
import MediaReferencesEditor from './MediaReferencesEditor';
interface Props {
categories: HomeMaterialCategory[];
watermarks: HomeMaterialWatermark[];
reloadKey?: number;
}
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
processing: { text: '处理中', color: 'processing' },
success: { text: '成功', color: 'success' },
failed: { text: '失败', color: 'error' },
};
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
rotateDeg: -30,
gapX: 220,
gapY: 140,
staggered: true,
};
const defaultConfig: HomeMaterialWatermarkConfig = {
watermarkType: 'image',
watermarkId: null,
opacityLevel: 6,
position: 'bottom_right',
customXRatio: null,
customYRatio: null,
sizeMode: 'ratio',
widthRatio: 0.18,
widthPx: null,
marginX: 24,
marginY: 24,
textWatermark: defaultTextWatermark,
};
const mediaTypeText: Record<string, string> = {
image: '图片',
video: '视频',
audio: '音频',
};
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
return {
text: raw?.text || defaultTextWatermark.text,
opacityLevel: raw?.opacityLevel ?? raw?.opacity_level ?? defaultTextWatermark.opacityLevel,
fontSizePx: raw?.fontSizePx ?? raw?.font_size_px ?? defaultTextWatermark.fontSizePx,
color: raw?.color || defaultTextWatermark.color,
rotateDeg: raw?.rotateDeg ?? raw?.rotate_deg ?? defaultTextWatermark.rotateDeg,
gapX: raw?.gapX ?? raw?.gap_x ?? defaultTextWatermark.gapX,
gapY: raw?.gapY ?? raw?.gap_y ?? defaultTextWatermark.gapY,
staggered: raw?.staggered ?? defaultTextWatermark.staggered,
};
}
function normalizeConfig(raw: unknown, fallbackWatermarkId?: string | null): HomeMaterialWatermarkConfig {
const data = (raw || {}) as any;
const watermarkType = data.watermarkType || data.watermark_type || 'image';
return {
watermarkType,
watermarkId: watermarkType === 'repeated_text' ? null : (data.watermarkId || data.watermark_id || fallbackWatermarkId || null),
opacityLevel: data.opacityLevel || data.opacity_level || 6,
position: data.position || 'bottom_right',
customXRatio: data.customXRatio ?? data.custom_x_ratio ?? null,
customYRatio: data.customYRatio ?? data.custom_y_ratio ?? null,
sizeMode: data.sizeMode || data.size_mode || 'ratio',
widthRatio: data.widthRatio ?? data.width_ratio ?? 0.18,
widthPx: data.widthPx ?? data.width_px ?? null,
marginX: data.marginX ?? data.margin_x ?? 24,
marginY: data.marginY ?? data.margin_y ?? 24,
textWatermark: normalizeTextConfig(data.textWatermark || data.text_watermark),
};
}
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
return !!config.watermarkId;
}
function shortText(value?: string | null, max = 48): string {
const text = (value || '').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) {
const list = refs || [];
if (list.length === 0) return <Typography.Text type="secondary"></Typography.Text>;
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{list.map((ref, index) => {
const displayUrl = apiUrl(ref.displayUrl || ref.url);
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
return (
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
</div>
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
<Space wrap>
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
{ref.role && <Tag>{ref.role}</Tag>}
</Space>
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
</Space>
</div>
);
})}
</Space>
);
}
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<HomeMaterialAssetQueryParams>({ page: 1, pageSize: 20 });
const [preview, setPreview] = useState<HomeMaterialAsset | null>(null);
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [regenSubmitting, setRegenSubmitting] = useState(false);
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
const [editSubmitting, setEditSubmitting] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await getHomeMaterialAssets(filters);
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [filters, reloadKey]);
useEffect(() => {
const processing = items.filter(i => i.status === 'processing');
if (processing.length === 0) return;
const timer = window.setInterval(async () => {
const statuses = await Promise.all(processing.map(i => getHomeMaterialAssetStatus(i.id).catch(() => null)));
const changed = statuses.some(s => s && s.status !== 'processing');
if (changed) load();
}, 2500);
return () => window.clearInterval(timer);
}, [items]);
const openRegenerate = (row: HomeMaterialAsset) => {
setRegen(row);
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
};
const openGenerationConfig = (row: HomeMaterialAsset) => {
setEditingConfig(row);
setEditPrompt(row.generationPrompt || '');
setEditRefs(row.mediaReferences || []);
};
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
return apiUrl(row.coverUrl || '');
};
const getVideoUrl = (row: HomeMaterialAsset) => apiUrl(row.watermarkedUrl || row.originalUrl);
const confirmDelete = (row: HomeMaterialAsset) => {
Modal.confirm({
title: '确认删除该素材?',
content: row.title || row.id,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
async onOk() {
await deleteHomeMaterialAsset(row.id);
message.success('删除成功');
await load();
},
});
};
const submitGenerationConfig = async () => {
if (!editingConfig || editSubmitting) return;
setEditSubmitting(true);
try {
await updateHomeMaterialAsset(editingConfig.id, {
category_id: editingConfig.categoryId,
title: editingConfig.title || null,
is_active: editingConfig.isActive,
sort_order: editingConfig.sortOrder,
generation_prompt: editPrompt.trim() || null,
media_references: editRefs,
});
message.success('生成配置已保存');
setEditingConfig(null);
await load();
} finally {
setEditSubmitting(false);
}
};
const submitRegenerate = async () => {
if (!regen || regenSubmitting) return;
if (!isValidConfig(regenConfig)) {
message.warning(regenConfig.watermarkType === 'repeated_text' ? '请输入重复文字水印内容' : '请选择水印图片');
return;
}
setRegenSubmitting(true);
try {
await regenerateHomeMaterialWatermark(regen.id, {
watermark_type: regenConfig.watermarkType,
watermark_id: regenConfig.watermarkType === 'image' ? regenConfig.watermarkId : null,
opacity_level: regenConfig.opacityLevel,
position: regenConfig.position,
custom_x_ratio: regenConfig.customXRatio,
custom_y_ratio: regenConfig.customYRatio,
size_mode: regenConfig.sizeMode,
width_ratio: regenConfig.widthRatio,
width_px: regenConfig.widthPx,
margin_x: regenConfig.marginX,
margin_y: regenConfig.marginY,
text_watermark: regenConfig.watermarkType === 'repeated_text' && regenConfig.textWatermark ? {
text: regenConfig.textWatermark.text,
opacity_level: regenConfig.textWatermark.opacityLevel,
font_size_px: regenConfig.textWatermark.fontSizePx,
color: regenConfig.textWatermark.color,
rotate_deg: regenConfig.textWatermark.rotateDeg,
gap_x: regenConfig.textWatermark.gapX,
gap_y: regenConfig.textWatermark.gapY,
staggered: regenConfig.textWatermark.staggered,
} : null,
wait: false,
});
message.success('已提交重新生成');
setRegen(null);
await load();
} finally {
setRegenSubmitting(false);
}
};
return (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
allowClear
style={{ width: 180 }}
placeholder="行业"
value={filters.categoryId}
onChange={(v) => setFilters(f => ({ ...f, categoryId: v, page: 1 }))}
options={categories.map(c => ({ label: c.name, value: c.id }))}
/>
<Select
allowClear
style={{ width: 140 }}
placeholder="素材类型"
value={filters.mediaType}
onChange={(v) => setFilters(f => ({ ...f, mediaType: v, page: 1 }))}
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
/>
<Select
allowClear
style={{ width: 140 }}
placeholder="处理状态"
value={filters.status}
onChange={(v) => setFilters(f => ({ ...f, status: v, page: 1 }))}
options={Object.entries(statusMap).map(([value, item]) => ({ value, label: item.text }))}
/>
<Button onClick={load}></Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
scroll={{ x: 1380 }}
columns={[
{
title: '预览',
width: 110,
render: (_: unknown, row: HomeMaterialAsset) => {
const imageUrl = getPreviewImageUrl(row);
if (row.mediaType === 'image') {
return imageUrl ? <Image width={86} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} src={imageUrl} /> : '-';
}
const videoUrl = getVideoUrl(row);
const posterUrl = apiUrl(row.coverUrl || '');
return videoUrl ? (
<video
src={videoUrl}
poster={posterUrl || undefined}
muted
preload="metadata"
style={{ width: 100, height: 64, objectFit: 'cover', borderRadius: 6, background: '#000' }}
/>
) : '-';
},
},
{ title: '标题', dataIndex: 'title', render: (v: string | null) => v || <span style={{ color: '#999' }}></span> },
{ title: '行业', dataIndex: 'categoryName' },
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
{
title: '生成提词',
width: 210,
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '附件',
width: 90,
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
},
{
title: '水印',
render: (_: unknown, row: HomeMaterialAsset) => {
const cfg = normalizeConfig(row.watermarkConfig, row.watermarkId);
return cfg.watermarkType === 'repeated_text' ? '重复文字水印' : (row.watermarkName || '图片水印');
},
},
{ title: '排序', dataIndex: 'sortOrder' },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
{
title: '操作',
width: 170,
align: 'center' as const,
render: (_: unknown, row: HomeMaterialAsset) => (
<Space size={8}>
<Button size="small" onClick={() => setPreview(row)}></Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
],
onClick: ({ key }) => {
if (key === 'generation_config') openGenerationConfig(row);
if (key === 'regenerate') openRegenerate(row);
if (key === 'delete') confirmDelete(row);
},
}}
>
<Button size="small" icon={<MoreOutlined />}></Button>
</Dropdown>
</Space>
),
},
]}
/>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
{preview && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div><b></b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
<div><b></b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
<div>
<b></b>
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
{preview.generationPrompt || '未设置'}
</div>
</div>
<div>
<b> / </b>
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
</div>
{preview.mediaType === 'image' ? (
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
) : (
<video src={getVideoUrl(preview)} poster={apiUrl(preview.coverUrl || '') || undefined} controls style={{ width: '100%', background: '#000' }} />
)}
</Space>
)}
</Modal>
<Modal
title="编辑生成配置"
open={!!editingConfig}
onCancel={() => !editSubmitting && setEditingConfig(null)}
onOk={submitGenerationConfig}
confirmLoading={editSubmitting}
cancelButtonProps={{ disabled: editSubmitting }}
width={920}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<div>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
maxLength={8000}
showCount
value={editPrompt}
disabled={editSubmitting}
placeholder="请输入用于生成该素材的提示词"
onChange={(e) => setEditPrompt(e.target.value)}
style={{ marginTop: 8 }}
/>
</div>
<div>
<Typography.Text strong> / </Typography.Text>
<div style={{ marginTop: 8 }}>
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
</div>
</div>
</Space>
</Modal>
<Modal
title="重新生成水印"
open={!!regen}
onCancel={() => !regenSubmitting && setRegen(null)}
onOk={submitRegenerate}
confirmLoading={regenSubmitting}
okButtonProps={{ disabled: regenSubmitting || !isValidConfig(regenConfig) }}
cancelButtonProps={{ disabled: regenSubmitting }}
width={980}
destroyOnHidden
>
{regen && (
<WatermarkEditor
value={regenConfig}
onChange={setRegenConfig}
watermarks={watermarks}
mediaUrl={apiUrl(regen.originalUrl || regen.watermarkedUrl)}
mediaType={regen.mediaType}
/>
)}
</Modal>
</>
);
};
export default HomeMaterialAssetTable;
@@ -0,0 +1,156 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, message } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import * as AntIcons from '@ant-design/icons';
import { deleteHomeMaterialCategory, getHomeMaterialCategories, saveHomeMaterialCategory } from '../../api';
import type { HomeMaterialCategory, HomeMaterialCategoryPayload } from '../../types';
const ICON_COMPONENTS = AntIcons as unknown as Record<string, React.ComponentType<{ style?: React.CSSProperties }>>;
const CATEGORY_ICON_OPTIONS = [
{ label: '通用应用', value: 'AppstoreOutlined' },
{ label: '图片素材', value: 'PictureOutlined' },
{ label: '视频素材', value: 'VideoCameraOutlined' },
{ label: '电商购物', value: 'ShoppingOutlined' },
{ label: '门店商铺', value: 'ShopOutlined' },
{ label: '美妆护肤', value: 'SkinOutlined' },
{ label: '餐饮食品', value: 'CoffeeOutlined' },
{ label: '家居生活', value: 'HomeOutlined' },
{ label: '汽车出行', value: 'CarOutlined' },
{ label: '医疗健康', value: 'MedicineBoxOutlined' },
{ label: '教育培训', value: 'BookOutlined' },
{ label: '数码科技', value: 'LaptopOutlined' },
{ label: '旅游定位', value: 'EnvironmentOutlined' },
{ label: '摄影影像', value: 'CameraOutlined' },
{ label: '礼品活动', value: 'GiftOutlined' },
{ label: '热门爆款', value: 'FireOutlined' },
{ label: '品牌服务', value: 'CustomerServiceOutlined' },
{ label: '营销增长', value: 'RocketOutlined' },
{ label: '机构企业', value: 'BankOutlined' },
{ label: '娱乐休闲', value: 'SmileOutlined' },
];
function renderIconOption(value?: string | null, label?: string) {
if (!value) {
return <span style={{ color: '#999' }}></span>;
}
const Icon = ICON_COMPONENTS[value];
return (
<Space size={6}>
{Icon ? <Icon /> : null}
<span>{label || value}</span>
<span style={{ color: '#999' }}>{value}</span>
</Space>
);
}
const HomeMaterialCategoryPanel: React.FC<{ onChanged?: () => void }> = ({ onChanged }) => {
const [items, setItems] = useState<HomeMaterialCategory[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HomeMaterialCategory | null>(null);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const res = await getHomeMaterialCategories({ page, pageSize: 20 });
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [page]);
const openEdit = (row?: HomeMaterialCategory) => {
setEditing(row || null);
form.setFieldsValue(row ? {
name: row.name,
key: row.key,
description: row.description,
icon: row.icon,
is_active: row.isActive,
sort_order: row.sortOrder,
} : { is_active: true, sort_order: 0 });
setModalOpen(true);
};
const submit = async () => {
const values = await form.validateFields();
await saveHomeMaterialCategory({ ...(values as HomeMaterialCategoryPayload), id: editing?.id });
message.success('保存成功');
setModalOpen(false);
await load();
onChanged?.();
};
return (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<div />
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}></Button>
</div>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ current: page, total, pageSize: 20, onChange: setPage }}
columns={[
{ title: '行业名称', dataIndex: 'name' },
{ title: 'Key', dataIndex: 'key' },
{ title: '图标', dataIndex: 'icon', render: (v?: string | null) => renderIconOption(v) },
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
{ title: '素材数', dataIndex: 'assetCount' },
{ title: '图片/视频', render: (_: unknown, r: HomeMaterialCategory) => `${r.imageCount}/${r.videoCount}` },
{ title: '排序', dataIndex: 'sortOrder' },
{
title: '操作',
render: (_: unknown, row: HomeMaterialCategory) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}></Button>
<Popconfirm title="确认删除该行业?" onConfirm={async () => { await deleteHomeMaterialCategory(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
]}
/>
<Modal title={editing ? '编辑行业' : '新增行业'} open={modalOpen} onOk={submit} onCancel={() => setModalOpen(false)} destroyOnHidden>
<Form form={form} layout="vertical">
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}><Input /></Form.Item>
<Form.Item name="key" label="行业Key" rules={[{ required: true, message: '请输入行业Key' }, { pattern: /^[A-Za-z0-9_-]+$/, message: '只允许字母、数字、下划线、中划线' }]}><Input /></Form.Item>
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="icon" label="图标">
<Select
allowClear
showSearch
placeholder="请选择行业图标"
optionFilterProp="searchText"
filterOption={(input, option) => {
const text = String((option as any)?.searchText || '').toLowerCase();
const value = String(option?.value || '').toLowerCase();
const keyword = input.toLowerCase();
return text.includes(keyword) || value.includes(keyword);
}}
options={CATEGORY_ICON_OPTIONS.map(item => ({
value: item.value,
searchText: `${item.label} ${item.value}`,
label: renderIconOption(item.value, item.label),
}))}
/>
</Form.Item>
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</>
);
};
export default HomeMaterialCategoryPanel;
@@ -0,0 +1,219 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { uploadHomeMaterialAsset } from '../../api';
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import WatermarkEditor from './WatermarkEditor';
import MediaReferencesEditor from './MediaReferencesEditor';
interface Props {
open: boolean;
onClose: () => void;
categories: HomeMaterialCategory[];
watermarks: HomeMaterialWatermark[];
onSuccess: () => void;
}
const defaultConfig: HomeMaterialWatermarkConfig = {
watermarkType: 'image',
watermarkId: null,
opacityLevel: 6,
position: 'bottom_right',
customXRatio: null,
customYRatio: null,
sizeMode: 'ratio',
widthRatio: 0.18,
widthPx: null,
marginX: 24,
marginY: 24,
textWatermark: {
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
rotateDeg: -30,
gapX: 220,
gapY: 140,
staggered: true,
},
};
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
return !!config.watermarkId;
}
const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, watermarks, onSuccess }) => {
const [form] = Form.useForm();
const [file, setFile] = useState<File | null>(null);
const [fileList, setFileList] = useState<any[]>([]);
const [mediaType, setMediaType] = useState<HomeMaterialMediaType>('image');
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [submitting, setSubmitting] = useState(false);
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
const resetState = () => {
setFile(null);
setFileList([]);
setMediaType('image');
setMediaObjectUrl('');
setSubmitting(false);
setMediaReferences([]);
};
useEffect(() => {
if (open) {
resetState();
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined });
}
}, [open, watermarks, form]);
useEffect(() => {
return () => {
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
};
}, [mediaObjectUrl]);
const mediaUrl = useMemo(() => mediaObjectUrl || undefined, [mediaObjectUrl]);
const handleBeforeUpload = (selectedFile: File) => {
const isImage = selectedFile.type.startsWith('image/');
const isVideo = selectedFile.type.startsWith('video/');
if (mediaType === 'image' && !isImage) {
message.warning('当前素材类型为图片,请选择图片文件');
return Upload.LIST_IGNORE;
}
if (mediaType === 'video' && !isVideo) {
message.warning('当前素材类型为视频,请选择视频文件');
return Upload.LIST_IGNORE;
}
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
const nextUrl = URL.createObjectURL(selectedFile);
setFile(selectedFile);
setMediaObjectUrl(nextUrl);
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
return false;
};
const handleRemoveFile = () => {
setFile(null);
setFileList([]);
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
setMediaObjectUrl('');
};
const changeMediaType = (value: HomeMaterialMediaType) => {
setMediaType(value);
handleRemoveFile();
};
const closeModal = () => {
if (submitting) return;
onClose();
};
const submit = async () => {
if (submitting) return;
const values = await form.validateFields();
if (!file) {
message.warning('请选择素材文件');
return;
}
if (config.watermarkType === 'image' && !config.watermarkId) {
message.warning('请选择水印图片');
return;
}
if (config.watermarkType === 'repeated_text' && !config.textWatermark?.text?.trim()) {
message.warning('请输入重复文字水印内容');
return;
}
setSubmitting(true);
try {
const res = await uploadHomeMaterialAsset({
categoryId: values.category_id,
file,
mediaType,
title: values.title?.trim() || null,
generationPrompt: values.generation_prompt?.trim() || null,
mediaReferences,
watermarkType: config.watermarkType,
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
opacityLevel: config.opacityLevel,
position: config.position,
customXRatio: config.customXRatio,
customYRatio: config.customYRatio,
sizeMode: config.sizeMode,
widthRatio: config.widthRatio,
widthPx: config.widthPx,
marginX: config.marginX,
marginY: config.marginY,
textWatermark: config.watermarkType === 'repeated_text' ? config.textWatermark : null,
isActive: values.is_active,
sortOrder: values.sort_order,
wait: false,
});
message.success(res.message || '已提交水印处理');
onSuccess();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal
title="上传首页素材"
open={open}
onCancel={closeModal}
onOk={submit}
confirmLoading={submitting}
okButtonProps={{ disabled: submitting || !file || !isValidConfig(config) }}
cancelButtonProps={{ disabled: submitting }}
width={980}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="category_id" label="行业" rules={[{ required: true, message: '请选择行业' }]}>
<Select options={categories.map(c => ({ label: c.name, value: c.id }))} disabled={submitting} />
</Form.Item>
<Form.Item name="media_type" label="素材类型" rules={[{ required: true }]}>
<Select
value={mediaType}
onChange={changeMediaType}
disabled={submitting}
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
/>
</Form.Item>
<Form.Item label="素材文件" required>
<Upload
beforeUpload={(f) => handleBeforeUpload(f as File)}
onRemove={handleRemoveFile}
fileList={fileList}
maxCount={1}
accept={mediaType === 'image' ? 'image/*' : 'video/*'}
disabled={submitting}
>
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}>{mediaType === 'image' ? '图片' : '视频'}</Button>
</Upload>
</Form.Item>
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
</Form.Item>
<Form.Item label="附件 / 参考素材">
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
</Form.Item>
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
</Form>
<WatermarkEditor value={config} onChange={setConfig} watermarks={watermarks} mediaUrl={mediaUrl} mediaType={mediaType} />
</Modal>
);
};
export default HomeMaterialUploadModal;
@@ -0,0 +1,167 @@
import React, { useMemo, useState } from 'react';
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import { uploadAdminFile } from '../../api';
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
interface Props {
value?: HomeMaterialMediaReference[] | null;
onChange: (value: HomeMaterialMediaReference[]) => void;
disabled?: boolean;
}
const mediaTypeText: Record<string, string> = {
image: '图片',
video: '视频',
audio: '音频',
};
function detectResourceType(file: File): AdminUploadResourceType | null {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
if (file.type.startsWith('audio/')) return 'audio';
const name = file.name.toLowerCase();
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
return null;
}
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
return new Promise(resolve => {
const url = URL.createObjectURL(file);
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
el.preload = 'metadata';
el.onloadedmetadata = () => {
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
URL.revokeObjectURL(url);
resolve(duration);
};
el.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
el.src = url;
});
}
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
const refs = useMemo(() => value || [], [value]);
const [uploading, setUploading] = useState(false);
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
};
const removeItem = (index: number) => {
onChange(refs.filter((_, idx) => idx !== index));
};
const uploadFile = async (file: File) => {
const resourceType = detectResourceType(file);
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
message.warning('附件仅支持图片、视频、音频');
return Upload.LIST_IGNORE;
}
setUploading(true);
try {
const durationSeconds = await readDuration(file, resourceType);
const res = await uploadAdminFile(file, {
scene: 'home_material_reference',
resourceType,
durationSeconds,
});
const ref = res.mediaReference;
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
throw new Error('上传返回附件格式异常');
}
onChange([
...refs,
{
url: ref.url,
type: ref.type as 'image' | 'video' | 'audio',
name: ref.name || file.name,
duration: ref.duration ?? durationSeconds,
source: ref.source,
uploadResourceId: ref.uploadResourceId,
displayUrl: ref.displayUrl || ref.url,
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
role: ref.role || `reference_${ref.type}`,
},
]);
message.success('附件上传成功');
} catch (e: any) {
message.error(e?.message || '附件上传失败');
} finally {
setUploading(false);
}
return Upload.LIST_IGNORE;
};
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}></Button>
</Upload>
{refs.length === 0 ? (
<div style={{ color: '#999', fontSize: 13 }}></div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{refs.map((ref, index) => {
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
const displayUrl = apiUrl(ref.displayUrl || ref.url);
return (
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
</div>
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
<Space wrap>
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.uploadResourceId && <Tag>ID{ref.uploadResourceId}</Tag>}
</Space>
<Input
size="small"
value={ref.name || ''}
placeholder="附件名称"
disabled={disabled}
onChange={(e) => updateItem(index, { name: e.target.value || null })}
/>
<Input
size="small"
value={ref.role || ''}
placeholder="附件角色,如 reference_image"
disabled={disabled}
onChange={(e) => updateItem(index, { role: e.target.value || null })}
/>
{(ref.type === 'video' || ref.type === 'audio') && (
<InputNumber
size="small"
min={0}
precision={3}
style={{ width: 180 }}
value={ref.duration ?? null}
placeholder="时长(秒)"
disabled={disabled}
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
/>
)}
<Input size="small" value={ref.url} disabled />
</Space>
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}></Button>
</div>
</Card>
);
})}
</Space>
)}
</Space>
);
};
export default MediaReferencesEditor;
@@ -0,0 +1,191 @@
import React from 'react';
import { Col, ColorPicker, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import WatermarkPreview from './WatermarkPreview';
interface WatermarkEditorProps {
value: HomeMaterialWatermarkConfig;
onChange: (next: HomeMaterialWatermarkConfig) => void;
watermarks: HomeMaterialWatermark[];
mediaUrl?: string | null;
mediaType?: HomeMaterialMediaType;
}
const positionOptions = [
{ label: '左上角', value: 'top_left' },
{ label: '顶部居中', value: 'top_center' },
{ label: '右上角', value: 'top_right' },
{ label: '左侧居中', value: 'middle_left' },
{ label: '正中间', value: 'center' },
{ label: '右侧居中', value: 'middle_right' },
{ label: '左下角', value: 'bottom_left' },
{ label: '底部居中', value: 'bottom_center' },
{ label: '右下角', value: 'bottom_right' },
{ label: '拖拽自定义', value: 'custom' },
];
const defaultTextWatermark = {
text: '民众智创',
opacityLevel: 2,
fontSizePx: 28,
color: '#ffffff',
rotateDeg: -30,
gapX: 220,
gapY: 140,
staggered: true,
};
const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, watermarks, mediaUrl, mediaType }) => {
const patch = (partial: Partial<HomeMaterialWatermarkConfig>) => onChange({ ...value, ...partial });
const patchText = (partial: Partial<NonNullable<HomeMaterialWatermarkConfig['textWatermark']>>) => {
patch({ textWatermark: { ...defaultTextWatermark, ...(value.textWatermark || {}), ...partial } });
};
const watermarkUrl = watermarks.find(w => w.id === value.watermarkId)?.fileUrl;
const textWatermark = { ...defaultTextWatermark, ...(value.textWatermark || {}) };
return (
<div>
<Form layout="vertical">
<Form.Item label="水印类型" required>
<Radio.Group
value={value.watermarkType || 'image'}
onChange={(e) => {
const nextType = e.target.value;
patch({
watermarkType: nextType,
textWatermark: nextType === 'repeated_text' ? textWatermark : null,
});
}}
>
<Radio.Button value="image"></Radio.Button>
<Radio.Button value="repeated_text"></Radio.Button>
</Radio.Group>
</Form.Item>
{value.watermarkType === 'repeated_text' ? (
<Row gutter={16}>
<Col span={24}>
<Form.Item label="水印文字" required extra="字体由后端固定开源可商用字体渲染,前端预览层和最终生成使用同一后端渲染逻辑。">
<Input
maxLength={64}
showCount
value={textWatermark.text}
onChange={(e) => patchText({ text: e.target.value })}
placeholder="请输入重复水印文字"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={`文字透明度:${textWatermark.opacityLevel}/10`} required>
<Slider min={1} max={10} step={1} value={textWatermark.opacityLevel} onChange={(v) => patchText({ opacityLevel: v })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="字号(px)" required>
<InputNumber min={8} max={160} style={{ width: '100%' }} value={textWatermark.fontSizePx} onChange={(v) => patchText({ fontSizePx: Number(v || 28) })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="文字颜色" required>
<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}>
<Form.Item label="旋转角度" required>
<InputNumber min={-90} max={90} style={{ width: '100%' }} value={textWatermark.rotateDeg} onChange={(v) => patchText({ rotateDeg: Number(v ?? -30) })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="横向间距" required>
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapX} onChange={(v) => patchText({ gapX: Number(v || 220) })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="纵向间距" required>
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapY} onChange={(v) => patchText({ gapY: Number(v || 140) })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="交错排列" valuePropName="checked">
<Switch checked={textWatermark.staggered} onChange={(checked) => patchText({ staggered: checked })} />
</Form.Item>
</Col>
</Row>
) : (
<Row gutter={16}>
<Col span={12}>
<Form.Item label="水印图片" required>
<Select
placeholder="请选择水印"
value={value.watermarkId || undefined}
onChange={(v) => patch({ watermarkId: v })}
options={watermarks.map(w => ({ label: `${w.name}${w.isDefault ? '(默认)' : ''}`, value: w.id }))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={`透明度:${value.opacityLevel}/10`} required>
<Slider min={1} max={10} step={1} value={value.opacityLevel} onChange={(v) => patch({ opacityLevel: v })} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="水印位置" required>
<Select value={value.position} onChange={(v) => patch({ position: v })} options={positionOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="大小模式" required>
<Select
value={value.sizeMode}
onChange={(v) => patch({ sizeMode: v })}
options={[{ label: '按素材宽度比例', value: 'ratio' }, { label: '固定像素宽度', value: 'px' }]}
/>
</Form.Item>
</Col>
{value.sizeMode === 'ratio' ? (
<Col span={12}>
<Form.Item label="水印宽度比例" required>
<InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} value={value.widthRatio ?? 0.18} onChange={(v) => patch({ widthRatio: Number(v || 0.18) })} />
</Form.Item>
</Col>
) : (
<Col span={12}>
<Form.Item label="水印宽度(px)" required>
<InputNumber min={1} max={10000} style={{ width: '100%' }} value={value.widthPx ?? 200} onChange={(v) => patch({ widthPx: Number(v || 200) })} />
</Form.Item>
</Col>
)}
<Col span={6}>
<Form.Item label="横向边距">
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginX} onChange={(v) => patch({ marginX: Number(v || 0) })} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="纵向边距">
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginY} onChange={(v) => patch({ marginY: Number(v || 0) })} />
</Form.Item>
</Col>
</Row>
)}
</Form>
<WatermarkPreview
mediaUrl={mediaUrl}
mediaType={mediaType}
watermarkUrl={watermarkUrl}
config={value}
onChange={(partial) => patch(partial)}
/>
</div>
);
};
export default WatermarkEditor;
@@ -0,0 +1,207 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Image, Input, Modal, Popconfirm, Space, Switch, Table, Upload, message } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
import { deleteHomeMaterialWatermark, getHomeMaterialWatermarks, updateHomeMaterialWatermark, uploadHomeMaterialWatermark } from '../../api';
import type { HomeMaterialWatermark } from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
interface Props {
open?: boolean;
onClose?: () => void;
embedded?: boolean;
onChanged?: () => void;
}
const WatermarkLibraryModal: React.FC<Props> = ({ open = true, onClose, embedded = false, onChanged }) => {
const [items, setItems] = useState<HomeMaterialWatermark[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [editing, setEditing] = useState<HomeMaterialWatermark | null>(null);
const [file, setFile] = useState<File | null>(null);
const [fileList, setFileList] = useState<any[]>([]);
const [previewUrl, setPreviewUrl] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const res = await getHomeMaterialWatermarks({ page: 1, pageSize: 200 });
setItems(res.items);
} finally {
setLoading(false);
}
};
useEffect(() => { if (open) load(); }, [open]);
useEffect(() => {
return () => {
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const resetUploadState = () => {
setFile(null);
setFileList([]);
setPreviewUrl('');
setSubmitting(false);
};
const openUpload = () => {
setEditing(null);
resetUploadState();
form.setFieldsValue({ name: '', is_default: false, is_active: true });
setUploadOpen(true);
};
const openEdit = (row: HomeMaterialWatermark) => {
setEditing(row);
resetUploadState();
setPreviewUrl(apiUrl(row.fileUrl));
form.setFieldsValue({ name: row.name, is_default: row.isDefault, is_active: row.isActive });
setUploadOpen(true);
};
const closeUploadModal = () => {
if (submitting) return;
setUploadOpen(false);
resetUploadState();
};
const handleBeforeUpload = (selectedFile: File) => {
if (!selectedFile.type.startsWith('image/')) {
message.warning('请选择图片文件');
return Upload.LIST_IGNORE;
}
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
const nextPreviewUrl = URL.createObjectURL(selectedFile);
setFile(selectedFile);
setPreviewUrl(nextPreviewUrl);
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
return false;
};
const handleRemoveFile = () => {
setFile(null);
setFileList([]);
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
setPreviewUrl('');
};
const submit = async () => {
if (submitting) return;
const values = await form.validateFields();
if (!editing && !file) {
message.warning('请选择水印图片');
return;
}
setSubmitting(true);
try {
if (editing) {
await updateHomeMaterialWatermark(editing.id, { name: values.name, is_default: values.is_default, is_active: values.is_active });
} else if (file) {
await uploadHomeMaterialWatermark(file, values.name, values.is_default);
}
message.success('保存成功');
setUploadOpen(false);
resetUploadState();
await load();
onChanged?.();
} finally {
setSubmitting(false);
}
};
const table = (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<div />
<Button type="primary" icon={<PlusOutlined />} onClick={openUpload}></Button>
</div>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ pageSize: 20 }}
columns={[
{ title: '预览', dataIndex: 'fileUrl', render: (url: string) => <Image width={80} src={apiUrl(url)} /> },
{ title: '名称', dataIndex: 'name' },
{ title: '尺寸', render: (_: unknown, r: HomeMaterialWatermark) => r.width && r.height ? `${r.width}×${r.height}` : '-' },
{ title: '默认', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
{
title: '操作',
render: (_: unknown, row: HomeMaterialWatermark) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}></Button>
<Popconfirm title="确认删除该水印?" onConfirm={async () => { await deleteHomeMaterialWatermark(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
]}
/>
<Modal
title={editing ? '编辑水印' : '上传水印'}
open={uploadOpen}
onOk={submit}
onCancel={closeUploadModal}
confirmLoading={submitting}
okButtonProps={{ disabled: submitting || (!editing && !file) }}
cancelButtonProps={{ disabled: submitting }}
destroyOnHidden
>
<Form form={form} layout="vertical">
{!editing && (
<Form.Item label="水印图片" required>
<Upload
beforeUpload={(f) => handleBeforeUpload(f as File)}
onRemove={handleRemoveFile}
fileList={fileList}
maxCount={1}
accept="image/png,image/jpeg,image/webp"
disabled={submitting}
>
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}></Button>
</Upload>
{previewUrl && (
<div style={{ marginTop: 12 }}>
<Image
src={previewUrl}
alt="水印预览"
width={180}
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
/>
</div>
)}
</Form.Item>
)}
{editing && previewUrl && (
<Form.Item label="当前水印">
<Image
src={previewUrl}
alt="当前水印"
width={180}
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
/>
</Form.Item>
)}
<Form.Item name="name" label="水印名称" rules={[{ required: true, message: '请输入水印名称' }]}><Input disabled={submitting} /></Form.Item>
<Form.Item name="is_default" label="默认水印" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
</Form>
</Modal>
</>
);
if (embedded) return table;
return <Modal title="水印库" open={open} onCancel={onClose} footer={null} width={900} destroyOnHidden>{table}</Modal>;
};
export default WatermarkLibraryModal;
@@ -0,0 +1,363 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Spin } from 'antd';
import { previewHomeMaterialTextWatermark } from '../../api';
import { apiUrl } from '../../utils/resourceUrl';
import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
interface WatermarkPreviewProps {
mediaUrl?: string | null;
mediaType?: HomeMaterialMediaType;
watermarkUrl?: string | null;
config: HomeMaterialWatermarkConfig;
onChange?: (patch: Partial<HomeMaterialWatermarkConfig>) => void;
}
interface Size {
width: number;
height: number;
}
const PREVIEW_MAX_HEIGHT = 360;
const PREVIEW_MIN_HEIGHT = 280;
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
const maxWidth = Math.max(0, containerWidth);
if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
if (!naturalSize?.width || !naturalSize?.height) {
return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
}
const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
return {
width: Math.max(1, Math.round(naturalSize.width * scale)),
height: Math.max(1, Math.round(naturalSize.height * scale)),
};
}
function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
if (config.sizeMode === 'px' && config.widthPx) {
return clamp(config.widthPx, 1, Math.max(1, stageWidth));
}
const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
return Math.max(1, stageWidth * ratio);
}
function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
const marginX = config.marginX ?? 24;
const marginY = config.marginY ?? 24;
switch (config.position) {
case 'top_left':
return { left: marginX, top: marginY };
case 'top_center':
return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
case 'top_right':
return { right: marginX, top: marginY };
case 'middle_left':
return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
case 'center':
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
case 'middle_right':
return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
case 'bottom_left':
return { left: marginX, bottom: marginY };
case 'bottom_center':
return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
case 'bottom_right':
default:
return { right: marginX, bottom: marginY };
}
}
function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
const text = config.textWatermark;
if (!text || !text.text?.trim()) return null;
return {
text: text.text.trim(),
opacity_level: text.opacityLevel,
font_size_px: text.fontSizePx,
color: text.color,
rotate_deg: text.rotateDeg,
gap_x: text.gapX,
gap_y: text.gapY,
staggered: text.staggered,
};
}
const WatermarkPreview: React.FC<WatermarkPreviewProps> = ({
mediaUrl,
mediaType = 'image',
watermarkUrl,
config,
onChange,
}) => {
const outerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const watermarkRef = useRef<HTMLImageElement | null>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const previewSeqRef = useRef(0);
const [containerWidth, setContainerWidth] = useState(0);
const [naturalSize, setNaturalSize] = useState<Size | null>(null);
const [watermarkSize, setWatermarkSize] = useState<Size>({ width: 0, height: 0 });
const [dragging, setDragging] = useState(false);
const [textPreviewLayer, setTextPreviewLayer] = useState<string>('');
const [textPreviewLoading, setTextPreviewLoading] = useState(false);
const [textPreviewError, setTextPreviewError] = useState<string>('');
const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
const isRepeatedText = config.watermarkType === 'repeated_text';
useLayoutEffect(() => {
if (!outerRef.current) return;
const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
update();
const observer = new ResizeObserver(update);
observer.observe(outerRef.current);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
if (!watermarkRef.current) return;
const update = () => {
const rect = watermarkRef.current?.getBoundingClientRect();
if (!rect) return;
setWatermarkSize({ width: rect.width, height: rect.height });
};
update();
const observer = new ResizeObserver(update);
observer.observe(watermarkRef.current);
return () => observer.disconnect();
}, [watermarkSrc, watermarkWidth]);
useEffect(() => {
setNaturalSize(null);
setTextPreviewLayer('');
setTextPreviewError('');
}, [mediaSrc, mediaType]);
useEffect(() => {
if (!isRepeatedText) {
setTextPreviewLayer('');
setTextPreviewLoading(false);
setTextPreviewError('');
return;
}
const textPayload = toTextPayload(config);
if (!naturalSize?.width || !naturalSize.height || !textPayload) {
setTextPreviewLayer('');
return;
}
const seq = ++previewSeqRef.current;
setTextPreviewLoading(true);
setTextPreviewError('');
const timer = window.setTimeout(async () => {
try {
const res = await previewHomeMaterialTextWatermark({
width: naturalSize.width,
height: naturalSize.height,
text_watermark: textPayload,
});
if (previewSeqRef.current === seq) {
setTextPreviewLayer(res.previewLayerDataUrl);
}
} catch (e) {
if (previewSeqRef.current === seq) {
setTextPreviewLayer('');
setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
}
} finally {
if (previewSeqRef.current === seq) setTextPreviewLoading(false);
}
}, 300);
return () => window.clearTimeout(timer);
}, [config, isRepeatedText, naturalSize]);
useEffect(() => {
const onMove = (e: PointerEvent) => {
if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
const rect = stageRef.current.getBoundingClientRect();
const wmWidth = watermarkSize.width || watermarkWidth;
const wmHeight = watermarkSize.height || 1;
const availableX = Math.max(1, rect.width - wmWidth);
const availableY = Math.max(1, rect.height - wmHeight);
const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
onChange({
position: 'custom',
customXRatio: Number((left / availableX).toFixed(4)),
customYRatio: Number((top / availableY).toFixed(4)),
});
};
const onUp = () => setDragging(false);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
};
}, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
const watermarkPositionStyle = useMemo<React.CSSProperties>(() => {
if (config.position !== 'custom') return calcPresetPosition(config);
const wmWidth = watermarkSize.width || watermarkWidth;
const wmHeight = watermarkSize.height || 1;
const availableX = Math.max(0, stageSize.width - wmWidth);
const availableY = Math.max(0, stageSize.height - wmHeight);
const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
return {
left: availableX * xRatio,
top: availableY * yRatio,
transform: 'none',
};
}, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
return (
<div
ref={outerRef}
style={{
position: 'relative',
width: '100%',
minHeight: Math.max(PREVIEW_MIN_HEIGHT, stageSize.height),
background: '#0f172a',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#94a3b8',
}}
>
{mediaSrc ? (
<div
ref={stageRef}
style={{
position: 'relative',
width: stageSize.width || '100%',
height: stageSize.height || PREVIEW_MIN_HEIGHT,
maxWidth: '100%',
overflow: 'hidden',
background: '#000',
}}
>
{mediaType === 'video' ? (
<video
src={mediaSrc}
controls
onLoadedMetadata={(e) => {
const target = e.currentTarget;
if (target.videoWidth && target.videoHeight) {
setNaturalSize({ width: target.videoWidth, height: target.videoHeight });
}
}}
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
/>
) : (
<img
src={mediaSrc}
alt="素材预览"
onLoad={(e) => {
const target = e.currentTarget;
if (target.naturalWidth && target.naturalHeight) {
setNaturalSize({ width: target.naturalWidth, height: target.naturalHeight });
}
}}
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
/>
)}
{isRepeatedText && textPreviewLayer && (
<img
src={textPreviewLayer}
alt="重复文字水印预览"
draggable={false}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'fill',
zIndex: 2,
pointerEvents: 'none',
userSelect: 'none',
}}
/>
)}
{isRepeatedText && (textPreviewLoading || textPreviewError) && (
<div
style={{
position: 'absolute',
left: 12,
bottom: 12,
zIndex: 3,
padding: '6px 10px',
borderRadius: 8,
background: 'rgba(15, 23, 42, 0.82)',
color: '#e2e8f0',
fontSize: 12,
}}
>
{textPreviewLoading ? <><Spin size="small" /> ...</> : textPreviewError}
</div>
)}
{!isRepeatedText && watermarkSrc && (
<img
ref={watermarkRef}
src={watermarkSrc}
alt="水印预览"
draggable={false}
onLoad={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setWatermarkSize({ width: rect.width, height: rect.height });
}}
onPointerDown={(e) => {
if (!onChange) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
dragOffsetRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
setDragging(true);
}}
style={{
position: 'absolute',
zIndex: 2,
cursor: onChange ? 'move' : 'default',
width: watermarkWidth,
opacity: (config.opacityLevel ?? 6) / 10,
userSelect: 'none',
touchAction: 'none',
pointerEvents: onChange ? 'auto' : 'none',
...watermarkPositionStyle,
}}
/>
)}
</div>
) : (
<span></span>
)}
</div>
);
};
export default WatermarkPreview;
+862 -1
View File
@@ -7,6 +7,8 @@ export interface User {
isAdmin: boolean;
userType: string;
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number;
}
export interface CreditRecord {
@@ -93,8 +95,91 @@ export interface LoginParams {
password: string;
}
export type ResourceCapacityUnit = 'MB' | 'GB' | 'TB';
export type ResourceCapacitySource = 'user' | 'global' | 'disabled';
export interface ResourceCapacityUsage {
enabled: boolean;
source: ResourceCapacitySource;
hasUserConfig: boolean;
usedBytes: number;
availableBytes: number | null;
totalBytes: number | null;
usagePercent: number | null;
exceeded: boolean;
limitValue: string | null;
limitUnit: ResourceCapacityUnit | null;
}
export interface ResourceCapacityConfigOut {
enabled: boolean;
limitValue: string;
limitUnit: ResourceCapacityUnit;
limitBytes: number;
}
export interface ResourceCapacityConfigPayload {
enabled: boolean;
limitValue?: string | number | null;
limitUnit?: ResourceCapacityUnit | null;
}
export interface AdminUserResourceCapacityOut {
hasUserConfig: boolean;
userConfig: ResourceCapacityConfigOut | null;
globalConfig: ResourceCapacityConfigOut;
effective: ResourceCapacityUsage;
}
// ── Admin Types ──────────────────────────────────────
export type AdminTeamStatus = 'active' | 'disabled' | string;
export interface AdminTeam {
id: string;
name: string;
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
}
export interface AdminTeamOption {
id: string;
name: string;
code?: string | null;
status: AdminTeamStatus;
}
export interface AdminTeamListResponse {
items: AdminTeam[];
total: number;
}
export interface AdminTeamQueryParams {
page?: number;
pageSize?: number;
keyword?: string;
status?: string;
}
export interface AdminTeamPayload {
name: string;
code?: string | null;
description?: string | null;
status: 'active' | 'disabled';
sort_order: number;
generation_prompt?: string | null;
media_references?: HomeMaterialMediaReference[] | null;
}
export interface AdminUser {
id: string;
username: string;
@@ -104,9 +189,38 @@ export interface AdminUser {
isActive: boolean;
isAdmin: boolean;
userType: string;
frontendUserKind: 'internal' | 'external';
teamId?: string | null;
teamName?: string | null;
createdAt: string;
lastLoginAt?: string;
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number;
}
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 {
@@ -124,6 +238,13 @@ 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 {
@@ -191,6 +312,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 {
@@ -203,7 +330,16 @@ export interface GenerationAiVideoEngine {
supportedResolutions: string[];
supportedDurations: number[];
maxDuration: number;
maxImageCount?: number;
maxVideoCount?: number;
maxAudioCount?: number;
supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean;
priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
isActive?: boolean;
deletedAt?: string | null;
}
export interface GenerationAiEnginesResponse {
@@ -225,24 +361,60 @@ export interface GenerationAiEngineOption {
supportedSizes?: Record<string, Record<string, string>>;
defaultSize?: string;
maxDuration?: number;
maxImageCount?: number;
maxVideoCount?: number;
maxAudioCount?: number;
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;
username: string;
projectId: string;
projectName: string;
industry?: string;
originalPrompt: string;
optimizedPrompt: string;
duration?: number;
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;
@@ -257,6 +429,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;
@@ -278,6 +454,7 @@ export interface GenerationAIEngineSnapshot {
name?: string;
provider?: string;
modelName?: string;
model?: string;
supportedModels?: string[];
defaultSize?: string;
selectedSize?: string;
@@ -297,7 +474,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;
@@ -326,6 +507,7 @@ export interface GenerationAITaskOut {
errorMessage?: string | null;
createdAt?: string | null;
generatedAt?: string | null;
childItems: GenerationAITaskOut[];
}
export interface GenerationAITaskListOut {
@@ -340,6 +522,9 @@ export interface GenerationAITaskQueryParams {
pageSize?: number;
userId?: string;
userName?: string;
engineId?: string;
createdStart?: string;
createdEnd?: string;
}
@@ -403,6 +588,10 @@ export interface ReplicationVideoGenerationOut {
promptSchema?: Record<string, any> | null;
finalPrompt?: string | null;
promptParams?: Record<string, any> | null;
schemaConfigSnapshot?: Record<string, any> | null;
schemaConfigSource?: string | null;
schemaConfigVersion?: string | null;
schemaConfigIsFallback?: boolean | null;
engineId?: string | null;
engineName?: string | null;
params?: Record<string, any> | null;
@@ -422,6 +611,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;
@@ -444,6 +636,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;
@@ -557,6 +751,7 @@ export interface ShotSegmentOut {
moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null;
moduleProjectFlowVersion?: 'v1' | 'v2' | string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
@@ -581,3 +776,669 @@ export interface AdminShotSegmentQueryParams {
page?: number;
pageSize?: number;
}
// ── Video Prompt Schema Config (Admin) ─────────────────────
export type VideoPromptSchemaNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'flow';
export interface VideoPromptSchemaNode {
key: string;
label: string;
type: VideoPromptSchemaNodeType | string;
enabled: boolean;
editable: boolean;
maxLength?: number;
value?: any;
children?: VideoPromptSchemaNode[];
contentKey?: string;
contentAliases?: string[];
itemFields?: VideoPromptFlowItemField[];
}
export interface VideoPromptFlowItemField {
key: string;
label: string;
enabled: boolean;
editable: boolean;
maxLength?: number;
value?: any;
}
export interface VideoPromptTimePlanSegment {
stage: string;
description: string;
}
export interface VideoPromptTimePlanRule {
minDuration: number;
maxDuration: number;
ratios: number[];
segments: VideoPromptTimePlanSegment[];
}
export interface VideoPromptSchemaConfigData {
version: string;
enabled: boolean;
sections: VideoPromptSchemaNode[];
timePlanRules: VideoPromptTimePlanRule[];
}
export interface VideoPromptSchemaConfigOut {
id?: string | null;
key: string;
description?: string | null;
isEnabled: boolean;
usingDefault: boolean;
data: VideoPromptSchemaConfigData;
defaultData: VideoPromptSchemaConfigData;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface VideoPromptSchemaConfigSavePayload {
is_enabled: boolean;
data: any;
}
export interface VideoPromptSchemaExportOut {
version: string;
key: string;
isEnabled: boolean;
data: VideoPromptSchemaConfigData;
}
export interface VideoPromptSchemaPreviewPayload {
is_enabled: boolean;
data: any;
duration: number;
aspect_ratio: string;
resolution: string;
frame_rate: string;
supported_durations?: number[];
supported_ratios?: string[];
supported_resolutions?: string[];
}
export interface VideoPromptSchemaPreviewOut {
schemaConfigSnapshot: Record<string, any>;
runtimeSchema: Record<string, any>;
timePlan: Record<string, any>[];
}
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
totalConsume: number;
/** 总回退(仅 type=refund= 真实退款 + 预扣释放 */
totalRefund: number;
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
totalCharge: number;
/** 独立统计列:预扣占用 hold */
totalHold: number;
/** 独立统计列:真实退款 refund(含历史 NULL) */
totalRefundReal: number;
/** 独立统计列:预扣释放 hold_releasetype=refund, action=hold_release */
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 AdminCreditRecord {
id: string;
userId: string;
username?: string;
phone?: string;
email?: string;
userType?: string;
userTypeLabel?: string;
frontendUserKind?: string;
frontendUserKindLabel?: string;
teamIdSnapshot?: string | null;
teamNameSnapshot?: string | null;
type: string;
recordType: string;
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
description?: string;
relatedId?: string;
bizKey?: string;
refundForBizKey?: string;
ownerType?: string;
ownerId?: string;
ownerDeleted?: boolean;
ownerDeletedAt?: string;
attemptNo?: number;
chargeKind?: string;
chargeKindLabel?: string;
chargeAction?: string;
chargeActionLabel?: string;
creditSubject?: string;
creditSubjectLabel?: string;
mediaType?: string;
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
sourceStepId?: string;
sourceStepCode?: string;
sourceStepCodeLabel?: string;
tokenUsageId?: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
engineType?: string;
engineId?: string;
engineName?: string;
engineProvider?: string;
engineModelName?: string;
createdAt?: string;
}
export interface AdminCreditRecordListResponse {
items: AdminCreditRecord[];
total: number;
summary: AdminCreditRecordSummary;
}
export interface AdminCreditRecordQueryParams {
page?: number;
pageSize?: number;
userId?: string;
userName?: string;
userType?: string;
frontendUserKind?: string;
teamId?: string;
recordType?: string;
type?: string;
creditSubject?: string;
mediaType?: string;
chargeKind?: string;
chargeAction?: string;
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
startDate?: string;
endDate?: string;
}
// ── 首页素材行业装修 ──────────────────────────────────────
export type AdminUploadScene =
| 'system_logo'
| 'system_pdf'
| 'open_type_thumb'
| 'home_material_reference'
| 'admin_common';
export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file';
export interface HomeMaterialMediaReference {
url: string;
type: 'image' | 'video' | 'audio';
name?: string | null;
duration?: number | null;
source?: string | null;
uploadResourceId?: string | null;
displayUrl?: string | null;
previewUrl?: string | null;
role?: string | null;
}
export interface AdminUploadMediaReference {
url: string;
type: AdminUploadResourceType;
name?: string | null;
duration?: number | null;
source: string;
uploadResourceId?: string | null;
displayUrl?: string | null;
previewUrl?: string | null;
role?: string | null;
}
export interface AdminUploadFileResult {
resourceId: string;
scene: AdminUploadScene;
module: string;
resourceType: AdminUploadResourceType;
url: string;
fileName: string;
originalFileName?: string | null;
fileSizeBytes: number;
durationSeconds?: number | null;
mediaReference?: AdminUploadMediaReference | null;
}
export type HomeMaterialMediaType = 'image' | 'video';
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
export type HomeMaterialWatermarkPosition =
| 'top_left'
| 'top_center'
| 'top_right'
| 'middle_left'
| 'center'
| 'middle_right'
| 'bottom_left'
| 'bottom_center'
| 'bottom_right'
| 'custom';
export type HomeMaterialWatermarkSizeMode = 'ratio' | 'px';
export type HomeMaterialPublicResponseMode = 'grouped' | 'flat';
export interface HomeMaterialConfig {
enabled: boolean;
title: string;
subtitle: string;
showOriginalInAdmin: boolean;
}
export interface HomeMaterialCategory {
id: string;
name: string;
key: string;
description?: string | null;
icon?: string | null;
isActive: boolean;
sortOrder: number;
assetCount: number;
imageCount: number;
videoCount: number;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface HomeMaterialCategoryPayload {
name: string;
key: string;
description?: string | null;
icon?: string | null;
is_active: boolean;
sort_order: number;
}
export interface HomeMaterialWatermark {
id: string;
name: string;
fileUrl: string;
fileName?: string | null;
fileSizeBytes: number;
width?: number | null;
height?: number | null;
isDefault: boolean;
isActive: boolean;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface HomeMaterialWatermarkPayload {
name: string;
is_default: boolean;
is_active: boolean;
}
export interface HomeMaterialTextWatermarkConfig {
text: string;
opacityLevel: number;
opacity?: number;
fontSizePx: number;
color: string;
rotateDeg: number;
gapX: number;
gapY: number;
staggered: boolean;
}
export interface HomeMaterialTextWatermarkPayload {
text: string;
opacity_level: number;
font_size_px: number;
color: string;
rotate_deg: number;
gap_x: number;
gap_y: number;
staggered: boolean;
}
export interface HomeMaterialWatermarkConfig {
watermarkType: HomeMaterialWatermarkType;
watermarkId?: string | null;
opacityLevel: number;
opacity?: number;
position: HomeMaterialWatermarkPosition;
customXRatio?: number | null;
customYRatio?: number | null;
sizeMode: HomeMaterialWatermarkSizeMode;
widthRatio?: number | null;
widthPx?: number | null;
marginX: number;
marginY: number;
textWatermark?: HomeMaterialTextWatermarkConfig | null;
}
export interface HomeMaterialAsset {
id: string;
categoryId: string;
categoryName?: string | null;
categoryKey?: string | null;
title?: string | null;
mediaType: HomeMaterialMediaType;
status: HomeMaterialAssetStatus;
originalUrl?: string | null;
watermarkedUrl?: string | null;
coverUrl?: string | null;
watermarkId?: string | null;
watermarkName?: string | null;
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
width?: number | null;
height?: number | null;
durationSeconds?: number | string | null;
fileSizeBytes: number;
watermarkedFileSizeBytes?: number | null;
isActive: boolean;
sortOrder: number;
errorMessage?: string | null;
processedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface HomeMaterialAssetUpdatePayload {
category_id: string;
title?: string | null;
is_active: boolean;
sort_order: number;
generation_prompt?: string | null;
media_references?: HomeMaterialMediaReference[] | null;
}
export interface HomeMaterialAssetStatusOut {
id: string;
status: HomeMaterialAssetStatus;
errorMessage?: string | null;
originalUrl?: string | null;
watermarkedUrl?: string | null;
coverUrl?: string | null;
processedAt?: string | null;
}
export interface HomeMaterialUploadResult {
id: string;
categoryId: string;
title?: string | null;
mediaType: HomeMaterialMediaType;
status: HomeMaterialAssetStatus;
originalUrl?: string | null;
watermarkedUrl?: string | null;
coverUrl?: string | null;
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
message: string;
}
export interface HomeMaterialListResponse<T> {
items: T[];
total: number;
}
export interface HomeMaterialCategoryQueryParams {
page?: number;
pageSize?: number;
keyword?: string;
isActive?: boolean;
}
export interface HomeMaterialWatermarkQueryParams {
page?: number;
pageSize?: number;
isActive?: boolean;
}
export interface HomeMaterialAssetQueryParams {
page?: number;
pageSize?: number;
categoryId?: string;
mediaType?: HomeMaterialMediaType;
status?: HomeMaterialAssetStatus;
isActive?: boolean;
keyword?: string;
}
export interface HomeMaterialUploadAssetParams {
categoryId: string;
file: File;
mediaType: HomeMaterialMediaType;
title?: string | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
watermarkType: HomeMaterialWatermarkType;
watermarkId?: string | null;
watermarkFile?: File | null;
opacityLevel: number;
position: HomeMaterialWatermarkPosition;
customXRatio?: number | null;
customYRatio?: number | null;
sizeMode: HomeMaterialWatermarkSizeMode;
widthRatio?: number | null;
widthPx?: number | null;
marginX: number;
marginY: number;
textWatermark?: HomeMaterialTextWatermarkConfig | null;
isActive: boolean;
sortOrder: number;
wait?: boolean;
waitTimeoutSeconds?: number;
}
export interface HomeMaterialRegeneratePayload {
watermark_type: HomeMaterialWatermarkType;
watermark_id?: string | null;
opacity_level: number;
position: HomeMaterialWatermarkPosition;
custom_x_ratio?: number | null;
custom_y_ratio?: number | null;
size_mode: HomeMaterialWatermarkSizeMode;
width_ratio?: number | null;
width_px?: number | null;
margin_x: number;
margin_y: number;
text_watermark?: HomeMaterialTextWatermarkPayload | null;
wait?: boolean;
wait_timeout_seconds?: number;
}
export interface HomeMaterialTextWatermarkPreviewRequest {
width: number;
height: number;
text_watermark: HomeMaterialTextWatermarkPayload;
}
export interface HomeMaterialTextWatermarkPreviewResponse {
width: number;
height: number;
previewLayerDataUrl: string;
}
export interface PrivatePortraitConfig {
enabled: boolean;
assetLimit: number;
usedAssetCount: number;
remainingAssetCount: number;
supportedAssetTypes?: string[];
unsupportedAssetTypes?: string[];
imageLimit?: number;
usedImageCount?: number;
remainingImageCount?: number;
}
export interface PrivatePortraitProject {
id: string;
userId?: string | null;
libraryType: string;
name: string;
nameSlug?: string | null;
remoteProjectName?: string | null;
description?: string | null;
status: string;
assetGroupCount: number;
assetCount: number;
imageAssetCount?: number;
videoAssetCount?: number;
activeAssetCount: number;
activeImageAssetCount?: number;
activeVideoAssetCount?: number;
lastUsedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitProjectListOut {
items: PrivatePortraitProject[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitValidateSession {
id: string;
projectId: string;
bytedToken?: string | null;
h5Link?: string | null;
callbackUrl?: string | null;
resultCode?: string | null;
status: string;
remoteGroupId?: string | null;
errorMessage?: string | null;
expiredAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAsset {
id: string;
userId?: string | null;
projectId: string;
projectName?: string | null;
groupId: string;
libraryType: string;
remoteGroupId: string;
remoteAssetId?: string | null;
remoteProjectName?: string | null;
assetType: string;
name?: string | null;
sourceUrl: string;
previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
remoteUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
fileSize?: number | null;
mimeType?: string | null;
status: string;
pollCount: number;
remoteDeleteStatus: string;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAssetListOut {
items: PrivatePortraitAsset[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitSelectableAsset {
id: string;
projectId: string;
projectName: string;
libraryType: string;
name?: string | null;
assetType: string;
previewUrl?: string | null;
displayUrl?: string | null;
providerUrl?: string | null;
videoDuration?: number | null;
videoCoverUrl?: string | null;
status: string;
createdAt?: string | null;
}
export interface PrivatePortraitSelectableAssetListOut {
items: PrivatePortraitSelectableAsset[];
total: number;
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;
}>;
};
}
+4
View File
@@ -0,0 +1,4 @@
declare module 'xlsx-js-style' {
export const utils: any;
export function writeFile(workbook: any, filename: string, options?: any): void;
}
+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;
}
}
+212
View File
@@ -0,0 +1,212 @@
import * as XLSX from 'xlsx-js-style';
export type ExcelCellValue = string | number | boolean | Date | null | undefined;
export interface StyledExcelColumn<T = any> {
title: string;
key?: string;
width?: number;
minWidth?: number;
maxWidth?: number;
align?: 'left' | 'center' | 'right';
numFmt?: string;
render: (row: T, rowIndex: number) => ExcelCellValue;
}
export interface StyledExcelExportOptions<T = any> {
filename: string;
sheetName: string;
title?: string;
metadataRows?: ExcelCellValue[][];
summaryRows?: ExcelCellValue[][];
columns: StyledExcelColumn<T>[];
rows: T[];
}
const BLACK_BORDER = {
top: { style: 'thin', color: { rgb: '000000' } },
right: { style: 'thin', color: { rgb: '000000' } },
bottom: { style: 'thin', color: { rgb: '000000' } },
left: { style: 'thin', color: { rgb: '000000' } },
};
const BASE_ALIGNMENT = {
vertical: 'center',
wrapText: true,
};
const TITLE_STYLE = {
font: { bold: true, sz: 16, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '111827' } },
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
border: BLACK_BORDER,
};
const SECTION_LABEL_STYLE = {
font: { bold: true, color: { rgb: '111827' } },
fill: { fgColor: { rgb: 'F3F4F6' } },
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
border: BLACK_BORDER,
};
const SECTION_VALUE_STYLE = {
font: { color: { rgb: '111827' } },
fill: { fgColor: { rgb: 'FFFFFF' } },
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
border: BLACK_BORDER,
};
const HEADER_STYLE = {
font: { bold: true, color: { rgb: 'FFFFFF' } },
fill: { fgColor: { rgb: '374151' } },
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
border: BLACK_BORDER,
};
const BODY_STYLE = {
font: { color: { rgb: '111827' } },
fill: { fgColor: { rgb: 'FFFFFF' } },
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
border: BLACK_BORDER,
};
function normalizeCellValue(value: ExcelCellValue): ExcelCellValue {
if (value === null || value === undefined) return '';
return value;
}
function visualLength(value: ExcelCellValue): number {
if (value === null || value === undefined) return 0;
const text = value instanceof Date ? value.toISOString() : String(value);
const lines = text.split(/\r?\n/);
return Math.max(...lines.map((line) => Array.from(line).reduce((len, ch) => len + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)), 0);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function estimateColumnWidths(rows: ExcelCellValue[][], columns: StyledExcelColumn[]): { wch: number }[] {
const totalColumns = Math.max(columns.length, ...rows.map((row) => row.length));
return Array.from({ length: totalColumns }, (_, index) => {
const config = columns[index];
if (config?.width) return { wch: config.width };
const maxLength = rows.reduce<number>((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title));
const minWidth = config?.minWidth ?? 10;
const maxWidth = config?.maxWidth ?? 42;
return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) };
});
}
function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number {
const maxLines = row.reduce<number>((max, cell, index) => {
const width = Math.max(8, colWidths[index]?.wch || 12);
const text = cell === null || cell === undefined ? '' : String(cell);
const explicitLines = text.split(/\r?\n/);
const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0);
return Math.max(max, wrappedLines);
}, 1);
return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120);
}
function applyCellStyle(cell: any, style: any, numFmt?: string): void {
cell.s = {
...style,
alignment: { ...style.alignment },
border: BLACK_BORDER,
};
if (numFmt) cell.z = numFmt;
}
function isNumericCell(value: ExcelCellValue): boolean {
return typeof value === 'number' && Number.isFinite(value);
}
export function exportStyledExcel<T>(options: StyledExcelExportOptions<T>): void {
const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options;
const totalColumns = Math.max(1, columns.length);
const aoa: ExcelCellValue[][] = [];
let titleRowIndex = -1;
let headerRowIndex = -1;
if (title) {
titleRowIndex = aoa.length;
aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]);
}
metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
if (metadataRows.length || summaryRows.length) {
aoa.push(Array.from({ length: totalColumns }, () => ''));
}
headerRowIndex = aoa.length;
aoa.push(columns.map((column) => column.title));
rows.forEach((row, rowIndex) => {
aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex))));
});
const worksheet = XLSX.utils.aoa_to_sheet(aoa);
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
const colWidths = estimateColumnWidths(aoa, columns);
worksheet['!cols'] = colWidths;
worksheet['!rows'] = aoa.map((row, index) => {
if (index === titleRowIndex) return { hpt: 34 };
if (index === headerRowIndex) return { hpt: 28 };
if (row.every((cell) => cell === '')) return { hpt: 10 };
return { hpt: estimateRowHeight(row, colWidths) };
});
if (titleRowIndex >= 0 && totalColumns > 1) {
worksheet['!merges'] = worksheet['!merges'] || [];
worksheet['!merges'].push({
s: { r: titleRowIndex, c: 0 },
e: { r: titleRowIndex, c: totalColumns - 1 },
});
}
worksheet['!autofilter'] = {
ref: XLSX.utils.encode_range({
s: { r: headerRowIndex, c: 0 },
e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 },
}),
};
for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) {
for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) {
const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex });
const cell = worksheet[address] || { t: 's', v: '' };
worksheet[address] = cell;
if (rowIndex === titleRowIndex) {
applyCellStyle(cell, TITLE_STYLE);
continue;
}
if (rowIndex === headerRowIndex) {
applyCellStyle(cell, HEADER_STYLE);
continue;
}
if (rowIndex < headerRowIndex) {
applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined);
continue;
}
const column = columns[colIndex];
const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left');
applyCellStyle(cell, {
...BODY_STYLE,
alignment: { ...BASE_ALIGNMENT, horizontal: align },
}, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined));
}
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
XLSX.writeFile(workbook, filename);
}
+35 -8
View File
@@ -1,11 +1,38 @@
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);
}
@@ -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;
+47
View File
@@ -0,0 +1,47 @@
const RAW_API_BASE = String(import.meta.env.VITE_API_BASE || 'http://localhost:8000')
.trim()
.replace(/\/$/, '');
export const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
export const isAbsoluteLikeUrl = (url: string): boolean => (
/^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url)
);
export const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim());
export const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (isAbsoluteLikeUrl(value)) return value;
if (!RESOURCE_BASE) {
return value.startsWith('/') ? value : `/${value}`;
}
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
};
export const isUrlExpired = (url?: string | null): boolean => {
if (!url || isBlobUrl(url)) return false;
try {
const parsed = new URL(apiUrl(url), window.location.origin);
const expireValue =
parsed.searchParams.get('exp') ||
parsed.searchParams.get('expires') ||
parsed.searchParams.get('expire') ||
parsed.searchParams.get('expires_at') ||
parsed.searchParams.get('x-expires');
if (!expireValue) return false;
const expireNumber = Number(expireValue);
if (!Number.isFinite(expireNumber)) return false;
const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000;
return Date.now() >= expireMs;
} catch {
return false;
}
};
@@ -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)));
};
@@ -0,0 +1,152 @@
export type JsonValue = any;
export type JsonRecord = Record<string, JsonValue>;
export interface VideoPromptSchemaFieldConfig {
key: string;
label: string;
type?: string;
enabled: boolean;
editable: boolean;
maxLength?: number;
}
export interface VideoPromptSchemaSectionConfig extends VideoPromptSchemaFieldConfig {
contentKey?: string;
itemFields: VideoPromptSchemaFieldConfig[];
children: VideoPromptSchemaFieldConfig[];
}
function isRecord(value: unknown): value is JsonRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(source: JsonRecord, ...keys: string[]): string {
for (const key of keys) {
const value = source[key];
if (value !== undefined && value !== null && String(value).trim() !== '') return String(value);
}
return '';
}
function readBool(source: JsonRecord, defaultValue: boolean, ...keys: string[]): boolean {
for (const key of keys) {
const value = source[key];
if (value === true || value === false) return value;
if (value === 'true') return true;
if (value === 'false') return false;
}
return defaultValue;
}
function readNumber(source: JsonRecord, ...keys: string[]): number | undefined {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
}
return undefined;
}
function normalizeField(raw: unknown): VideoPromptSchemaFieldConfig | null {
if (!isRecord(raw)) return null;
const key = readString(raw, 'key');
if (!key) return null;
return {
key,
label: readString(raw, 'label') || key,
type: readString(raw, 'type') || undefined,
enabled: readBool(raw, true, 'enabled'),
editable: readBool(raw, true, 'editable'),
maxLength: readNumber(raw, 'maxLength', 'max_length'),
};
}
export function getSchemaConfigData(snapshot: unknown): JsonRecord | null {
if (!isRecord(snapshot)) return null;
const data = snapshot.data;
if (isRecord(data)) return data;
if (Array.isArray(snapshot.sections)) return snapshot;
return null;
}
export function hasSchemaConfigSnapshot(snapshot: unknown): boolean {
return !!getSchemaConfigData(snapshot);
}
export function normalizeSchemaSections(snapshot: unknown): VideoPromptSchemaSectionConfig[] {
const data = getSchemaConfigData(snapshot);
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
return rawSections
.map((raw): VideoPromptSchemaSectionConfig | null => {
const base = normalizeField(raw);
if (!base || !isRecord(raw)) return null;
const children = Array.isArray(raw.children) ? raw.children.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[] : [];
const itemFields = Array.isArray(raw.itemFields)
? raw.itemFields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
: Array.isArray(raw.item_fields)
? raw.item_fields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
: [];
return {
...base,
contentKey: readString(raw, 'contentKey', 'content_key') || undefined,
children,
itemFields,
};
})
.filter((section): section is VideoPromptSchemaSectionConfig => !!section && section.enabled);
}
export function inferSchemaSections(schema: unknown): VideoPromptSchemaSectionConfig[] {
if (!isRecord(schema)) return [];
return Object.entries(schema)
.filter(([key]) => !['schemaVersion', 'schemaUsage', 'schema_version', 'schema_usage'].includes(key))
.map(([key, value]) => {
if (Array.isArray(value) && value.every(isRecord)) {
const fields = Array.from(new Set(value.flatMap((item) => Object.keys(item))));
return {
key,
label: key,
type: 'flow',
enabled: true,
editable: false,
itemFields: fields.filter((field) => field !== '时间段').map((field) => ({ key: field, label: field, enabled: true, editable: false })),
children: [],
} as VideoPromptSchemaSectionConfig;
}
if (isRecord(value)) {
return {
key,
label: key,
type: 'object',
enabled: true,
editable: false,
children: Object.keys(value).map((field) => ({ key: field, label: field, enabled: true, editable: false })),
itemFields: [],
} as VideoPromptSchemaSectionConfig;
}
return {
key,
label: key,
type: 'text',
enabled: true,
editable: false,
children: [],
itemFields: [],
} as VideoPromptSchemaSectionConfig;
});
}
export function stringifySchemaValue(value: JsonValue): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
}
+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/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.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/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.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/store/index.ts","./src/types/index.ts","./src/utils/formatdate.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/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/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"}
+11 -1
View File
@@ -6,9 +6,10 @@ SECRET_KEY=local-dev-secret-key-not-for-production
# Database (PostgreSQL)
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://localhost:6379/3
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://localhost:6379/5
CELERY_RESULT_BACKEND=redis://localhost:6379/6
@@ -42,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
@@ -56,6 +61,11 @@ VIDEO_COVER_WIDTH=600
VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png
# VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
# VOLC_SMS
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SMS_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
+2
View File
@@ -40,6 +40,8 @@ from app.models.material_cost import MaterialCost # noqa: F401
from app.models.generated_resource import GeneratedResource # noqa: F401
from app.models.pre_test_template import PreTestTemplate # noqa: F401
from app.models.upload_task import UploadTask # noqa: F401
from app.models.open_type import OpenType # noqa: F401
config = context.config
@@ -0,0 +1,29 @@
"""add video engine max audio count
Revision ID: 054735900c23
Revises: d4e5f6a7b8c9
Create Date: 2026-07-03 11:23:07.721724
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '054735900c23'
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('video_engines', sa.Column('max_audio_count', sa.Integer(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('video_engines', 'max_audio_count')
# ### end Alembic commands ###
@@ -0,0 +1,935 @@
"""add credit record billing snapshots and frontend user kind
Revision ID: 0a8da2d3c091
Revises: 5e2c124e5484
Create Date: 2026-06-25 13:25:21.246025
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0a8da2d3c091"
down_revision: Union[str, None] = "5e2c124e5484"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1) 表结构变更。
# 注意:autogenerate 生成的 upload_task.other_info 删除语句与本次需求无关,已移除。
op.add_column("users", sa.Column("frontend_user_kind", sa.String(length=16), server_default="external", nullable=False))
op.add_column("credit_records", sa.Column("owner_type", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("owner_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("attempt_no", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("charge_kind", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("charge_action", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("credit_subject", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("media_type", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("billing_scene", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_module", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_project_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_step_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_step_code", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("input_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("output_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("total_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("engine_type", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("engine_id", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("engine_name", sa.String(length=128), nullable=True))
op.add_column("credit_records", sa.Column("engine_provider", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("engine_model_name", sa.String(length=128), nullable=True))
op.add_column("credit_records", sa.Column("user_type_snapshot", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("frontend_user_kind_snapshot", sa.String(length=16), nullable=True))
op.add_column("module_generation_steps", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
op.add_column("module_generation_steps", sa.Column("model_config_id", sa.String(length=32), nullable=True))
op.add_column("module_generation_steps", sa.Column("input_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("output_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("total_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("text_credits_cost", sa.Float(), nullable=True))
op.add_column("token_usage", sa.Column("owner_type", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("owner_id", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("biz_key", sa.String(length=160), nullable=True))
op.add_column("token_usage", sa.Column("source_module", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("source_step_code", sa.String(length=64), nullable=True))
# 2) 历史数据补齐。
_backfill_legacy_data()
# 3) 索引。放在历史补齐之后,避免大批量 UPDATE 时维护过多新索引。
op.create_index(op.f("ix_users_frontend_user_kind"), "users", ["frontend_user_kind"], unique=False)
op.create_index(op.f("ix_users_user_type"), "users", ["user_type"], unique=False)
op.create_index(op.f("ix_credit_records_billing_scene"), "credit_records", ["billing_scene"], unique=False)
op.create_index(op.f("ix_credit_records_charge_kind"), "credit_records", ["charge_kind"], unique=False)
op.create_index(op.f("ix_credit_records_credit_subject"), "credit_records", ["credit_subject"], unique=False)
op.create_index(op.f("ix_credit_records_engine_id"), "credit_records", ["engine_id"], unique=False)
op.create_index(op.f("ix_credit_records_engine_type"), "credit_records", ["engine_type"], unique=False)
op.create_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), "credit_records", ["frontend_user_kind_snapshot"], unique=False)
op.create_index(op.f("ix_credit_records_media_type"), "credit_records", ["media_type"], unique=False)
op.create_index("ix_credit_records_owner", "credit_records", ["owner_type", "owner_id"], unique=False)
op.create_index(op.f("ix_credit_records_owner_id"), "credit_records", ["owner_id"], unique=False)
op.create_index(op.f("ix_credit_records_owner_type"), "credit_records", ["owner_type"], unique=False)
op.create_index(op.f("ix_credit_records_source_module"), "credit_records", ["source_module"], unique=False)
op.create_index("ix_credit_records_source_module_scene", "credit_records", ["source_module", "billing_scene"], unique=False)
op.create_index(op.f("ix_credit_records_source_project_id"), "credit_records", ["source_project_id"], unique=False)
op.create_index(op.f("ix_credit_records_source_step_code"), "credit_records", ["source_step_code"], unique=False)
op.create_index(op.f("ix_credit_records_source_step_id"), "credit_records", ["source_step_id"], unique=False)
op.create_index("ix_credit_records_subject_media", "credit_records", ["credit_subject", "media_type"], unique=False)
op.create_index(op.f("ix_credit_records_token_usage_id"), "credit_records", ["token_usage_id"], unique=False)
op.create_index(op.f("ix_credit_records_type"), "credit_records", ["type"], unique=False)
op.create_index("ix_credit_records_user_kind_time", "credit_records", ["user_type_snapshot", "frontend_user_kind_snapshot", "created_at"], unique=False)
op.create_index(op.f("ix_credit_records_user_type_snapshot"), "credit_records", ["user_type_snapshot"], unique=False)
op.create_index(op.f("ix_module_generation_steps_model_config_id"), "module_generation_steps", ["model_config_id"], unique=False)
op.create_index(op.f("ix_module_generation_steps_token_usage_id"), "module_generation_steps", ["token_usage_id"], unique=False)
op.create_index("ix_token_usage_biz_key", "token_usage", ["biz_key"], unique=False)
op.create_index("ix_token_usage_owner", "token_usage", ["owner_type", "owner_id"], unique=False)
op.create_index(op.f("ix_token_usage_owner_id"), "token_usage", ["owner_id"], unique=False)
op.create_index(op.f("ix_token_usage_owner_type"), "token_usage", ["owner_type"], unique=False)
op.create_index(op.f("ix_token_usage_source_module"), "token_usage", ["source_module"], unique=False)
op.create_index(op.f("ix_token_usage_source_step_code"), "token_usage", ["source_step_code"], unique=False)
def _backfill_legacy_data() -> None:
"""修复历史账务流水的归类、模块、步骤、token、模型和引擎快照。
设计原则:
- 只补能确定的数据,不用“同用户 + 时间接近 + token 相等”这类不可靠方式强行匹配。
- ModuleGenerationStep 包含 deleted_at 不为空的软删数据,保证历史流水仍可展示来源。
- CreditRecord 是账务事实表,历史业务数据软删不影响流水展示。
- CreditRecord 统一使用 engine_* 承载执行配置快照:model/image/video。
"""
# users.frontend_user_kind 默认外部用户。
op.execute(
"""
UPDATE users
SET frontend_user_kind = 'external'
WHERE frontend_user_kind IS NULL OR frontend_user_kind NOT IN ('internal', 'external')
"""
)
# 用户类型快照:按迁移时 users 当前值兜底。
op.execute(
"""
UPDATE credit_records cr
SET
user_type_snapshot = COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END),
frontend_user_kind_snapshot = CASE
WHEN COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END) = 'frontend'
THEN COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
ELSE COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
END
FROM users u
WHERE cr.user_id = u.id
"""
)
# 解析标准 biz_keyowner_type:owner_id:attempt:{n}:charge_kind:charge/refund。
op.execute(
"""
UPDATE credit_records
SET
owner_type = split_part(biz_key, ':', 1),
owner_id = split_part(biz_key, ':', 2),
attempt_no = split_part(biz_key, ':', 4)::integer,
charge_kind = split_part(biz_key, ':', 5),
charge_action = split_part(biz_key, ':', 6)
WHERE biz_key IS NOT NULL
AND biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
"""
)
# 少数退款记录如果自身 biz_key 不完整,用 refund_for_biz_key 兜底解析业务归属。
op.execute(
"""
UPDATE credit_records
SET
owner_type = split_part(refund_for_biz_key, ':', 1),
owner_id = split_part(refund_for_biz_key, ':', 2),
attempt_no = split_part(refund_for_biz_key, ':', 4)::integer,
charge_kind = split_part(refund_for_biz_key, ':', 5),
charge_action = 'refund'
WHERE type = 'refund'
AND (owner_type IS NULL OR owner_type = 'unknown' OR owner_id IS NULL)
AND refund_for_biz_key IS NOT NULL
AND refund_for_biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
"""
)
# 支付充值流水。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
owner_id = COALESCE(cr.owner_id, po.id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'recharge'),
charge_action = COALESCE(cr.charge_action, 'charge'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'recharge'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'recharge'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
FROM payment_orders po
WHERE cr.related_id = po.id
AND cr.type = 'recharge'
AND (cr.description IS NULL OR cr.description NOT LIKE '管理员调整:%')
"""
)
# 支付退款扣回积分。历史上它通常是 type=consume,但属于支付退款,不应该混入生成消费。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
owner_id = COALESCE(cr.owner_id, po.id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'refund'),
charge_action = COALESCE(cr.charge_action, 'refund'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'refund'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'refund'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
FROM payment_orders po
WHERE cr.related_id = po.id
AND cr.type = 'consume'
AND (cr.description ILIKE '%退款%' OR po.status = 'refunded')
"""
)
# 管理员手动调整,不混入普通充值/消费。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'admin_adjust'),
owner_id = COALESCE(cr.owner_id, cr.related_id),
charge_kind = 'admin_adjust',
charge_action = CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END,
credit_subject = 'admin_adjust',
billing_scene = 'admin_adjust',
source_module = 'admin'
WHERE cr.description LIKE '管理员调整:%'
"""
)
# GenerationRecord 旧版提示词优化裸扣费:generation.py /optimize 历史上未传 related_id / biz_key / record_meta。
# 只做严格唯一匹配:同用户、同项目名称描述、同扣费金额,并且 credit_record 与 generation_record 均唯一时才回填。
op.execute(
"""
WITH candidates AS (
SELECT
cr.id AS credit_record_id,
gr.id AS generation_record_id,
gr.text_tokens_used,
gr.text_credits_cost
FROM credit_records cr
JOIN generation_records gr
ON gr.user_id = cr.user_id
JOIN projects p
ON p.id = gr.project_id
WHERE cr.type = 'consume'
AND cr.biz_key IS NULL
AND cr.refund_for_biz_key IS NULL
AND cr.related_id IS NULL
AND (cr.owner_type IS NULL OR cr.owner_type = 'unknown')
AND (cr.source_module IS NULL OR cr.source_module = 'unknown')
AND (cr.billing_scene IS NULL OR cr.billing_scene = 'unknown')
AND COALESCE(gr.text_credits_cost, 0) > 0
AND COALESCE(gr.text_tokens_used, 0) > 0
AND cr.description = '提示词优化 - ' || p.name
AND ABS(ABS(cr.amount)::numeric - ROUND(COALESCE(gr.text_credits_cost, 0)::numeric, 2)) < 0.01
),
unique_credit AS (
SELECT credit_record_id
FROM candidates
GROUP BY credit_record_id
HAVING COUNT(*) = 1
),
unique_generation AS (
SELECT generation_record_id
FROM candidates
GROUP BY generation_record_id
HAVING COUNT(*) = 1
),
matched AS (
SELECT c.*
FROM candidates c
JOIN unique_credit uc ON uc.credit_record_id = c.credit_record_id
JOIN unique_generation ug ON ug.generation_record_id = c.generation_record_id
)
UPDATE credit_records cr
SET
related_id = COALESCE(cr.related_id, matched.generation_record_id),
owner_type = 'generation_record',
owner_id = matched.generation_record_id,
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = 'text_prompt',
charge_action = 'charge',
credit_subject = 'text',
billing_scene = 'generation_record_text_prompt_optimize',
source_module = 'generation_record',
total_tokens = COALESCE(cr.total_tokens, NULLIF(matched.text_tokens_used, 0)),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM matched
WHERE cr.id = matched.credit_record_id
"""
)
# GenerationRecord:项目记录提词/文件解析/图片理解/图片生成/视频生成。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'generation_record'),
owner_id = COALESCE(cr.owner_id, gr.id),
related_id = COALESCE(cr.related_id, gr.id),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'generation_record'),
media_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) IN ('image', 'video') THEN lower(gr.gen_type)
ELSE cr.media_type
END,
total_tokens = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN COALESCE(cr.total_tokens, NULLIF(gr.text_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(gr.image_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(gr.video_tokens_used, 0))
ELSE cr.total_tokens
END,
credit_subject = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
ELSE cr.credit_subject
END,
billing_scene = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN 'generation_record_text_prompt_optimize'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'file_parse' THEN 'generation_record_file_parse'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'vision_input' THEN 'generation_record_vision_input'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN 'generation_record_image_generate'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN 'generation_record_video_generate'
ELSE cr.billing_scene
END,
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
ELSE cr.engine_type
END
FROM generation_records gr
WHERE (cr.owner_type = 'generation_record' AND cr.owner_id = gr.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = gr.id)
"""
)
# ModuleGenerationStep 提词优化:从 output_json.usage.credit_biz_key 精准匹配 CreditRecord.biz_key。
# 包含软删步骤,不加 deleted_at 过滤。
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.project_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
) AS token_usage_id,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
) AS model_config_id
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
)
UPDATE module_generation_steps s
SET
token_usage_id = COALESCE(s.token_usage_id, step_usage.token_usage_id),
model_config_id = COALESCE(s.model_config_id, step_usage.model_config_id),
input_tokens = COALESCE(
s.input_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
),
output_tokens = COALESCE(
s.output_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
),
total_tokens = COALESCE(
s.total_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
CASE
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
ELSE NULL
END
),
text_credits_cost = COALESCE(
s.text_credits_cost,
CASE WHEN COALESCE(step_usage.usage ->> 'text_credits_cost', '') ~ '^[0-9]+(\\.[0-9]+)?$' THEN (step_usage.usage ->> 'text_credits_cost')::double precision ELSE NULL END
)
FROM step_usage
WHERE s.id = step_usage.step_id
"""
)
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.project_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage,
COALESCE(
NULLIF(s.token_usage_id, ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
) AS step_token_usage_id,
COALESCE(
NULLIF(s.model_config_id, ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
) AS step_model_config_id,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'name', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_name', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigName', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'name', '')
) AS step_model_config_name,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelProvider', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'provider', ''),
NULLIF(s.output_json::jsonb ->> 'model_provider', ''),
NULLIF(s.output_json::jsonb ->> 'modelProvider', ''),
NULLIF(s.output_json::jsonb ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'provider', '')
) AS step_model_provider,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb ->> 'model_name', ''),
NULLIF(s.output_json::jsonb ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'modelName', '')
) AS step_model_name
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
)
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'module_generation_step'),
owner_id = COALESCE(cr.owner_id, step_usage.step_id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'text_prompt'),
charge_action = COALESCE(cr.charge_action, 'charge'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'text'),
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'hot_opening_image_prompt_optimize'
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'hot_opening_video_prompt_optimize'
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'shot_image_prompt_optimize'
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'shot_video_prompt_optimize'
ELSE 'unknown'
END
),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_usage.module),
source_project_id = COALESCE(cr.source_project_id, step_usage.project_id),
source_step_id = COALESCE(cr.source_step_id, step_usage.step_id),
source_step_code = COALESCE(cr.source_step_code, step_usage.step_code),
token_usage_id = COALESCE(cr.token_usage_id, step_usage.step_token_usage_id),
input_tokens = COALESCE(
cr.input_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
),
output_tokens = COALESCE(
cr.output_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
),
total_tokens = COALESCE(
cr.total_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
CASE
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
ELSE NULL
END
),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
engine_id = COALESCE(cr.engine_id, step_usage.step_model_config_id),
engine_name = COALESCE(cr.engine_name, step_usage.step_model_config_name),
engine_provider = COALESCE(cr.engine_provider, step_usage.step_model_provider),
engine_model_name = COALESCE(cr.engine_model_name, step_usage.step_model_name)
FROM step_usage
WHERE cr.biz_key = step_usage.usage ->> 'credit_biz_key'
"""
)
# TokenUsage 如果 usage 中已经记录 token_usage_id,则可以安全补 owner;否则不强行按时间/token 猜。
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
AND NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', '') IS NOT NULL
)
UPDATE token_usage tu
SET
owner_type = COALESCE(NULLIF(tu.owner_type, 'unknown'), 'module_generation_step'),
owner_id = COALESCE(tu.owner_id, step_usage.step_id),
biz_key = COALESCE(tu.biz_key, NULLIF(step_usage.usage ->> 'credit_biz_key', '')),
source_module = COALESCE(NULLIF(tu.source_module, 'unknown'), step_usage.module),
source_step_code = COALESCE(tu.source_step_code, step_usage.step_code)
FROM step_usage
WHERE tu.id = step_usage.usage ->> 'token_usage_id'
"""
)
# 拆镜复刻原视频整体分析流水:归属到任务集。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_task_set'),
owner_id = COALESCE(cr.owner_id, ts.id),
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
media_type = COALESCE(cr.media_type, 'video'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_original_video_analysis'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
source_project_id = COALESCE(cr.source_project_id, ts.id),
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM shot_replicate_task_sets ts
WHERE (
(cr.owner_type = 'shot_replicate_task_set' AND cr.owner_id = ts.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = ts.id)
)
AND cr.type IN ('consume', 'refund')
AND (
cr.charge_kind = 'video_analysis'
OR cr.billing_scene IN ('shot_video_analysis', 'shot_original_video_analysis')
OR cr.description ILIKE '%视频分析%'
OR cr.description ILIKE '%原视频分析%'
)
"""
)
# 拆镜复刻手动片段分析流水:归属到片段。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_segment'),
owner_id = COALESCE(cr.owner_id, sg.id),
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
media_type = COALESCE(cr.media_type, 'video'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_segment_video_analysis'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
source_project_id = COALESCE(cr.source_project_id, sg.task_set_id),
source_step_id = COALESCE(cr.source_step_id, sg.id),
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM shot_replicate_segments sg
WHERE (
(cr.owner_type = 'shot_replicate_segment' AND cr.owner_id = sg.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = sg.id)
)
AND cr.type IN ('consume', 'refund')
AND (
cr.charge_kind = 'video_analysis'
OR cr.billing_scene IN ('shot_video_analysis', 'shot_segment_video_analysis')
OR cr.description ILIKE '%片段分析%'
OR cr.description ILIKE '%手动%分析%'
)
"""
)
# Module media generationChatGenerationTask 被模块步骤引用时,优先归到具体模块/步骤,而不是 AI 创作。
op.execute(
"""
WITH step_task AS (
SELECT DISTINCT ON (s.chat_task_id)
s.chat_task_id,
s.id AS step_id,
s.project_id,
s.module,
s.step_code
FROM module_generation_steps s
WHERE s.chat_task_id IS NOT NULL
ORDER BY s.chat_task_id, s.is_current DESC, s.updated_at DESC
)
UPDATE credit_records cr
SET
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_task.module),
source_project_id = COALESCE(cr.source_project_id, step_task.project_id),
source_step_id = COALESCE(cr.source_step_id, step_task.step_id),
source_step_code = COALESCE(cr.source_step_code, step_task.step_code),
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'image_generate' THEN 'hot_opening_image_generate'
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'video_generate' THEN 'hot_opening_video_generate'
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'image_generate' THEN 'shot_image_generate'
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'video_generate' THEN 'shot_video_generate'
ELSE cr.billing_scene
END
)
FROM step_task
WHERE cr.owner_type = 'chat_generation_task'
AND cr.owner_id = step_task.chat_task_id
"""
)
# ChatGenerationTaskAI 创作图片/视频生成,以及媒体 token/引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'chat_generation_task'),
owner_id = COALESCE(cr.owner_id, t.id),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'ai_creation'),
credit_subject = CASE WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media' ELSE cr.credit_subject END,
media_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
ELSE cr.media_type
END,
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN 'ai_creation_image_generate'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN 'ai_creation_video_generate'
ELSE cr.billing_scene
END
),
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
ELSE cr.engine_type
END,
engine_id = COALESCE(cr.engine_id, t.engine_id),
total_tokens = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(t.image_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(t.video_tokens_used, 0))
ELSE cr.total_tokens
END
FROM chat_generation_tasks t
WHERE (cr.owner_type = 'chat_generation_task' AND cr.owner_id = t.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = t.id)
"""
)
# 从 TokenUsage 补 CreditRecord 的 token 和 ModelConfig 执行快照,前提是已经能安全匹配 token_usage_id。
op.execute(
"""
UPDATE credit_records cr
SET
input_tokens = COALESCE(cr.input_tokens, NULLIF(tu.input_tokens, 0)),
output_tokens = COALESCE(cr.output_tokens, NULLIF(tu.output_tokens, 0)),
total_tokens = COALESCE(cr.total_tokens, NULLIF(tu.total_tokens, 0)),
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
ELSE cr.engine_type
END,
engine_id = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
THEN COALESCE(cr.engine_id, tu.model_config_id)
ELSE cr.engine_id
END
FROM token_usage tu
WHERE cr.token_usage_id = tu.id
"""
)
# ModelConfig 快照统一冷备到 engine_*engine_type=model。
op.execute(
"""
UPDATE credit_records cr
SET
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
engine_name = COALESCE(cr.engine_name, mc.name),
engine_provider = COALESCE(cr.engine_provider, mc.provider),
engine_model_name = COALESCE(cr.engine_model_name, mc.model_name)
FROM model_configs mc
WHERE cr.engine_id = mc.id
AND (
cr.engine_type = 'model'
OR cr.charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR cr.credit_subject IN ('text', 'analysis')
)
"""
)
# 图片引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
engine_name = COALESCE(cr.engine_name, ie.name),
engine_provider = COALESCE(cr.engine_provider, ie.provider),
engine_model_name = COALESCE(cr.engine_model_name, ie.model_name),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'image')
FROM image_engines ie
WHERE cr.engine_id = ie.id
AND (cr.engine_type = 'image' OR cr.media_type = 'image' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
"""
)
# 视频引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
engine_name = COALESCE(cr.engine_name, ve.name),
engine_provider = COALESCE(cr.engine_provider, ve.provider),
engine_model_name = COALESCE(cr.engine_model_name, ve.model_name),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'video')
FROM video_engines ve
WHERE cr.engine_id = ve.id
AND (cr.engine_type = 'video' OR cr.media_type = 'video' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
"""
)
# 退款流水复制原消费流水快照,但 billing_scene 固定为 refund,方便筛选回退流水。
op.execute(
"""
UPDATE credit_records r
SET
owner_type = COALESCE(NULLIF(r.owner_type, 'unknown'), c.owner_type),
owner_id = COALESCE(r.owner_id, c.owner_id),
attempt_no = COALESCE(r.attempt_no, c.attempt_no),
charge_kind = COALESCE(NULLIF(r.charge_kind, 'unknown'), c.charge_kind),
charge_action = 'refund',
credit_subject = COALESCE(NULLIF(r.credit_subject, 'unknown'), c.credit_subject, 'refund'),
media_type = COALESCE(r.media_type, c.media_type),
billing_scene = 'refund',
source_module = COALESCE(NULLIF(r.source_module, 'unknown'), c.source_module),
source_project_id = COALESCE(r.source_project_id, c.source_project_id),
source_step_id = COALESCE(r.source_step_id, c.source_step_id),
source_step_code = COALESCE(r.source_step_code, c.source_step_code),
token_usage_id = COALESCE(r.token_usage_id, c.token_usage_id),
input_tokens = COALESCE(r.input_tokens, c.input_tokens),
output_tokens = COALESCE(r.output_tokens, c.output_tokens),
total_tokens = COALESCE(r.total_tokens, c.total_tokens),
engine_type = COALESCE(NULLIF(r.engine_type, 'unknown'), c.engine_type),
engine_id = COALESCE(r.engine_id, c.engine_id),
engine_name = COALESCE(r.engine_name, c.engine_name),
engine_provider = COALESCE(r.engine_provider, c.engine_provider),
engine_model_name = COALESCE(r.engine_model_name, c.engine_model_name),
user_type_snapshot = COALESCE(r.user_type_snapshot, c.user_type_snapshot),
frontend_user_kind_snapshot = COALESCE(r.frontend_user_kind_snapshot, c.frontend_user_kind_snapshot)
FROM credit_records c
WHERE r.type = 'refund'
AND r.refund_for_biz_key = c.biz_key
"""
)
# 基础分类兜底。
op.execute(
"""
UPDATE credit_records
SET
charge_action = COALESCE(charge_action, CASE WHEN type = 'refund' THEN 'refund' ELSE 'charge' END),
charge_kind = COALESCE(
NULLIF(charge_kind, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
ELSE 'unknown'
END
),
credit_subject = COALESCE(
NULLIF(credit_subject, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
WHEN charge_kind = 'media' THEN 'media'
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
WHEN charge_kind = 'module_create' THEN 'module'
WHEN charge_kind = 'video_analysis' THEN 'analysis'
WHEN charge_kind = 'video_split' THEN 'split'
WHEN charge_kind = 'admin_adjust' THEN 'admin_adjust'
ELSE 'unknown'
END
),
source_module = COALESCE(NULLIF(source_module, 'unknown'), CASE WHEN type = 'recharge' THEN 'payment' ELSE 'unknown' END),
billing_scene = COALESCE(
NULLIF(billing_scene, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
ELSE 'unknown'
END
),
owner_type = COALESCE(NULLIF(owner_type, 'unknown'), 'unknown'),
engine_type = CASE
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR credit_subject IN ('text', 'analysis')
THEN COALESCE(NULLIF(engine_type, 'unknown'), 'model')
ELSE engine_type
END
WHERE charge_kind IS NULL
OR charge_kind = 'unknown'
OR charge_action IS NULL
OR credit_subject IS NULL
OR credit_subject = 'unknown'
OR source_module IS NULL
OR source_module = 'unknown'
OR billing_scene IS NULL
OR billing_scene = 'unknown'
OR owner_type IS NULL
OR owner_type = 'unknown'
"""
)
# 用户快照兜底,避免后台筛选 NULL。
op.execute(
"""
UPDATE credit_records
SET
user_type_snapshot = COALESCE(user_type_snapshot, 'frontend'),
frontend_user_kind_snapshot = COALESCE(frontend_user_kind_snapshot, 'external')
WHERE user_type_snapshot IS NULL OR frontend_user_kind_snapshot IS NULL
"""
)
def downgrade() -> None:
op.drop_index(op.f("ix_users_user_type"), table_name="users")
op.drop_index(op.f("ix_users_frontend_user_kind"), table_name="users")
op.drop_index(op.f("ix_token_usage_source_step_code"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_source_module"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_owner_type"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_owner_id"), table_name="token_usage")
op.drop_index("ix_token_usage_owner", table_name="token_usage")
op.drop_index("ix_token_usage_biz_key", table_name="token_usage")
op.drop_index(op.f("ix_module_generation_steps_token_usage_id"), table_name="module_generation_steps")
op.drop_index(op.f("ix_module_generation_steps_model_config_id"), table_name="module_generation_steps")
op.drop_index(op.f("ix_credit_records_user_type_snapshot"), table_name="credit_records")
op.drop_index("ix_credit_records_user_kind_time", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_token_usage_id"), table_name="credit_records")
op.drop_index("ix_credit_records_subject_media", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_step_id"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_step_code"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_project_id"), table_name="credit_records")
op.drop_index("ix_credit_records_source_module_scene", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_module"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_owner_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_owner_id"), table_name="credit_records")
op.drop_index("ix_credit_records_owner", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_media_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_engine_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_engine_id"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_credit_subject"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_charge_kind"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_billing_scene"), table_name="credit_records")
op.drop_column("token_usage", "source_step_code")
op.drop_column("token_usage", "source_module")
op.drop_column("token_usage", "biz_key")
op.drop_column("token_usage", "owner_id")
op.drop_column("token_usage", "owner_type")
op.drop_column("module_generation_steps", "text_credits_cost")
op.drop_column("module_generation_steps", "total_tokens")
op.drop_column("module_generation_steps", "output_tokens")
op.drop_column("module_generation_steps", "input_tokens")
op.drop_column("module_generation_steps", "model_config_id")
op.drop_column("module_generation_steps", "token_usage_id")
op.drop_column("credit_records", "frontend_user_kind_snapshot")
op.drop_column("credit_records", "user_type_snapshot")
op.drop_column("credit_records", "engine_model_name")
op.drop_column("credit_records", "engine_provider")
op.drop_column("credit_records", "engine_name")
op.drop_column("credit_records", "engine_id")
op.drop_column("credit_records", "engine_type")
op.drop_column("credit_records", "total_tokens")
op.drop_column("credit_records", "output_tokens")
op.drop_column("credit_records", "input_tokens")
op.drop_column("credit_records", "token_usage_id")
op.drop_column("credit_records", "source_step_code")
op.drop_column("credit_records", "source_step_id")
op.drop_column("credit_records", "source_project_id")
op.drop_column("credit_records", "source_module")
op.drop_column("credit_records", "billing_scene")
op.drop_column("credit_records", "media_type")
op.drop_column("credit_records", "credit_subject")
op.drop_column("credit_records", "charge_action")
op.drop_column("credit_records", "charge_kind")
op.drop_column("credit_records", "attempt_no")
op.drop_column("credit_records", "owner_id")
op.drop_column("credit_records", "owner_type")
op.drop_column("users", "frontend_user_kind")
@@ -0,0 +1,133 @@
"""add upload resource physical delete status
Revision ID: 1475d11b1d74
Revises: cd7688999204
Create Date: 2026-07-08 15:14:13.995718
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1475d11b1d74'
down_revision: Union[str, None] = 'cd7688999204'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('upload_resources',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('module', sa.String(length=64), nullable=False),
sa.Column('resource_type', sa.String(length=32), nullable=False),
sa.Column('resource_url', sa.Text(), nullable=False),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('file_name', sa.String(length=255), nullable=True),
sa.Column('file_ext', sa.String(length=32), nullable=True),
sa.Column('mime_type', sa.String(length=128), nullable=True),
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('duration_seconds', sa.Float(), nullable=True),
sa.Column('duration_source', sa.String(length=32), nullable=True),
sa.Column('width', sa.BigInteger(), nullable=True),
sa.Column('height', sa.BigInteger(), nullable=True),
sa.Column('source_model', sa.String(length=64), nullable=True),
sa.Column('source_id', sa.String(length=32), nullable=True),
sa.Column('source_module', sa.String(length=64), nullable=True),
sa.Column('bind_status', sa.String(length=32), server_default='pending', nullable=False),
sa.Column('delete_policy', sa.String(length=32), server_default='user_deletable', nullable=False),
sa.Column('created_by', sa.String(length=32), server_default='api', nullable=False),
sa.Column('metadata_json', sa.Text(), nullable=True),
sa.Column('capacity_released_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('physical_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('file_delete_status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('file_delete_error', sa.Text(), 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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('storage_path', name='uq_upload_resources_storage_path')
)
op.create_index(op.f('ix_upload_resources_bind_status'), 'upload_resources', ['bind_status'], unique=False)
op.create_index(op.f('ix_upload_resources_capacity_released_at'), 'upload_resources', ['capacity_released_at'], unique=False)
op.create_index('ix_upload_resources_created', 'upload_resources', ['created_at'], unique=False)
op.create_index(op.f('ix_upload_resources_created_by'), 'upload_resources', ['created_by'], unique=False)
op.create_index(op.f('ix_upload_resources_delete_policy'), 'upload_resources', ['delete_policy'], unique=False)
op.create_index(op.f('ix_upload_resources_deleted_at'), 'upload_resources', ['deleted_at'], unique=False)
op.create_index('ix_upload_resources_file_delete_status', 'upload_resources', ['file_delete_status'], unique=False)
op.create_index(op.f('ix_upload_resources_module'), 'upload_resources', ['module'], unique=False)
op.create_index('ix_upload_resources_physical_deleted', 'upload_resources', ['physical_deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_resource_type'), 'upload_resources', ['resource_type'], unique=False)
op.create_index('ix_upload_resources_source', 'upload_resources', ['source_model', 'source_id', 'deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_source_id'), 'upload_resources', ['source_id'], unique=False)
op.create_index(op.f('ix_upload_resources_source_model'), 'upload_resources', ['source_model'], unique=False)
op.create_index('ix_upload_resources_url', 'upload_resources', ['resource_url'], unique=False)
op.create_index('ix_upload_resources_user_bind', 'upload_resources', ['user_id', 'bind_status', 'deleted_at'], unique=False)
op.create_index(op.f('ix_upload_resources_user_id'), 'upload_resources', ['user_id'], unique=False)
op.create_index('ix_upload_resources_user_module_type', 'upload_resources', ['user_id', 'module', 'resource_type', 'deleted_at'], unique=False)
op.create_index(
'ix_upload_resources_history_lookup',
'upload_resources',
['user_id', 'resource_type', sa.text('created_at DESC')],
unique=False,
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND bind_status = 'pending' "
"AND delete_policy = 'user_deletable' "
"AND source_model IS NULL "
"AND source_id IS NULL"
),
)
op.add_column('user_resource_month_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_month_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
op.add_column('user_resource_total_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user_resource_total_stats', 'shot_segment_count')
op.drop_column('user_resource_total_stats', 'audio_count')
op.drop_column('user_resource_total_stats', 'upload_count')
op.drop_column('user_resource_total_stats', 'shot_segment_size_bytes')
op.drop_column('user_resource_total_stats', 'audio_size_bytes')
op.drop_column('user_resource_total_stats', 'upload_size_bytes')
op.drop_column('user_resource_month_stats', 'shot_segment_count')
op.drop_column('user_resource_month_stats', 'audio_count')
op.drop_column('user_resource_month_stats', 'upload_count')
op.drop_column('user_resource_month_stats', 'shot_segment_size_bytes')
op.drop_column('user_resource_month_stats', 'audio_size_bytes')
op.drop_column('user_resource_month_stats', 'upload_size_bytes')
op.drop_index('ix_upload_resources_history_lookup', table_name='upload_resources')
op.drop_index('ix_upload_resources_user_module_type', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_user_id'), table_name='upload_resources')
op.drop_index('ix_upload_resources_user_bind', table_name='upload_resources')
op.drop_index('ix_upload_resources_url', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_source_model'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_source_id'), table_name='upload_resources')
op.drop_index('ix_upload_resources_source', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_resource_type'), table_name='upload_resources')
op.drop_index('ix_upload_resources_physical_deleted', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_module'), table_name='upload_resources')
op.drop_index('ix_upload_resources_file_delete_status', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_deleted_at'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_delete_policy'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_created_by'), table_name='upload_resources')
op.drop_index('ix_upload_resources_created', table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_capacity_released_at'), table_name='upload_resources')
op.drop_index(op.f('ix_upload_resources_bind_status'), table_name='upload_resources')
op.drop_table('upload_resources')
# ### end Alembic commands ###
@@ -0,0 +1,107 @@
"""add team management
Revision ID: 170005531deb
Revises: 78fb32c26a6e
Create Date: 2026-06-30 11:07:14.278871
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '170005531deb'
down_revision: Union[str, None] = '78fb32c26a6e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('teams',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False, comment='团队名称'),
sa.Column('code', sa.String(length=64), nullable=True, comment='团队编码'),
sa.Column('description', sa.String(length=512), nullable=True, comment='团队备注'),
sa.Column('status', sa.String(length=16), server_default='active', nullable=False, comment='团队状态:active启用,disabled禁用'),
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序值,越小越靠前'),
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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_teams_code', 'teams', ['code'], unique=False)
op.create_index(op.f('ix_teams_deleted_at'), 'teams', ['deleted_at'], unique=False)
op.create_index(op.f('ix_teams_name'), 'teams', ['name'], unique=False)
op.create_index(op.f('ix_teams_sort_order'), 'teams', ['sort_order'], unique=False)
op.create_index(op.f('ix_teams_status'), 'teams', ['status'], unique=False)
op.create_index('ix_teams_status_sort', 'teams', ['status', 'sort_order', 'created_at'], unique=False)
op.drop_index(op.f('ix_contact_requests_phone'), table_name='contact_requests')
op.drop_index(op.f('ix_contact_requests_user_id'), table_name='contact_requests')
op.drop_table('contact_requests')
op.add_column('credit_records', sa.Column('team_id_snapshot', sa.String(length=32), nullable=True))
op.add_column('credit_records', sa.Column('team_name_snapshot', sa.String(length=128), nullable=True))
op.drop_index(op.f('ix_credit_records_engine_type'), table_name='credit_records')
op.create_index(op.f('ix_credit_records_team_id_snapshot'), 'credit_records', ['team_id_snapshot'], unique=False)
op.create_index('ix_credit_records_team_time', 'credit_records', ['team_id_snapshot', 'created_at'], unique=False)
op.create_index('ix_credit_records_user_kind_team_time', 'credit_records', ['user_type_snapshot', 'frontend_user_kind_snapshot', 'team_id_snapshot', 'created_at'], unique=False)
op.create_index('idx_genrec_project_status', 'generation_records', ['project_id', 'status'], unique=False)
op.create_index('idx_genrec_user_status_created', 'generation_records', ['user_id', 'status', 'created_at'], unique=False)
op.create_index('idx_menu_target_active_sort', 'menu_configs', ['menu_target', 'is_active', 'sort_order'], unique=False)
op.create_index(op.f('ix_menu_configs_parent_id'), 'menu_configs', ['parent_id'], unique=False)
op.create_index('idx_oplog_created', 'operation_logs', ['created_at'], unique=False)
op.create_index('idx_oplog_user_created', 'operation_logs', ['user_id', 'created_at'], unique=False)
op.create_index('idx_payorder_status_created', 'payment_orders', ['status', 'created_at'], unique=False)
op.create_index('idx_payorder_user_status_created', 'payment_orders', ['user_id', 'status', 'created_at'], unique=False)
op.create_index('idx_upload_oauth_status', 'upload_task', ['oauth_id', 'status'], unique=False)
op.create_index('idx_upload_user_status_created', 'upload_task', ['user_id', 'status', 'created_at'], unique=False)
op.add_column('users', sa.Column('team_id', sa.String(length=32), nullable=True))
op.create_index(op.f('ix_users_team_id'), 'users', ['team_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_team_id'), table_name='users')
op.drop_column('users', 'team_id')
op.drop_index('idx_upload_user_status_created', table_name='upload_task')
op.drop_index('idx_upload_oauth_status', table_name='upload_task')
op.drop_index('idx_payorder_user_status_created', table_name='payment_orders')
op.drop_index('idx_payorder_status_created', table_name='payment_orders')
op.drop_index('idx_oplog_user_created', table_name='operation_logs')
op.drop_index('idx_oplog_created', table_name='operation_logs')
op.drop_index(op.f('ix_menu_configs_parent_id'), table_name='menu_configs')
op.drop_index('idx_menu_target_active_sort', table_name='menu_configs')
op.drop_index('idx_genrec_user_status_created', table_name='generation_records')
op.drop_index('idx_genrec_project_status', table_name='generation_records')
op.drop_index('ix_credit_records_user_kind_team_time', table_name='credit_records')
op.drop_index('ix_credit_records_team_time', table_name='credit_records')
op.drop_index(op.f('ix_credit_records_team_id_snapshot'), table_name='credit_records')
op.create_index(op.f('ix_credit_records_engine_type'), 'credit_records', ['engine_type'], unique=False)
op.drop_column('credit_records', 'team_name_snapshot')
op.drop_column('credit_records', 'team_id_snapshot')
op.create_table('contact_requests',
sa.Column('id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
sa.Column('user_id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('company_name', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
sa.Column('industry', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_handled', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('contact_requests_user_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('contact_requests_pkey'))
)
op.create_index(op.f('ix_contact_requests_user_id'), 'contact_requests', ['user_id'], unique=False)
op.create_index(op.f('ix_contact_requests_phone'), 'contact_requests', ['phone'], unique=False)
op.drop_index('ix_teams_status_sort', table_name='teams')
op.drop_index(op.f('ix_teams_status'), table_name='teams')
op.drop_index(op.f('ix_teams_sort_order'), table_name='teams')
op.drop_index(op.f('ix_teams_name'), table_name='teams')
op.drop_index(op.f('ix_teams_deleted_at'), table_name='teams')
op.drop_index('ix_teams_code', table_name='teams')
op.drop_table('teams')
# ### end Alembic commands ###
@@ -0,0 +1,82 @@
"""add team manager and invite system
Revision ID: 20260706_20260706
Revises: 054735900c23
Create Date: 2026-07-06 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '20260706_20260706'
down_revision: Union[str, None] = '054735900c23'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# teams 表新增 manager_id 列
op.add_column('teams', sa.Column('manager_id', sa.String(length=32), nullable=True))
op.create_index(op.f('ix_teams_manager_id'), 'teams', ['manager_id'], unique=False)
op.create_foreign_key('fk_teams_manager_id', 'teams', 'users', ['manager_id'], ['id'])
# 邀请码表
op.create_table('team_invitations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('code', sa.String(length=64), nullable=False),
sa.Column('created_by', sa.String(length=32), nullable=False),
sa.Column('status', sa.String(length=16), server_default='active', nullable=False),
sa.Column('max_uses', sa.Integer(), nullable=True),
sa.Column('use_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('expires_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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code', name='uq_team_invitations_code')
)
op.create_index(op.f('ix_team_invitations_team_id'), 'team_invitations', ['team_id'], unique=False)
op.create_index(op.f('ix_team_invitations_code'), 'team_invitations', ['code'], unique=False)
op.create_index('ix_team_invitations_team', 'team_invitations', ['team_id', 'status'], unique=False)
# 加入申请表
op.create_table('team_join_requests',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('team_id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('invitation_id', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('note', sa.String(length=256), nullable=True),
sa.Column('handled_by', sa.String(length=32), 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.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['invitation_id'], ['team_invitations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_team_join_requests_team_id'), 'team_join_requests', ['team_id'], unique=False)
op.create_index(op.f('ix_team_join_requests_user_id'), 'team_join_requests', ['user_id'], unique=False)
op.create_index('ix_tjr_team_status', 'team_join_requests', ['team_id', 'status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_tjr_team_status', table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_user_id'), table_name='team_join_requests')
op.drop_index(op.f('ix_team_join_requests_team_id'), table_name='team_join_requests')
op.drop_table('team_join_requests')
op.drop_index('ix_team_invitations_team', table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_code'), table_name='team_invitations')
op.drop_index(op.f('ix_team_invitations_team_id'), table_name='team_invitations')
op.drop_table('team_invitations')
op.drop_constraint('fk_teams_manager_id', 'teams', type_='foreignkey')
op.drop_index(op.f('ix_teams_manager_id'), table_name='teams')
op.drop_column('teams', 'manager_id')
@@ -0,0 +1,37 @@
"""2026070901_add_credits_ratio_增加上传图片积分规则
Revision ID: 2026070901
Revises: 6c1aaf036f43
Create Date: 2026-07-01 00:00:00.000000
该文件包含 2026-07-01 的数据库迁移内容:
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2026070901'
down_revision: Union[str, None] = '6c1aaf036f43'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入图片计费字段
# ========================================
op.add_column('credit_ratios', sa.Column('input_image_ratio', sa.Float(), server_default='1.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_image_base_credits', sa.Float(), server_default='0.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_image_per_image_credits', sa.Float(), server_default='0.5', nullable=False))
def downgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入图片计费字段(回滚)
# ========================================
op.drop_column('credit_ratios', 'input_image_per_image_credits')
op.drop_column('credit_ratios', 'input_image_base_credits')
op.drop_column('credit_ratios', 'input_image_ratio')
@@ -0,0 +1,51 @@
"""2026070902_add_generated_date_expression_indexes
Revision ID: 2026070902
Revises: 2026070901
Create Date: 2026-07-09 00:00:00.000000
该文件包含 2026-07-09 的数据库迁移内容:
1. chat_generation_tasks 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
2. generation_records 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '2026070902'
down_revision: Union[str, None] = '2026070901'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ========================================
# 2026-07-09 - 生成记录表增加日期表达式索引
# ========================================
# 使用 CAST(timezone('utc', generated_at) AS date) 而不是 date(generated_at)
# 1) 当 generated_at 为 timestamptz 时,date() 的结果依赖会话时区,属于 STABLE 函数,
# 无法用于索引表达式。显式指定 UTC 时区后转为 date 是纯计算,属于 IMMUTABLE。
# 2) 必须用 CAST(... AS date) 而不能用 ::date::: 的优先级高于函数调用,
# PG 会把 func(x)::date 解析为 func(x::date),导致语法错误。
op.create_index(
'idx_chat_generation_tasks_generated_date',
'chat_generation_tasks',
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
unique=False,
)
op.create_index(
'idx_generation_records_generated_date',
'generation_records',
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
unique=False,
)
def downgrade() -> None:
# ========================================
# 2026-07-09 - 生成记录表增加日期表达式索引(回滚)
# ========================================
op.drop_index('idx_generation_records_generated_date', table_name='generation_records')
op.drop_index('idx_chat_generation_tasks_generated_date', table_name='chat_generation_tasks')
@@ -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,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,29 @@
"""新增开户方式管理表
Revision ID: 2bafebb4be14
Revises: 08749ddd1414
Create Date: 2026-06-23 13:50:26.577410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2bafebb4be14'
down_revision: Union[str, None] = '08749ddd1414'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -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,29 @@
"""资源表新增文件名字段
Revision ID: 5e2c124e5484
Revises: 2bafebb4be14
Create Date: 2026-06-24 14:28:56.094105
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5e2c124e5484'
down_revision: Union[str, None] = '2bafebb4be14'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('generated_resources', sa.Column('file_name', sa.String(length=255), nullable=True, comment='文件名,平台素材名称'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('generated_resources', 'file_name')
# ### end Alembic commands ###
@@ -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,31 @@
"""add home material generation config
Revision ID: 6c1aaf036f43
Revises: 1475d11b1d74
Create Date: 2026-07-09 10:28:05.067550
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6c1aaf036f43'
down_revision: Union[str, None] = '1475d11b1d74'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('home_material_assets', sa.Column('generation_prompt', sa.Text(), nullable=True, comment='生成提词'))
op.add_column('home_material_assets', sa.Column('media_references_json', sa.Text(), nullable=True, comment='附件/参考素材JSON字符串'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('home_material_assets', 'media_references_json')
op.drop_column('home_material_assets', 'generation_prompt')
# ### end Alembic commands ###
@@ -0,0 +1,228 @@
"""add private portrait assets
Revision ID: 6fc75582f6f9
Revises: 054735900c23
Create Date: 2026-07-06 14:48:50.946515
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6fc75582f6f9'
down_revision: Union[str, None] = '20260706_20260706'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('private_portrait_projects',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False, comment='用户展示项目名'),
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='项目名安全 slug'),
sa.Column('remote_project_name', sa.String(length=256), nullable=False, comment='火山 ProjectName 快照'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('asset_group_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('active_asset_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('last_used_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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_projects_remote_project_name', 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_projects_user_deleted', 'private_portrait_projects', ['user_id', 'deleted_at'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
op.create_index('idx_private_portrait_projects_user_status_created', 'private_portrait_projects', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_deleted_at'), 'private_portrait_projects', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name'), 'private_portrait_projects', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_name_slug'), 'private_portrait_projects', ['name_slug'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_remote_project_name'), 'private_portrait_projects', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_status'), 'private_portrait_projects', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_projects_user_id'), 'private_portrait_projects', ['user_id'], unique=False)
op.create_table('private_portrait_asset_groups',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_group_name', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('group_type', sa.String(length=32), server_default='LivenessFace', nullable=False),
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('remote_delete_error', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), 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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_asset_groups_project_status', 'private_portrait_asset_groups', ['project_id', 'status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_delete_status', 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_asset_groups_remote_project_name', 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_asset_groups_user_project', 'private_portrait_asset_groups', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_deleted_at'), 'private_portrait_asset_groups', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_project_id'), 'private_portrait_asset_groups', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), 'private_portrait_asset_groups', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), 'private_portrait_asset_groups', ['remote_group_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_status'), 'private_portrait_asset_groups', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_asset_groups_user_id'), 'private_portrait_asset_groups', ['user_id'], unique=False)
op.create_index('uq_private_portrait_asset_groups_remote_group_id', 'private_portrait_asset_groups', ['remote_group_id'], unique=True)
op.create_table('private_portrait_validate_sessions',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('byted_token', sa.String(length=256), nullable=True),
sa.Column('h5_link', sa.Text(), nullable=True),
sa.Column('callback_url', sa.Text(), nullable=True),
sa.Column('result_code', sa.String(length=32), nullable=True),
sa.Column('algorithm_base_resp_code', sa.String(length=64), nullable=True),
sa.Column('verify_type', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=32), server_default='created', nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('raw_callback_json', sa.Text(), nullable=True),
sa.Column('raw_response_json', sa.Text(), 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.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_validate_sessions_byted_token', 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_status_created', 'private_portrait_validate_sessions', ['status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_validate_sessions_user_project', 'private_portrait_validate_sessions', ['user_id', 'project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_byted_token'), 'private_portrait_validate_sessions', ['byted_token'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_project_id'), 'private_portrait_validate_sessions', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), 'private_portrait_validate_sessions', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), 'private_portrait_validate_sessions', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_result_code'), 'private_portrait_validate_sessions', ['result_code'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_status'), 'private_portrait_validate_sessions', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_validate_sessions_user_id'), 'private_portrait_validate_sessions', ['user_id'], unique=False)
op.create_table('private_portrait_assets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False),
sa.Column('project_id', sa.String(length=32), nullable=False),
sa.Column('group_id', sa.String(length=32), nullable=False),
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
sa.Column('remote_asset_id', sa.String(length=128), nullable=True),
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
sa.Column('asset_type', sa.String(length=16), nullable=False),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('source_url', sa.Text(), nullable=False),
sa.Column('preview_url', sa.Text(), nullable=True),
sa.Column('remote_url', sa.Text(), nullable=True),
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(length=32), server_default='creating', nullable=False),
sa.Column('moderation_json', sa.Text(), nullable=True),
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('poll_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
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),
sa.Column('raw_response_json', sa.Text(), 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.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['group_id'], ['private_portrait_asset_groups.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_private_portrait_assets_group_status_created', 'private_portrait_assets', ['group_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_next_poll_status', 'private_portrait_assets', ['next_poll_at', 'status'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.create_index('idx_private_portrait_assets_project_status_created', 'private_portrait_assets', ['project_id', 'status', 'created_at'], unique=False)
op.create_index('idx_private_portrait_assets_remote_delete_status', 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index('idx_private_portrait_assets_remote_project_name', 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index('idx_private_portrait_assets_user_status_created', 'private_portrait_assets', ['user_id', 'status', 'created_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_asset_type'), 'private_portrait_assets', ['asset_type'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_deleted_at'), 'private_portrait_assets', ['deleted_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_group_id'), 'private_portrait_assets', ['group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_name'), 'private_portrait_assets', ['name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_next_poll_at'), 'private_portrait_assets', ['next_poll_at'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_project_id'), 'private_portrait_assets', ['project_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_asset_id'), 'private_portrait_assets', ['remote_asset_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_delete_status'), 'private_portrait_assets', ['remote_delete_status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_group_id'), 'private_portrait_assets', ['remote_group_id'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_remote_project_name'), 'private_portrait_assets', ['remote_project_name'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_status'), 'private_portrait_assets', ['status'], unique=False)
op.create_index(op.f('ix_private_portrait_assets_user_id'), 'private_portrait_assets', ['user_id'], unique=False)
op.create_index('uq_private_portrait_assets_remote_asset_id', 'private_portrait_assets', ['remote_asset_id'], unique=True)
op.add_column('users', sa.Column('private_portrait_image_limit', sa.Integer(), server_default='5', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'private_portrait_image_limit')
op.drop_index('uq_private_portrait_assets_remote_asset_id', table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_user_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_project_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_delete_status'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_remote_asset_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_project_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_next_poll_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_name'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_group_id'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_deleted_at'), table_name='private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_assets_asset_type'), table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_user_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_project_name', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_remote_delete_status', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_project_status_created', table_name='private_portrait_assets')
op.drop_index('idx_private_portrait_assets_next_poll_status', table_name='private_portrait_assets', postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
op.drop_index('idx_private_portrait_assets_group_status_created', table_name='private_portrait_assets')
op.drop_table('private_portrait_assets')
op.drop_index(op.f('ix_private_portrait_validate_sessions_user_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_status'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_result_code'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_project_id'), table_name='private_portrait_validate_sessions')
op.drop_index(op.f('ix_private_portrait_validate_sessions_byted_token'), table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_user_project', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_status_created', table_name='private_portrait_validate_sessions')
op.drop_index('idx_private_portrait_validate_sessions_byted_token', table_name='private_portrait_validate_sessions')
op.drop_table('private_portrait_validate_sessions')
op.drop_index('uq_private_portrait_asset_groups_remote_group_id', table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_user_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_project_id'), table_name='private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_asset_groups_deleted_at'), table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_user_project', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_project_name', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_remote_delete_status', table_name='private_portrait_asset_groups')
op.drop_index('idx_private_portrait_asset_groups_project_status', table_name='private_portrait_asset_groups')
op.drop_table('private_portrait_asset_groups')
op.drop_index(op.f('ix_private_portrait_projects_user_id'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_status'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_remote_project_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name_slug'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_name'), table_name='private_portrait_projects')
op.drop_index(op.f('ix_private_portrait_projects_deleted_at'), table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_status_created', table_name='private_portrait_projects')
op.drop_index('idx_private_portrait_projects_user_deleted', table_name='private_portrait_projects', postgresql_where=sa.text('deleted_at IS NULL'))
op.drop_index('idx_private_portrait_projects_remote_project_name', table_name='private_portrait_projects')
op.drop_table('private_portrait_projects')
# ### end Alembic commands ###
@@ -0,0 +1,37 @@
"""6idufv2q1c_add_credits_ratio_增加上传视频积分规则
Revision ID: a1b2c3d4e5f7
Revises: 6idufv2q1c
Create Date: 2026-07-01 00:00:00.000000
该文件包含 2026-07-01 的数据库迁移内容:
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '6idufv2q1c'
down_revision: Union[str, None] = 'a1b2c3d4e5f7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入视频计费字段
# ========================================
op.add_column('credit_ratios', sa.Column('input_video_ratio', sa.Float(), server_default='1.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_video_base_credits', sa.Float(), server_default='0.0', nullable=False))
op.add_column('credit_ratios', sa.Column('input_video_per_second_credits', sa.Float(), server_default='0.5', nullable=False))
def downgrade() -> None:
# ========================================
# 2026-07-01 - 积分规则表增加传入视频计费字段(回滚)
# ========================================
op.drop_column('credit_ratios', 'input_video_per_second_credits')
op.drop_column('credit_ratios', 'input_video_base_credits')
op.drop_column('credit_ratios', 'input_video_ratio')
@@ -0,0 +1,43 @@
"""add user resource capacity config
Revision ID: 78fb32c26a6e
Revises: n123456789ab
Create Date: 2026-06-29 13:05:45.888522
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '78fb32c26a6e'
down_revision: Union[str, None] = 'n123456789ab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_resource_capacity_configs',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('user_id', sa.String(length=32), nullable=False, comment='用户ID'),
sa.Column('enabled', sa.Boolean(), server_default='false', nullable=False, comment='是否启用该用户个人容量限制'),
sa.Column('limit_value', sa.Numeric(precision=18, scale=3), server_default='1', nullable=False, comment='容量数值,最小1,最多3位小数'),
sa.Column('limit_unit', sa.String(length=8), server_default='GB', nullable=False, comment='容量单位:MB/GB/TB'),
sa.Column('limit_bytes', sa.BigInteger(), server_default='1073741824', nullable=False, comment='换算后的容量字节数'),
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.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', name='uq_user_resource_capacity_configs_user')
)
op.create_index(op.f('ix_user_resource_capacity_configs_user_id'), 'user_resource_capacity_configs', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_resource_capacity_configs_user_id'), table_name='user_resource_capacity_configs')
op.drop_table('user_resource_capacity_configs')
# ### end Alembic commands ###
@@ -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")

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