Compare commits

..

119 Commits

Author SHA1 Message Date
kdletters c8dd6cbd33 统一 Jenkins 本机 Git 源
将生产流水线 Git checkout 地址统一为 127.0.0.1:2222
数据库导入导出改由 Built-In Node 准备并传递脚本
新增 Git 源与目标节点边界的生产运维门禁
同步生产运维文档和项目共享记忆
2026-07-14 22:03:32 +08:00
k88936 34c6ed8756 自动扩缩edit图片的大小适应gptimage2的生成图片大小参数要求 (#82)
尺寸严格限制规则 (Strict Size Constraints):
图片最大边长 ≤ 3840px <- by hardcoded length
宽高两边像素均为 16px 的倍数 <- by backend do crop
长边 / 短边 比值 ≤ 3:1 <- by hardcoded aspect ratio
总像素范围:最小 655360 ~ 最大 8294400 <- by auto scale in this pr

logic impl in the backend

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/82
Co-authored-by: kvtodev <kvtodev@outlook.com>
Co-committed-by: kvtodev <kvtodev@outlook.com>
2026-07-14 21:04:54 +08:00
kdletters 630fce1438 Merge pull request '抽取钱包/充值入口组件, 用来在客户端里复用' (#77) from extract-wallet-entry-to-shared into master
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/77
Reviewed-by: 段舒康 <kdletters@qq.com>
2026-07-14 21:03:16 +08:00
kdletters 75dd05d2e6 Merge branch 'master' into extract-wallet-entry-to-shared 2026-07-14 21:02:06 +08:00
kdletters f3936f60a3 修复后台多账号安全与事务边界
将Argon2密码操作移出异步执行器并限制并发
统一未知停用和非法账号的登录校验与错误语义
隔离登录凭据快照和普通账号公开契约
调整管理员显示名查询顺序避免写入伪失败
新增后台账号procedure身份与事务集成烟测
同步后台账号技术方案与开发验证文档
2026-07-14 20:30:44 +08:00
k88936 08e17fa640 Merge branch 'master' into extract-wallet-entry-to-shared 2026-07-14 20:28:45 +08:00
kdletters bddc25b4e0 后台支持多账号与页面访问权限
保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号
建立一级页面权限、实时会话失效和后端逐请求鉴权
统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识
补充账号管理页面、生成绑定、定向测试和技术文档
2026-07-14 19:21:33 +08:00
kdletters a597fcd076 收紧退款人工复核与重试契约
补齐退款事实对比字段与人工复核审计展示
人工复核重放严格校验管理员、原因和错误码
退款占用重试完整匹配管理员与归一化原因
统一退款原因持久化上限并修复刷新恢复
补充前后端回归测试、生成绑定与数据契约文档
2026-07-14 19:20:25 +08:00
kdletters c70a33d682 修复退款复核与渠道校验
退款 observation ID 按来源与事实指纹稳定区分
人工复核只豁免本次批准的订单冲突并持久化错误码
普通微信渠道在查单前拒绝虚拟支付、会员与非正式渠道
网络结果未知时当前弹窗直接复用原请求继续核对
按 release 首次上线基线移除未发布退款结构兼容并同步文档与绑定
2026-07-14 18:09:50 +08:00
k88936 d66b17b855 抽取配色css (#79)
Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/79
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kvtodev <kvtodev@outlook.com>
Co-committed-by: kvtodev <kvtodev@outlook.com>
2026-07-14 17:01:39 +08:00
k88936 99d76b25f9 Merge branch 'master' into extract-wallet-entry-to-shared 2026-07-14 16:52:47 +08:00
kdletters 0baa8cf8cc 修复退款复核与发布安全边界
补齐退款人工复核、刷新恢复、会话隔离和旧冷备导入兼容
强化真实微信退款对账开关、重复环境变量和生产发布门禁
优化公开作品资产授权查询并保持实时撤销语义
统一后台微信渠道、退款追回流水和画布正式资源测试契约
补齐生成与素材侧栏回归测试
2026-07-14 16:31:22 +08:00
kdletters aa56c117ec 修复角色动作素材入库
将最终角色动作帧序列保存为单条素材并保留完整元数据
登记全部动作帧的私有资产对象并以首帧作为预览
补充动作素材持久化测试和后端数据契约
2026-07-14 15:48:30 +08:00
kdletters 2653adeb34 合并微信支付退款闭环
新增后台充值退款管理与微信 V3 退款闭环
统一退款观察、对账、权益追回和钱包冻结
修复成功退款冲突时点数钱包未冻结问题
保留后台素材预览连续加载修复
2026-07-14 13:58:16 +08:00
k88936 708b9cafda Merge remote-tracking branch 'origin/master' into extract-wallet-entry-to-shared 2026-07-14 13:45:19 +08:00
kdletters 3861c9c99a 修复后台素材预览连续加载
按视口懒加载并错峰执行素材换签请求
增加限流退避与卸载清理,避免后续素材永久占位
补充滚动、分页、大批量排队和缩略图路径回归测试
记录后台素材换签限流排障与验证口径
2026-07-14 13:39:20 +08:00
kdletters 8862887baa 新增后台充值退款管理与微信V3退款闭环
新增充值订单查询、通用用户详情、钱包冻结与部分退款操作
接入微信V3退款申请、回调、主动查单、交易账单对账与安全诊断
新增退款占用、权益追回、异常欠账和消费限制事务
补齐Native支付SSE自动收口及退款编号与部分退款校验
同步SpacetimeDB schema、生成绑定、测试与项目文档
2026-07-14 12:16:01 +08:00
k88936 11abb43438 if it run , dont del it 2026-07-14 11:12:07 +08:00
k88936 b13d73c5cf sync doc 2026-07-14 11:11:11 +08:00
k88936 8d7394eda1 fix simple import 2026-07-14 11:05:09 +08:00
k88936 d307abf7a3 removed component export from index 2026-07-14 11:04:01 +08:00
k88936 5244dbfcd8 rollback: all in relative path now 2026-07-14 10:56:11 +08:00
kdletters ed8be38287 统一画布生成资源命名
新增所有生成面板的资源名称输入并统一80字符边界
贯通画布标题、项目资源、素材库与生成请求的名称传播
为图标图集和角色动作补齐命名契约及中间产物后缀
补充前后端回归测试、OpenAPI与编辑器文档
2026-07-14 10:27:25 +08:00
kdletters f7f799df81 修复画布音频拖放与拖拽热区
素材库显式拖放不再叠加级联偏移,音频卡中心落在鼠标松手位置
将音频播放按钮热区收紧到中央,释放卡片主体用于选择与拖拽
补充模型、桥接和画布交互回归测试并同步音频设计文档
2026-07-14 10:23:42 +08:00
kdletters 838d819c3d 修复Suno背景音乐下载失败
识别audiopipe中转地址并通过item_id获取稳定WAV文件

避免封面地址混入音频候选并跳过失败任务的额外探测

补充Suno两跳解析回归测试并更新设计与排障文档
2026-07-13 22:06:36 +08:00
kdletters 4e7bcb8f24 补齐生成中间产物持久化与拆图提示
图标和UI图集拆分失败时保留主图集并在内联、队列及刷新路径提示
角色、图片修改、图标UI和角色动作链路把可恢复中间产物写入项目与素材库
外部生成摘要新增独立告警字段并清理成功任务的历史错误
同步共享契约、SpacetimeDB迁移与绑定、OpenAPI、设计文档和回归测试
2026-07-13 21:41:46 +08:00
kdletters f1639de5fe 修复图标图集自动拆分降级
图集带背景原图和透明整图先写入项目资源与账号素材库并回填画布
自动拆分识别或持久化失败时保留图集并返回结构化警告
手动拆分限制图片尺寸、总像素和单次切片数量
同步共享契约、外部 OpenAPI、前端类型、设计文档和回归测试
2026-07-13 20:29:06 +08:00
kdletters 052d656a52 修复公开资产授权一致性与签名期限
公开资产 metadata 和作品授权改为在同一 SpacetimeDB 事务快照中判断
移除连接级公开授权订阅缓存对 ACL 的参与
公开读取签名有效期上限固定为 600 秒并保留 owner 和 admin 行为
同步生成 bindings 并更新后端契约与项目记忆
2026-07-13 20:04:29 +08:00
kdletters 4fc8beea72 合并画板图集自动拆分 2026-07-13 19:22:33 +08:00
kdletters eae00292a1 新增画板图集自动拆分
生成图标图集后自动拆分并持久化独立素材
新增图集工具栏手动拆分入口与权威画布回填
修复连通域切片阅读顺序并补齐前后端测试
同步外部接口与编辑器设计文档
2026-07-13 19:10:30 +08:00
k88936 e6d0ccc243 use import { formatMudPointCount } from '@genarrative/shared' style 2026-07-13 18:48:58 +08:00
kdletters 3cbc33bd69 优化画布快速编辑参数与替换规则
快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型
仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理
按用户选定分辨率替换当前图层并保持图层中心位置
补齐后端真实图层校验、移动端避让、定向测试与项目文档
2026-07-13 18:48:38 +08:00
kdletters 2ac57463c0 修复公开作品私有资产读取
新增公开可见作品的精确资产授权视图并覆盖十类玩法

改用受限 procedure 权威点查资产对象并在失败时关闭访问

移除资产对象全表订阅并统一 ACL 与编辑器引用查询

生成 SpacetimeDB 绑定并补齐回归测试、架构文档和项目记忆
2026-07-13 18:35:26 +08:00
kdletters 5536fba95e 补齐画布多产物生成回填
将角色、图标图集和UI素材提取的原图登记为项目资源并随处理结果写入画布
保持后处理结果为生成器主锚点并将原图错开放置
新增原图标签、后端多产物布局测试和前端快照回归
同步更新图片画布方案与共享决策记录
2026-07-13 18:28:54 +08:00
k88936 a3034b1d51 fix import 2026-07-13 18:08:46 +08:00
kdletters 55471e15de 补齐后台兑换码有效期与独立滚动
为兑换码增加可选生效和截止时间,并按左闭右开区间校验
同步 SpacetimeDB 迁移、生成绑定、共享契约和后台管理界面
让后台侧边栏与主内容区域独立滚动
修正兑换业务拒绝的 HTTP 状态映射并补充回归测试和文档
2026-07-13 17:26:47 +08:00
kdletters f4153ebfa7 统一维护与错误页面并修复移动端顶部文案
新增品牌维护页和404页面并补齐网关路由
将临时维护公告迁到release外运行态覆盖并增加构建门禁
同步Nginx、Pingora、部署脚本与生产运维文档
隐藏移动端顶部SEO介绍并保留桌面端与H1语义
增加维护页、404页和移动端回归测试
2026-07-13 16:26:27 +08:00
k88936 c038275e11 add back moved code to git 2026-07-13 15:49:34 +08:00
k88936 071dac67c1 fix import extension for formatMudPointCount in PlatformMudPointWalletEntry 2026-07-13 14:46:24 +08:00
k88936 9c982992a7 update import paths for PlatformMudPointWalletEntry 2026-07-13 14:18:21 +08:00
k88936 9831571dda fix resolve res in Vitest 2026-07-13 14:03:02 +08:00
k88936 b7b4dd0066 extract mud point wallet entry component for reuse in tauri app 2026-07-13 13:52:40 +08:00
kdletters 36a8719d0f 补齐微信虚拟支付发货确认
在虚拟支付商品入账后调用微信发货确认接口
允许已入账商品订单只重试发货而不重复发放权益
遇到失效 access token 时强制刷新并最多重放一次
要求使用微信 paid_time 并完善历史补单文件清理和回归测试
2026-07-13 13:39:59 +08:00
kdletters 6143189dd9 恢复停服升级公告页面
恢复今天晚上 21:00 至 22:00 的维护时间公告
列出画布与 PC 网页充值修复内容
补充抠图与背景色升级说明
适配桌面端和移动端维护页面布局
2026-07-13 13:17:22 +08:00
kdletters e815ece133 补齐完整发布备份模式参数
在 Full 流水线显式增加 DATABASE_BACKUP_MODE 参数
将备份模式原样透传到 SpacetimeDB 发布作业
增加生产运维门禁防止 Full 静默回退到异步备份
补充 release 禁止再次上传时必须选择 skip 的运维说明
2026-07-13 13:13:11 +08:00
kdletters 2f69f2fda2 补齐微信虚拟支付查单与补偿
接入微信官方虚拟支付查单并缓存稳定接口令牌
让用户确认与订单过期补偿统一核对支付类型、状态、金额和单号
增加单笔历史订单的 dry-run 与确认指纹补单工具
隔离运行时身份与敏感 openid 文件并补充配置契约和回归测试
2026-07-13 13:12:44 +08:00
kdletters 06aa99e3eb 修复后台表查询筛选与分页
使用单次哨兵查询统一计算扫描范围、匹配总数和截断状态
增加完整候选集服务端排序与越界页码钳制
增加固定分页栏、扫描上限提示和后端排序交互
补充大表响应边界文档与前后端回归测试
2026-07-13 13:09:53 +08:00
kdletters 7becd38117 修复泥点悬浮窗与登录弹窗交互
桌面端泥点详情支持从按钮移动到浮层并取消点击保持
统一弹窗仅在完整遮罩指针序列后关闭
补充泥点悬浮与登录遮罩拖出回归测试
记录遮罩关闭交互约束
2026-07-13 12:47:40 +08:00
kdletters 184d7e24c9 恢复默认维护页面
移除临时停服升级公告和维护时段文案。

恢复简洁的服务维护提示页面。
2026-07-13 11:36:07 +08:00
kdletters aeca412cdb 修复数据库冷备分片上传
冷备归档改用 OSS Multipart 顺序分片上传并加入可重试请求。

Complete 后通过签名 HEAD 严格核对远端对象长度,再更新清单或清理本地归档。

补齐签名查询、分片重试、缺失 ETag、Complete 歧义和 Abort 回归测试。

记录数据库冷备上传与清理边界的长期运维决策。
2026-07-13 11:13:57 +08:00
kdletters cde9b597bc 修复微信支付通知公钥验签
解析微信平台 SPKI 公钥并提取 PKCS#1 RSA 公钥。
复用生产 RSA-SHA256 验签路径处理支付通知。
补充标准 SPKI 公钥与真实签名向量回归测试。
同步微信支付验签排障文档和解析依赖。
2026-07-13 00:45:20 +08:00
kdletters 35568eeef4 修复泥点账单结算顺序
按当前余额反向校验流水结算链,纠正延迟入账的历史错序。

钱包流水与钱包状态统一记录事务实际结算时间。

补充延迟支付场景回归测试和账本时间契约文档。
2026-07-12 23:45:01 +08:00
kdletters 8e1f441d6b 补齐发布包维护脚本
把 API 部署与维护开关脚本复制进 current release
补充发布夹具对三个随包脚本的存在性断言
记录 Full 退出维护依赖 current release 自包含脚本
2026-07-12 23:02:58 +08:00
kdletters 1a90b4f644 收窄充值过期监听范围
改为订阅活跃充值过期定时器删除事件并按订单编号回读状态
移除不再使用的充值订单表行映射
同步后端契约、生产运维与项目共享记忆
2026-07-12 22:42:30 +08:00
kdletters 425699b2e2 修复发布就绪等待与充值过期监听
为 API readiness 探测增加单次超时,避免端口已建立时无限挂起
修复 SpacetimeDB 枚举状态订阅导致的充值过期监听失败
在 pause-after-stdb 审批提示中强调运行时与迁移身份互斥
补齐生产发布门禁、回归测试与运维排障文档
2026-07-12 22:24:47 +08:00
kdletters 51740c021b 补齐流水线维护退出选项与停服公告
Full 发布全程保持维护并在最后按选项退出
API Deploy 支持成功后保留维护并补齐回归门禁
新增停服公告维护页并同步运维文档
2026-07-12 21:24:17 +08:00
kdletters 71811f04c3 维护模式仅拦截公网流量
Nginx 按真实连接地址让内网绕过全站维护闸
Pingora 统一内网维护放行并拒绝 XFF 伪造
补齐公网与内网维护回归测试和运维文档
2026-07-12 20:27:04 +08:00
kdletters 8bfedd8ad7 调整首页品牌文案字号
将品牌说明字号固定为 17px
将首页主描述字号调整为 20px
将泥点赠送文案字号调整为 16px
2026-07-12 20:22:18 +08:00
kdletters 66178edc7c 统一泥点账户与每日免费额度
- 新增每日免费泥点独立余额桶,按北京时间零点固定重置20泥点
- 按每日免费、会员限时、永久顺序扣费并支持跨日退款按原分桶回补
- 统一主站与图片画板泥点入口,隐藏限时泥点、任务入口和会员购买入口
- 收敛四档充值商品与泥点购买弹窗,并接入后台动态商品配置
- 同步余额read model、SpacetimeDB迁移绑定、回归测试和工程文档
- 将泥点明细浮层圆角匹配首页卡片视觉规范
2026-07-12 19:35:22 +08:00
kdletters 2b13067492 调整首页品牌文案层级
更新首页唯一 H1、小眉题、产品说明与权益文案
统一游戏美术 AI 创作工具 H2 和功能弹窗 H2 语义
补充首页标题层级测试与 SEO、创作主页文档约束
2026-07-12 19:32:11 +08:00
kdletters 300e69c97d 修复图片改造尺寸保持
保持图片改造提交与画布回填的原图尺寸
在 VectorEngine 边界对齐非 16 倍数参考图并恢复目标尺寸
补充前后端回归测试与编辑器尺寸契约文档
2026-07-11 18:10:00 +08:00
kdletters d756e8e04b 补齐Jenkins凭据与维护后台通道
固化三个Stdb流水线的Secret File凭据默认值
将04:00开发服定时发布默认切换为normal
显式关闭三个下游Build Job的自动发布
允许内网来源在维护模式下访问后台完整链路
保持公网后台与普通业务路由的维护阻断
补齐Nginx、Pingora回归门禁与运维文档
2026-07-11 17:40:37 +08:00
kdletters ad2e59292c 合并 SEO 基础设施与精确路由
合并 codex/seo 的首页 SEO 元数据、robots 与 sitemap
统一 Nginx 和 Pingora SPA 精确路由及未知路径 404
保留 master 与 SEO 分支的项目记忆条目
补齐路由门禁、Pingora smoke 与前端回归测试
2026-07-11 16:12:05 +08:00
kdletters 2dac4a69fd 统一升级 SpacetimeDB 至 2.6.0
精确锁定 SpacetimeDB Rust crate 并更新依赖锁文件
使用 2.6.0 CLI 重新生成 Rust bindings
对齐容器镜像、server provision 与本地版本校验
更新运维文档、项目记忆与 SpacetimeDB skills 版本口径
2026-07-11 15:54:16 +08:00
kdletters 324f99efdf 修复外部生成任务大载荷导致的内存膨胀
阻止图片编辑器将 Data URL 和超限 JSON 写入生成任务,复用 objectKey 等轻量引用
新增外部生成任务摘要投影与分批 payload 压缩回填流程,避免正式列表读取大字段
收紧 SpacetimeDB 发布为永不删数据并移除 Jenkins 普通清库入口,补齐维护窗口门禁
更新生成绑定、运维文档和回归测试,并完成生产数据副本迁移与压缩验证
2026-07-11 13:55:05 +08:00
kdletters f848cbf78b 修复模型定价运行时身份边界
仅允许 HTTP 角色初始化定价,非 HTTP 角色改用队列只读鉴权
移除定价 procedure 快照中的 writer identity 并同步 bindings
后台保存复用受保护 bootstrap secret 支持缺失配置自愈
补充回归测试与架构运维文档
2026-07-10 19:50:55 +08:00
kdletters 187d7735b9 修复编辑器定价持久化与资源预览
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移
修复快速编辑支付弹窗期间框选显示和交互冻结
补齐后台图片放大以及视频音频资源预览
收紧主站和 External 资源换签授权并记录管理员跨用户审计
固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口
加固运行时身份轮换、bootstrap secret 与生产构建发布门禁
同步生成绑定、定向测试、运维脚本和项目文档
2026-07-10 18:28:06 +08:00
lhk229 5c9b5ef69e 动作视频/生图背景色链路升级 + 阿里云抠图接入 (#75)
主要改动
1. 阿里云通用抠图接入(新增 platform-matting crate)

手搓阿里云 VIAPI 通用抠图接口(ACS3-HMAC-SHA256 签名),跨区输入走临时桶通道。
图片抠图升级为三档降级:BgFilter → 阿里云 → 本地键色兜底。
动作视频逐帧抠图改用阿里云,remove_editor_generated_green_screen_background 降为最后兜底。
2. 背景色不再写死绿色,改多背景色自动决策

动作视频背景色和生图一样从候选调色板自动选,选项继承。
3. 视觉背景色决策(修"蓝撞蓝")

让 LLM 看参考图选背景色(gpt-4o-mini 视觉模型,路由到 VectorEngine 视觉客户端),修复原纯文本盲选。
4. 背景色候选硬过滤器(新增 editor_screen_background_filter)

按参考图前景配色算 Lab 危险质量 + 皮肤专属三判据(ΔE 距离 / 色调投影 / RGB 分离)剔除撞色候选,LLM 只在安全集合里审美选。露肤角色自动收敛到冷区。新增"灰竹绿"候选补齐安全弧绿色段。
5. 源图合成到背景色(修视频背景变白)

透明源图先填成选定实色再发给 Ark,视频背景确定性等于抠图键色,不再赌模型服从提示词。
6. screenColorHex 结构化记录 + 死代码清理

四条链路统一把实际背景色写进 generation_inputs,修复生图链路"抠图背景色"字段从未生效的旧逻辑。
清理前端抠绿 + qwenSprite 死代码,移除一次性探针示例。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/75
Reviewed-by: kdletters <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-10 18:22:17 +08:00
kdletters c483980c9c 修复 Pingora 部署检查误报
显式传播 systemd 与 EnvironmentFile 提取失败,首错后立即终止部署检查
补充 direct-entry 与 systemctl cat 失败回归,阻止空环境连带误报
同步记录旧 release 残留 capability 的安全清理与验收口径
2026-07-10 16:23:37 +08:00
kdletters 3462c1efe1 修复 Pingora 部署检查误报
显式传播 systemd 与 EnvironmentFile 提取失败,首错后立即终止部署检查
补充 direct-entry 与 systemctl cat 失败回归,阻止空环境连带误报
同步记录旧 release 残留 capability 的安全清理与验收口径
2026-07-10 16:20:21 +08:00
kdletters 09fec601b1 完善官网 SEO 地基与精确路由
补充 robots、sitemap、SEO 元信息、结构化数据与首页语义内容
统一三套 Nginx 与 Pingora 的 62 条 SPA 路由和真实 404 行为
新增路由一致性检查、网关测试并同步技术文档与项目记忆
2026-07-10 12:14:44 +08:00
kdletters e464e62697 修复创作页滚动与画布生成状态同步
为创作页恢复可见滚动条并补充样式测试
普通点击画布对象时收敛多选状态,避免批量拖动
裁扩结果先资源化,并在去背完成时保留源图层
队列完成后重读未决项目快照并补充回归测试
同步图片画布持久化文档与长期排障记录
2026-07-10 08:46:17 +08:00
kdletters 2de9b79e69 修复充值过期补偿确认竞态
调整微信充值确认终态判断,未完成补偿查单的 expired 不再提前结束。

让 SSE 客户端和前端确认流程等待补偿后的 paid 或已检查 expired。

补充 unchecked expired 到 paid 的回归测试。
2026-07-10 02:09:59 +08:00
kdletters 00ff425e03 改造充值订单过期补偿流程
新增 SpacetimeDB 原生充值订单过期 timer 与 expired 状态。

移除 api-server 轮询 worker,改为 HTTP 角色订阅 Pending 到 Expired 事件并查单补偿。

统一微信下单 5 分钟 time_expire,并恢复前端支付通道选择与 expired 展示。

补齐生成绑定、前后端契约、测试和项目文档。
2026-07-10 01:37:53 +08:00
kdletters 6ddde830ef 修复钱包流水枚举追加顺序
将会员周期流水枚举追加到已发布枚举末尾

重新生成 SpacetimeDB Rust 绑定保持枚举判别序号兼容

补充 SpacetimeDB 持久化枚举追加约束文档
2026-07-09 20:12:16 +08:00
kdletters 12ba0e95f3 修复会员旧档迁移与周期权益
修正旧月季年会员迁移到新同级档位的购买判定

修正 active 升级后续周期天数切换为新商品配置

修正 mock 立即结算订单先按支付前会员状态定价

修正会员商品周期泥点文案按周期天数展示

补充会员迁移升级与前端周期文案测试
2026-07-09 18:16:34 +08:00
kdletters 104c376d6e 修复后台Dashboard月视图日期刻度
放宽 Dashboard 柱状图日期列宽并保持柱子居中

补充 Dashboard 长时段横轴滚动展示口径
2026-07-09 17:41:56 +08:00
kdletters c022bbb8a0 修复小程序内充值设备识别
为 API 请求和刷新请求补充宿主运行时请求头。

在小程序充值设备身份异常时清理旧 token 并重新拉起小程序登录。

补充小程序充值回归测试并隔离测试 spy。
2026-07-09 16:28:32 +08:00
kdletters 67fee3718d 修复画布背景色与任务计时显示
恢复生成任务列表的进行中计时显示并兼容微秒时间戳

替换画布背景色原生取色器为可拖拽色盘和色带

持久化画布背景色并在项目恢复时应用

补充背景色交互持久化和任务计时显示测试
2026-07-09 16:05:25 +08:00
kdletters 5f6f4d0ba6 接入会员周期泥点制度
新增 Starter、Basic、Pro、Ultimate 会员商品配置与权益字段。

拆分会员有效期、周期重置时间、周期限时泥点和永久泥点余额。

实现升级补价补点但不延长有效期或移动重置时间,同级购买只延长有效期。

补齐钱包扣费与退款中的会员周期泥点消耗、重置和流水记录。

同步后台商品配置、前端充值展示、共享契约、SpacetimeDB 迁移与生成绑定。
2026-07-09 14:48:26 +08:00
kdletters 2cf3953bad 收紧外部生成任务租约和超时保护
缩短 worker lease 并新增普通和长任务执行预算配置

让 worker 超过执行预算后写失败并释放槽位

降低 BgFilter 默认超时并加入连续失败熔断兜底

补充资产计费 future 取消时的退款补偿保护

同步生产 env 示例、部署脚本和运维文档
2026-07-09 01:32:23 +08:00
kdletters f1ca3e79c1 轻量化外部生成队列进程
worker 和 controller 改为订阅 external_generation_job 队列变更来唤醒 claim 与扩缩容评估
非 HTTP 角色关闭 API 读模型订阅并将 SpacetimeDB 连接池收敛为 1
更新 worker/controller 环境模板和运维记忆,明确 poll interval 只作兜底
2026-07-08 22:16:51 +08:00
kdletters 542fb09365 接入微信 Native 充值与订单过期处理
调整网页端充值渠道,微信内浏览器在开放平台接入前走 Native 支付。

拆分 Native 支付二维码弹窗,展示金额和充值内容并取消无效 code_url 跳转。

修复微信 Native 下单 time_expire 秒级 RFC3339 格式,避免小数秒参数错误。

新增充值订单 5 分钟过期调度,过期前查询微信真实订单状态。

补充微信授权绑定、路由加载恢复和后台表时间显示等配套逻辑。

更新 SpacetimeDB schema、绑定和相关文档测试。
2026-07-08 21:35:02 +08:00
kdletters 2721fec51c Merge remote-tracking branch 'origin/codex/gray-release' 2026-07-08 20:44:29 +08:00
kdletters 1462ec2920 优化灰度规则判定与后台表单
按需读取灰度用户标签

新建 Gate Target 时重置表单规则

补充灰度语义和踩坑文档
2026-07-08 12:22:48 +08:00
kdletters 25cac367c6 接入功能灰度与画布 Agent 灰度
新增 SpacetimeDB 灰度配置表与后端判定链路

新增后台灰度发布页面和两段式 Gate Key 选择器

将画布 Agent 入口接入灰度配置与运行时能力下发

修复灰度配置刷新读取空本地缓存的问题

补充灰度规则和后台下拉维护约定文档
2026-07-08 11:26:41 +08:00
kdletters 4e3c26d5d8 修复 release 发布与冷备份恢复
冷备份增加工作目录空间预检并确保失败后恢复依赖服务

API deploy 增加 Pingora 产物硬校验并区分 current 切换前后维护模式处理

Jenkins release 默认构建和部署 Pingora 影子网关并在全量流水线透传参数

补充备份与 API deploy 回归检查和生产运维护栏

更新生产运维文档与项目记忆的事故处理口径
2026-07-08 11:04:48 +08:00
kdletters 754d364227 修复画布生成刷新后完成同步
任务列表识别当前画布项目的未确认完成任务并发出完成通知。

编辑器收到任务完成通知后重新加载项目快照并刷新素材库。

补充刷新后任务已完成和轮询完成的回归测试。
2026-07-07 19:45:16 +08:00
kdletters 00db7d39ea 统一生成器对话框范围行为
移除生成器对话框打开后的安全范围自动拉动视口。

取消快速编辑、裁扩和角色动画面板的画布范围夹取。

保留创建生成器时聚焦新占位的行为并更新回归测试。
2026-07-07 19:36:20 +08:00
kdletters 862653c987 修复精选提示词展示
精选卡片优先展示用户可见的生成输入

过滤系统内置和素材提取提示词

补充精选提示词外露回归测试
2026-07-07 18:47:23 +08:00
kdletters 5888219c95 移除精选预览弹窗点赞操作
精选素材预览弹窗只保留点赞数展示

补充弹窗不展示点赞按钮的回归测试
2026-07-07 18:37:58 +08:00
kdletters 446c6945ad 恢复精选瀑布流布局
将创作主页精选列表从网格恢复为多栏瀑布流

新增样式契约测试防止瀑布流再次退化为网格
2026-07-07 18:34:07 +08:00
kdletters 9b49085869 修复画布生成器偶发无法打开
同步生成器 active/inactive ref 与 state

激活已有生成器时清理选参考图和菜单临时状态

窗口失焦时释放 Shift 与空格按住态

补充生成器状态与快捷键回归测试

记录图片画布生成器排障经验
2026-07-07 16:22:09 +08:00
kdletters b743152c6a 补齐zustand依赖锁文件
在 package-lock.json 中补齐 zustand 依赖声明

记录 zustand 5.0.14 的 npm lock 条目
2026-07-07 15:10:45 +08:00
lhk229 308d440816 新抠图算法(轻量版)上线 (#73)
新抠图算法(轻量版)上线,生图背景色同步改为多颜色+自动决策

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/73
Reviewed-by: kdletters <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-07 14:57:10 +08:00
k88936 0c14e51419 Editor agent paste image (#72)
![shotmd-1783397773.webp](/attachments/659f35ce-87c1-449d-8342-8afc09934da8)

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/72
Reviewed-by: kdletters <kdletters@qq.com>
Co-authored-by: kvtodev <kvtodev@outlook.com>
Co-committed-by: kvtodev <kvtodev@outlook.com>
2026-07-07 14:56:28 +08:00
kdletters 5477abd7ba 修正精选活动图居中显示
恢复精选预览容器 23rem 高度上限

为活动卡预览添加独立样式并使用 contain 居中显示图片

补充活动卡预览 class 与比例测试
2026-07-07 14:19:46 +08:00
kdletters 2a98b60830 改为运行时控制画板 Agent 入口
新增前端运行时配置接口下发画板 Agent 开关

将画板 Agent 入口改为读取后端运行时配置

把开关变量改为 GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR

补充前后端测试和生产环境变量文档
2026-07-07 14:04:08 +08:00
kdletters 1d43783685 Merge pull request '隐藏画板素材信息中Object ID' (#71) from hide-obj-id into master
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/71
Reviewed-by: kdletters <kdletters@qq.com>
2026-07-07 13:54:09 +08:00
kdletters 9e92b3e673 Merge branch 'master' into hide-obj-id 2026-07-07 13:53:44 +08:00
kdletters 4622b35a32 修复精选活动图裁切
移除精选预览容器最大高度限制

让活动图按记录宽高自然拉伸卡片高度

通过浏览器验证 900x1200 活动图比例不再被压到 23rem
2026-07-06 22:59:25 +08:00
kdletters 2d631becc7 显示精选审核提交失败原因
统一素材精选审核提交资格判断

在素材菜单显示不可提交和后端拒绝原因

将编辑器业务拒绝映射为 400 并保留中文原因

补充精选审核原因展示测试和文档口径
2026-07-06 21:35:10 +08:00
kdletters be7f0375e5 记录精选活动卡图片宽高
后台上传活动卡图片时读取并保存原始宽高

隐藏活动卡 Object Key 输入,重复上传替换图片引用

公开精选按活动图宽高设置卡片比例

同步 SpacetimeDB schema、bindings 和后端数据契约文档
2026-07-06 21:00:21 +08:00
k88936 29db6365e1 隐藏画板素材信息中Object ID 2026-07-06 19:23:02 +08:00
kdletters ab61390121 修复画布 Agent 默认被任务列表隐藏
打开画布时 Agent 开关启用则保持 Agent 默认展开。

恢复任务自动打开任务列表时不再抢占 Agent 默认态。

用户手动打开任务列表时仍按右侧面板互斥关闭 Agent。

补充恢复任务场景下 Agent 默认显示的回归测试。
2026-07-06 19:15:03 +08:00
kdletters fcd353f12f 同步精选分类与活动卡文档
更新精选审核公开口径,说明未设置分类进入全部。

更新创作主页文档,移除素材包分类并说明全部 Tab。

更新后端表目录,记录活动卡 imageObjectKey 和 OSS 上传方式。
2026-07-06 18:59:40 +08:00
kdletters 51c47c952b 调整前台精选分类展示
前台精选分类新增全部并作为默认 Tab。

移除素材包 Tab 和素材包组合逻辑,未设置分类素材保留在全部中。

活动卡支持从 imageObjectKey 兜底图片地址。

精选卡片改为网格布局并让图片素材铺满预览区域。

更新前台精选模型和创作主页测试。
2026-07-06 18:58:58 +08:00
kdletters 68e9087552 完善后台精选活动卡上传
后台精选活动卡支持选择图片并通过 OSS 直传凭证上传。

保存活动卡时同步 imageSrc 和 imageObjectKey。

移除后台素材包分类选项,清空分类时不再自动隐藏精选素材。

补充后台活动卡上传和分类交互测试。
2026-07-06 18:57:20 +08:00
kdletters b5aa68fc6a 调整精选后端公开契约
新增精选活动卡图片 objectKey 存储字段和迁移兼容。

新增后台活动卡图片 OSS 直传凭证接口。

移除素材包精选分类约束,允许未设置分类素材进入全部精选。

同步 admin 和前台读取活动卡 objectKey 的类型契约。
2026-07-06 18:56:49 +08:00
kdletters b8e6e9f3ef 控制画布 Agent 入口默认关闭
新增 VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR 控制画布 Agent 入口显示。

默认 .env 关闭、本地 .env.local 开启,并补充示例和类型声明。

禁用时隐藏 dock 按钮和收起态 Agent 面板,开启时保留右侧任务列表互斥逻辑。

修正右侧 Agent 和任务列表切换不影响左侧资源栏。

补充画布 Agent 开关、面板独立性和 dock 隐藏测试。
2026-07-06 18:51:11 +08:00
kdletters 7c449f0596 修正音频素材封面
使用用户提供的透明 PNG 替换自绘 SVG 音频封面
统一后台素材查询、精选审核、创作主页和画板素材库的音频封面引用
补齐画板图层列表音频图层封面展示
更新相关测试覆盖
2026-07-06 14:44:43 +08:00
kdletters 0b766c8bf9 统一音频素材封面缩略图
新增音频素材统一封面图。
素材库、创作主页精选和后台缩略图改为使用音频封面。
补充音频封面展示回归测试。
2026-07-06 14:10:16 +08:00
kdletters b288b091b2 完善精选审核与素材查询展示
移除素材查询分类筛选和分类列

精选审核通过后改为手动设置展示分类

公开精选按分类展示并补充底部点赞按钮

使用文档大拇指透明图标并同步点赞测试

同步精选审核数据契约、绑定和文档
2026-07-05 18:01:02 +08:00
kdletters a24bbeeeb8 补齐API启动controller弱依赖
API systemd unit 增加 controller 弱依赖
生产 ops 护栏校验 API unit 依赖
同步运维文档和项目记忆说明
2026-07-05 18:01:02 +08:00
kdletters 1fb5a0773e 修复画板提示与失效项目跳转
画板参考图选择提示改为持续显示并支持手动关闭。

显式项目访问失效时同步切回项目页状态。

补充提示关闭和项目失效回退测试。
2026-07-05 18:01:02 +08:00
kdletters d7d98acfd4 接入VectorEngine画布Agent规划
切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。

为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。

补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。

同步前后端契约、测试脚本、环境示例和项目文档。
2026-07-05 17:42:27 +08:00
kdletters a4a7aed024 合并画布 Agent 侧边栏聊天
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/spacetime-client/src/module_bindings.rs
2026-07-04 18:39:40 +08:00
kdletters 1671d35995 修复画布Agent聊天生成体验
让画布 Agent 消息统一走后端规划与 LLM 回复
修复生成图片在聊天、画布和素材库中的展示与恢复
补齐右键删除、画布焦点、聊天滚轮和面板避让交互
同步 Agent 事件契约、文档和定向回归测试
2026-07-04 16:41:33 +08:00
kdletters 8dad52f040 实现画布Agent侧边栏聊天
新增画布Agent会话契约、领域规则、SpacetimeDB元数据表和生成绑定。
新增api-server画布Agent会话CRUD、OSS消息文档读写、SSE消息流和生成工具编排。
新增前端右侧Agent对话面板、会话状态hook、SSE客户端、BFF客户端和相关测试。
调整画布侧栏、任务栏、小地图默认状态和面板互斥交互。
补齐画布Agent、OSS消息存储、SSE收口、后端契约和项目记忆文档。
2026-07-04 12:03:58 +08:00
586 changed files with 85518 additions and 7642 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
"Bash(findstr LISTENING)",
"Bash(npx tsc:*)",
"Bash(lsof -ti:8081)",
"Bash(curl -s http://localhost:8081/health)"
"Bash(curl -s http://localhost:8081/health)",
"Skill(code-review)",
"Skill(code-review:*)"
]
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-cli
description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
---
# SpacetimeDB CLI
@@ -61,7 +61,7 @@ spacetime describe my-db table users --server http://127.0.0.1:3101 --json
# Reducer/procedure calls. Arguments are positional JSON values.
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
# 2.5+ accepts hex strings for Identity arguments without full JSON tuple syntax.
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
# Subscribe from CLI
@@ -102,7 +102,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping
| Flag | Description |
|------|-------------|
| `--server`, `-s` | Target server nickname, host, or URL |
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values |
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values |
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
| `--module-path`, `-p` | Module project path |
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
@@ -146,6 +146,6 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)"
## Notes
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution.
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
+9 -8
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-concepts
description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
---
# SpacetimeDB Core Concepts
@@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
@@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client-
## Procedures
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6.
## Views
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
## Event Tables
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables.
## Subscriptions
@@ -78,7 +78,7 @@ Best practices:
- Avoid overlapping queries that duplicate row delivery.
- Use indexes for subscribed filters.
## 2.2.0 to 2.5.0 Delta
## 2.2.0 to 2.6.0 Delta
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
@@ -87,6 +87,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands.
## Debugging Checklist
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-rust
description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
---
# SpacetimeDB Rust Module Development
@@ -28,7 +28,7 @@ ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
ctx.sender // Use ctx.sender()
ctx.db.user().name().update(..) // Update by primary key only
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures since 2.5
```
## Required Patterns
@@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
## Views
@@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer
## Procedures
Procedures are stable in 2.5 and no longer require the `unstable` feature.
Procedures remain stable in 2.6 and no longer require the `unstable` feature.
```rust
use spacetimedb::{procedure, ProcedureContext};
+2
View File
@@ -1,5 +1,7 @@
# 微信小程序 web-view 登录配置。
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
WECHAT_MINI_PROGRAM_APP_ID=""
WECHAT_MINI_PROGRAM_APP_SECRET=""
WECHAT_JS_CODE_SESSION_ENDPOINT=""
+17 -11
View File
@@ -1,11 +1,12 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
LLM_BASE_URL="https://api.vectorengine.cn/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` or `ARK_API_KEY`.
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
# through the Rust api-server proxy.
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
# but it should not be relied on by browser code.
LLM_API_KEY="YOUR_API_KEY"
LLM_API_KEY=""
# Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm"
@@ -103,6 +104,8 @@ WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order"
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods"
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
WECHAT_STATE_TTL_MINUTES="15"
WECHAT_MOCK_USER_ID="wx-mock-user"
@@ -116,7 +119,11 @@ WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDE
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
# Optional: enable upstream web search for RPG story text generation.
RPG_LLM_WEB_SEARCH_ENABLED="true"
@@ -125,13 +132,8 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# APIMart Responses config for creative-agent text/multimodal understanding.
APIMART_BASE_URL="https://api.apimart.ai/v1"
APIMART_API_KEY="YOUR_APIMART_API_KEY"
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
# VectorEngine GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai"
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
@@ -199,6 +201,10 @@ VITE_LLM_DEBUG_LOG="false"
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
VITE_DEBUG_MODE=""
# Optional: show the image editor right-side Agent entry at runtime.
# This is read by api-server and exposed through /api/runtime/frontend-config.
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.
+2
View File
@@ -42,6 +42,8 @@ LLM_DEBUG_LOG="true"
# 注意:不要在客户端启用调试日志,避免敏感数据泄露
# VITE_LLM_DEBUG_LOG="false"
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true
ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing"
ALIYUN_OSS_ENDPOINT="oss-cn-beijing.aliyuncs.com"
+12
View File
@@ -16,6 +16,18 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
**画布Agent对话**:
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
**画布Agent会话记录**:
画布Agent对话的持久化形态:SpacetimeDB 表只存会话元数据(会话 ID、所属工程、属主、标题、软删标记、聊天记录 OSS 对象引用、时间戳),完整消息内容以会话粒度 JSON 对象存 OSS,追加消息即整体重写对象。
_Avoid_: api-server 内存会话、消息全文入 SpacetimeDB 表、对话混入工程布局快照、每条消息一个 OSS 对象
**画布Agent对话附件**:
画布Agent对话消息携带的图片参考,统一为画布资源 / 素材库对象引用(resourceId / assetId + 可选 objectKey),单条消息上限 9 张;上传图片若从对话入口进入,必须复用素材库 / 画布资源登记链路,在上传格未落地前只从已有画布资源和账号素材库选择,不存在只属于对话的第三种图。
_Avoid_: 对话私有图片副本、内嵌 base64 附件、音视频附件
**画布资源**:
图片画布工程中可被一个或多个图层引用的图片资源记录,保存 OSS 对象引用、上传 / 生成来源、提示词、模型、任务和尺寸等资源元数据;同一资源可以在工程布局中出现多次。
_Avoid_: 图层位置、前端 hover / selected 状态、直接内嵌图片二进制
@@ -0,0 +1,174 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminUserDetail,
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
} from './adminApiClient';
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await createAdminAccount('owner-token', {
username: 'operator',
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
enabled: true,
});
await updateAdminAccount('owner-token', 'member/1', {
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/accounts');
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
}),
}),
);
});
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminRechargeOrders('token-1', {
orderId: 'order 1',
userId: 'user-1',
providerTransactionId: 'wx-1',
paymentChannel: 'wechat_native',
status: 'paid',
createdAfter: '2026-07-01T00:00:00.000Z',
createdBefore: '2026-07-13T23:59:00.000Z',
limit: 50,
});
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
const parsed = new URL(requestUrl, 'http://admin.local');
expect(parsed.pathname).toBe('/admin/api/profile/recharge-orders');
expect(Object.fromEntries(parsed.searchParams)).toEqual({
orderId: 'order 1',
providerTransactionId: 'wx-1',
userId: 'user-1',
paymentChannel: 'wechat_native',
status: 'paid',
createdAfter: '2026-07-01T00:00:00.000Z',
createdBefore: '2026-07-13T23:59:00.000Z',
limit: '50',
});
});
test('用户详情只发送实际提供的用户定位字段', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
const parsed = new URL(requestUrl, 'http://admin.local');
expect(parsed.pathname).toBe('/admin/api/profile/users/detail');
expect(Object.fromEntries(parsed.searchParams)).toEqual({
publicUserCode: 'TN1001',
});
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await executeAdminRechargeRefund('token-1', {
orderId: 'order-1',
refundAmountCents: 300,
requestId: 'request-1',
reason: '用户申请',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/recharge-refunds/execute',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
orderId: 'order-1',
refundAmountCents: 300,
requestId: 'request-1',
reason: '用户申请',
}),
}),
);
});
test('退款人工复核使用独立 resolve 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await resolveAdminRechargeRefundManualReview('token-1', {
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/recharge-refunds/manual-review/resolve',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
}),
}),
);
});
+322 -4
View File
@@ -1,4 +1,7 @@
import type {
AdminAccountListResponse,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
@@ -11,23 +14,39 @@ import type {
AdminDatabaseTableListResponse,
AdminDatabaseTableRowsQuery,
AdminDatabaseTableRowsResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
AdminEditorAssetListQuery,
AdminEditorAssetListResponse,
AdminDirectUploadTicketPayload,
AdminEditorShowcaseAssetResponse,
AdminEditorShowcaseCampaignResponse,
AdminEditorShowcaseDisplayRequest,
AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest,
AdminFeatureGateConfigResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse,
AdminRechargeRefundExecuteRequest,
AdminRechargeRefundManualReviewResolveRequest,
AdminRechargeRefundPreviewRequest,
AdminRechargeRefundPreviewResponse,
AdminRechargeRefundRegisterRequest,
AdminTrackingEventListQuery,
AdminTrackingEventKeyListResponse,
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
@@ -35,6 +54,10 @@ import type {
AdminUpsertProfileWalletConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
AdminWalletRestrictionResponse,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
@@ -170,6 +193,32 @@ export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', { token });
}
export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
}
export function createAdminAccount(
token: string,
payload: AdminCreateAccountRequest,
) {
return request<AdminCreateAccountResponse>('/admin/api/accounts', {
method: 'POST',
token,
body: payload,
});
}
export function updateAdminAccount(
token: string,
accountId: string,
payload: AdminUpdateAccountRequest,
) {
return request<AdminUpdateAccountResponse>(
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
{method: 'PUT', token, body: payload},
);
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
@@ -226,6 +275,23 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function getAdminFeatureGateConfig(token: string) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
token,
});
}
export function upsertAdminFeatureGateConfig(
token: string,
payload: AdminUpsertFeatureGateConfigRequest,
) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
method: 'PUT',
token,
body: payload,
});
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
@@ -319,9 +385,13 @@ export function updateAdminWorkVisibility(
);
}
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
export function getAdminAssetReadUrl(
token: string,
query: AdminAssetReadUrlQuery,
) {
return request<AdminAssetReadUrlResponse>(
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
`/admin/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
{ token },
);
}
@@ -394,6 +464,35 @@ export function upsertAdminEditorShowcaseCampaign(
);
}
export async function uploadAdminEditorShowcaseCampaignImage(
token: string,
file: File,
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
const contentType = resolveAdminImageContentType(file);
const dimensions = await readAdminImageFileDimensions(file);
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
{
method: 'POST',
token,
body: {
fileName: file.name.trim() || 'showcase-campaign.png',
contentType,
contentLength: file.size,
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
},
);
await postAdminDirectUploadFile(response.upload, file);
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
return {
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
imageObjectKey: objectKey,
imageWidth: dimensions.imageWidth,
imageHeight: dimensions.imageHeight,
legacyPublicPath: response.upload.legacyPublicPath,
};
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
@@ -524,6 +623,76 @@ export function upsertProfileRechargeProduct(
);
}
export function listAdminRechargeOrders(
token: string,
query: AdminRechargeOrderListQuery = {},
) {
return request<AdminRechargeOrderListResponse>(
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
{token},
);
}
export function getAdminUserDetail(
token: string,
query: AdminUserDetailQuery,
) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
{token},
);
}
export function previewAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundPreviewRequest,
) {
return request<AdminRechargeRefundPreviewResponse>(
'/admin/api/profile/recharge-refunds/preview',
{method: 'POST', token, body: payload},
);
}
export function executeAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundExecuteRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/execute',
{method: 'POST', token, body: payload},
);
}
export function registerAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundRegisterRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/register',
{method: 'POST', token, body: payload},
);
}
export function resolveAdminRechargeRefundManualReview(
token: string,
payload: AdminRechargeRefundManualReviewResolveRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/manual-review/resolve',
{method: 'POST', token, body: payload},
);
}
export function updateAdminWalletRestriction(
token: string,
payload: AdminWalletRestrictionRequest,
) {
return request<AdminWalletRestrictionResponse>(
'/admin/api/profile/wallet-restriction',
{method: 'POST', token, body: payload},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
@@ -556,6 +725,123 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
return queryString ? `?${queryString}` : '';
}
function resolveAdminImageContentType(file: File) {
const declaredType = file.type.trim();
if (declaredType.startsWith('image/')) {
return declaredType;
}
const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1];
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg';
}
if (extension === 'png') {
return 'image/png';
}
if (extension === 'webp') {
return 'image/webp';
}
if (extension === 'gif') {
return 'image/gif';
}
return declaredType || 'application/octet-stream';
}
function normalizeAdminImageDimensions(width: number, height: number) {
const imageWidth = Math.round(width);
const imageHeight = Math.round(height);
if (
!Number.isFinite(imageWidth) ||
!Number.isFinite(imageHeight) ||
imageWidth <= 0 ||
imageHeight <= 0
) {
return null;
}
return { imageWidth, imageHeight };
}
async function readAdminImageFileDimensions(file: File) {
if (typeof createImageBitmap === 'function') {
try {
const bitmap = await createImageBitmap(file);
const dimensions = normalizeAdminImageDimensions(
bitmap.width,
bitmap.height,
);
bitmap.close();
if (dimensions) {
return dimensions;
}
} catch {
// Fall back to HTMLImageElement decoding below.
}
}
if (
typeof Image === 'undefined' ||
typeof URL === 'undefined' ||
typeof URL.createObjectURL !== 'function'
) {
throw new Error('读取活动卡图片尺寸失败,请重新选择图片');
}
return new Promise<{ imageWidth: number; imageHeight: number }>(
(resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
const cleanup = () => {
if (typeof URL.revokeObjectURL === 'function') {
URL.revokeObjectURL(objectUrl);
}
};
image.onload = () => {
cleanup();
const dimensions = normalizeAdminImageDimensions(
image.naturalWidth || image.width,
image.naturalHeight || image.height,
);
if (dimensions) {
resolve(dimensions);
return;
}
reject(new Error('读取活动卡图片尺寸失败,请重新选择图片'));
};
image.onerror = () => {
cleanup();
reject(new Error('读取活动卡图片尺寸失败,请重新选择图片'));
};
image.src = objectUrl;
},
);
}
function buildAdminDirectUploadFormData(
upload: AdminDirectUploadTicketPayload,
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file, file.name);
return formData;
}
async function postAdminDirectUploadFile(
upload: AdminDirectUploadTicketPayload,
file: File,
) {
const response = await fetch(upload.host, {
method: 'POST',
body: buildAdminDirectUploadFormData(upload, file),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
function buildQueryString(query: AdminTrackingEventListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'eventKey', query.eventKey);
@@ -574,6 +860,35 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
return queryString ? `?${queryString}` : '';
}
function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'orderId', query.orderId);
appendQueryParam(
params,
'providerTransactionId',
query.providerTransactionId,
);
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
appendQueryParam(params, 'paymentChannel', query.paymentChannel);
appendQueryParam(params, 'status', query.status);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildAdminUserDetailQuery(query: AdminUserDetailQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildDashboardQuery(query: AdminDashboardQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'granularity', query.granularity);
@@ -591,6 +906,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
if (typeof query.page === 'number' && Number.isFinite(query.page)) {
params.set('page', String(query.page));
}
appendQueryParam(params, 'sortColumn', query.sortColumn);
appendQueryParam(params, 'sortDirection', query.sortDirection);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
@@ -599,7 +919,6 @@ function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor);
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
appendQueryParam(params, 'assetKind', query.assetKind);
appendQueryParam(params, 'keyword', query.keyword);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
@@ -614,7 +933,6 @@ function buildEditorShowcaseListQuery(query: AdminEditorShowcaseListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor);
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
appendQueryParam(params, 'assetKind', query.assetKind);
appendQueryParam(params, 'reviewStatus', query.reviewStatus);
appendQueryParam(params, 'submittedAfter', query.submittedAfter);
appendQueryParam(params, 'submittedBefore', query.submittedBefore);
+319 -3
View File
@@ -36,10 +36,53 @@ export interface AdminSessionPayload {
username: string;
displayName: string;
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminAccountPayload {
accountId: string;
username: string;
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface AdminAccountListResponse {
accounts: AdminAccountPayload[];
}
export interface AdminCreateAccountRequest {
username: string;
displayName: string;
password: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminCreateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminUpdateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
@@ -150,8 +193,11 @@ export interface AdminDatabaseTableListResponse {
export interface AdminDatabaseTableRowsQuery {
limit?: number;
page?: number;
search?: string;
filters?: string;
sortColumn?: string;
sortDirection?: 'asc' | 'desc';
}
export interface AdminDatabaseTableRowPayload {
@@ -165,6 +211,11 @@ export interface AdminDatabaseTableRowsResponse {
rows: AdminDatabaseTableRowPayload[];
totalReturned: number;
limit: number;
page: number;
totalMatched: number;
scannedCount: number;
scanLimit: number;
scanLimitReached: boolean;
}
export interface AdminDatabaseTableStatPayload {
@@ -199,7 +250,15 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export type ProfileMembershipTier =
| 'normal'
| 'month'
| 'season'
| 'year'
| 'starter'
| 'basic'
| 'pro'
| 'ultimate';
export interface AdminTrackingEventListQuery {
eventKey?: string;
@@ -212,6 +271,26 @@ export interface AdminTrackingEventListQuery {
exportAll?: boolean;
}
export interface AdminFeatureGateConfigPayload {
gateKey: string;
enabled: boolean;
rolloutPercent: number;
allowUserIds: string[];
allowUserTags: string[];
denyUserIds: string[];
description: string;
updatedAt: string;
}
export interface AdminFeatureGateConfigResponse {
gates: AdminFeatureGateConfigPayload[];
}
export type AdminUpsertFeatureGateConfigRequest = Omit<
AdminFeatureGateConfigPayload,
'updatedAt'
>;
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
export interface AdminCreationEntryConfigResponse {
entries: AdminCreationEntryTypeConfigPayload[];
@@ -349,7 +428,6 @@ export interface AdminUpdateWorkVisibilityResponse {
export interface AdminEditorAssetListQuery {
cursor?: string | null;
ownerUserId?: string | null;
assetKind?: string | null;
keyword?: string | null;
createdAfter?: string | null;
createdBefore?: string | null;
@@ -391,7 +469,6 @@ export interface AdminEditorAssetListResponse {
export interface AdminEditorShowcaseListQuery {
cursor?: string | null;
ownerUserId?: string | null;
assetKind?: string | null;
reviewStatus?: string | null;
submittedAfter?: string | null;
submittedBefore?: string | null;
@@ -431,6 +508,7 @@ export interface AdminEditorShowcaseAssetPayload {
approvedAt?: string | null;
rejectedAt?: string | null;
updatedAt: string;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseListResponse {
@@ -447,6 +525,7 @@ export interface AdminEditorShowcaseReviewRequest {
export interface AdminEditorShowcaseDisplayRequest {
showcaseId: string;
displayEnabled: boolean;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseAssetResponse {
@@ -457,6 +536,9 @@ export interface AdminEditorShowcaseCampaignPayload {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
prompt: string;
author: string;
costText: string;
@@ -471,11 +553,41 @@ export interface AdminUpsertEditorShowcaseCampaignRequest {
enabled: boolean;
title: string;
imageSrc: string;
imageObjectKey?: string | null;
imageWidth?: number | null;
imageHeight?: number | null;
prompt: string;
author: string;
costText: string;
}
export interface AdminDirectUploadTicketPayload {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
contentType?: string | null;
formFields: Record<string, string | null | undefined>;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketRequest {
fileName: string;
contentType: string;
contentLength: number;
}
export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: AdminDirectUploadTicketPayload;
}
export interface AdminUploadedEditorShowcaseCampaignImage {
imageSrc: string;
imageObjectKey: string;
imageWidth: number;
imageHeight: number;
legacyPublicPath: string;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
@@ -484,6 +596,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
startsAt?: string | null;
expiresAt?: string | null;
}
export interface AdminUpsertProfileInviteCodeRequest {
@@ -525,6 +639,10 @@ export interface AdminUpsertProfileRechargeProductRequest {
badgeLabel?: string | null;
description?: string | null;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
}
@@ -541,6 +659,8 @@ export interface ProfileRedeemCodeAdminResponse {
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
startsAt?: string | null;
expiresAt?: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -552,6 +672,7 @@ export interface ProfileCodeOperationAdminResponse {
code: string;
action: 'create' | 'update' | 'disable' | string;
operatorUserId: string;
operatorDisplayName: string;
createdAt: string;
}
@@ -588,8 +709,10 @@ export interface ProfileTaskConfigAdminResponse {
enabled: boolean;
sortOrder: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -608,6 +731,10 @@ export interface ProfileRechargeProductConfigAdminResponse {
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
@@ -624,8 +751,10 @@ export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -657,3 +786,190 @@ export interface AdminTrackingEventKeyPayload {
export interface AdminTrackingEventKeyListResponse {
eventKeys: AdminTrackingEventKeyPayload[];
}
export interface AdminRechargeOrderListQuery {
orderId?: string;
providerTransactionId?: string;
userId?: string;
publicUserCode?: string;
paymentChannel?: string;
status?: string;
createdAfter?: string;
createdBefore?: string;
limit?: number;
}
export interface AdminUserSummaryPayload {
userId: string;
publicUserCode: string;
displayName: string;
avatarUrl?: string | null;
}
export interface AdminWalletManualRestrictionPayload {
frozen: boolean;
reason: string;
createdByAdminUserId: string;
createdByAdminDisplayName: string;
createdAtMicros: number;
updatedByAdminUserId: string;
updatedByAdminDisplayName: string;
updatedAtMicros: number;
}
export interface AdminProfileWalletPayload {
userId: string;
totalBalance: number;
spendableBalance: number;
dailyFreePoints: number;
membershipLimitedPoints: number;
permanentPoints: number;
heldPoints: number;
refundDebtPoints: number;
manualFrozen: boolean;
refundDebtFrozen: boolean;
walletFrozen: boolean;
manualRestriction?: AdminWalletManualRestrictionPayload | null;
}
export interface AdminRechargeRefundPayload {
outRefundNo: string;
providerRefundId: string;
providerTransactionId: string;
providerStatus: string;
totalCents: number;
refundCents: number;
payerRefundCents: number;
successAtMicros?: number | null;
firstObservedAtMicros: number;
updatedAtMicros: number;
observationSource: string;
targetRecoveryPoints: number;
recoveredPoints: number;
unrecoveredPoints: number;
recoveryStatus: string;
lastErrorCode?: string | null;
manualReviewResolvedByAdminUserId?: string | null;
manualReviewResolutionReason?: string | null;
manualReviewResolvedAtMicros?: number | null;
manualReviewResolvedErrorCode?: string | null;
}
export interface AdminRechargeRefundHoldPayload {
outRefundNo: string;
refundCents: number;
heldPoints: number;
status: string;
adminUserId: string;
reason: string;
createdAtMicros: number;
updatedAtMicros: number;
}
export interface AdminRechargeOrderEntryPayload {
orderId: string;
userId: string;
user?: AdminUserSummaryPayload | null;
productId: string;
productTitle: string;
productKind: string;
amountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
providerTransactionId?: string | null;
createdAtMicros: number;
pointsDelta: number;
cumulativeSuccessRefundCents: number;
targetRecoveryPoints: number;
recoveredPoints: number;
unrecoveredPoints: number;
recoveryStatus?: string | null;
wallet: AdminProfileWalletPayload;
refunds: AdminRechargeRefundPayload[];
activeHold?: AdminRechargeRefundHoldPayload | null;
remainingRefundableCents: number;
refundEligible: boolean;
refundBlockReasonCode?: string | null;
}
export interface AdminRechargeOrderListResponse {
entries: AdminRechargeOrderEntryPayload[];
}
export interface AdminUserDetailQuery {
userId?: string;
publicUserCode?: string;
}
export interface AdminUserDetailResponse {
userId: string;
publicUserCode: string;
displayName: string;
avatarUrl?: string | null;
phoneNumberMasked?: string | null;
loginMethod: string;
bindingStatus: string;
phoneBound: boolean;
wechatBound: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
}
export interface AdminRechargeRefundPreviewRequest {
orderId: string;
refundAmountCents: number;
}
export interface AdminRechargeRefundExecuteRequest {
orderId: string;
refundAmountCents: number;
requestId: string;
reason?: string | null;
}
export interface AdminRechargeRefundRegisterRequest {
outRefundNo: string;
}
export interface AdminRechargeRefundManualReviewResolveRequest {
outRefundNo: string;
reason: string;
expectedErrorCode: string;
}
export interface AdminWalletRestrictionRequest {
userId: string;
frozen: boolean;
reason: string;
}
export interface AdminWechatPaymentCheckPayload {
verified: boolean;
tradeState: string;
transactionId?: string | null;
amountTotalCents?: number | null;
knownRefundsRefreshed: number;
}
export interface AdminRechargeRefundPreviewResponse {
order: AdminRechargeOrderEntryPayload;
paymentCheck: AdminWechatPaymentCheckPayload;
refundAmountCents: number;
incrementalRecoveryPoints: number;
remainingRefundableCents: number;
canSubmit: boolean;
blockReasonCode?: string | null;
}
export interface AdminRechargeRefundActionResponse {
outRefundNo: string;
providerStatus: string;
resultCode: string;
providerStatusUnknown: boolean;
order: AdminRechargeOrderEntryPayload;
}
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
+78 -19
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {
formatAdminApiError,
@@ -18,6 +18,7 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
@@ -26,16 +27,23 @@ import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
import {
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
type SessionStatus = 'checking' | 'guest' | 'authenticated';
@@ -53,6 +61,13 @@ export function AdminApp() {
useState<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const accessibleRoutes = useMemo(
() => (admin ? getAccessibleAdminRoutes(admin) : []),
[admin],
);
const activeRouteId = accessibleRoutes.some((route) => route.id === routeId)
? routeId
: null;
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
@@ -105,6 +120,26 @@ export function AdminApp() {
};
}, []);
useEffect(() => {
if (status !== 'authenticated' || !admin) {
return;
}
const nextRouteId = resolveAccessibleAdminRoute(
window.location.hash,
accessibleRoutes,
);
if (!nextRouteId) {
return;
}
setRouteId(nextRouteId);
const nextHash = routeHash(nextRouteId);
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
}, [accessibleRoutes, admin, routeId, status]);
useEffect(() => {
const handleHashChange = () => {
setRouteId(resolveAdminRoute(window.location.hash));
@@ -161,63 +196,75 @@ export function AdminApp() {
return (
<AdminShell
admin={admin}
routeId={routeId}
routeId={activeRouteId}
routes={accessibleRoutes}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'dashboard' ? (
{activeRouteId === null ? (
<section className="admin-panel admin-zero-permission-state">
<h2>访</h2>
</section>
) : null}
{activeRouteId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'overview' ? (
{activeRouteId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tables' ? (
{activeRouteId === 'tables' ? (
<AdminDatabaseTablesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'debug' ? (
{activeRouteId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tracking' ? (
{activeRouteId === 'tracking' ? (
<AdminTrackingEventsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'invite' ? (
{activeRouteId === 'invite' ? (
<AdminInviteCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-announcement' ? (
{activeRouteId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-entry' ? (
{activeRouteId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'work-visibility' ? (
{activeRouteId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
{activeRouteId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
@@ -225,7 +272,7 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'profile-wallet' ? (
{activeRouteId === 'profile-wallet' ? (
<AdminProfileWalletConfigPage
result={profileWalletConfigResult}
token={token}
@@ -233,7 +280,7 @@ export function AdminApp() {
onResultChange={setProfileWalletConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
{activeRouteId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
@@ -241,24 +288,36 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-showcase' ? (
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-assets' ? (
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'accounts' ? (
<AdminAccountsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+13 -6
View File
@@ -7,6 +7,7 @@ import {
LogOut,
Megaphone,
Eye,
GitBranch,
Images,
Star,
WalletCards,
@@ -17,16 +18,18 @@ import {
Table2,
TicketCheck,
TicketPercent,
ReceiptText,
Users,
} from 'lucide-react';
import type {ReactNode} from 'react';
import type {AdminSessionPayload} from '../api/adminApiTypes';
import type {AdminRouteId} from './adminRoutes';
import {adminRoutes} from './adminRoutes';
import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
routeId: AdminRouteId;
routeId: AdminRouteId | null;
routes: AdminRouteDefinition[];
children: ReactNode;
onRouteChange: (routeId: AdminRouteId) => void;
onLogout: () => void;
@@ -38,22 +41,26 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
accounts: Users,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
routes,
children,
onRouteChange,
onLogout,
@@ -72,7 +79,7 @@ export function AdminShell({
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
@@ -95,7 +102,7 @@ export function AdminShell({
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<button
className="admin-icon-button"
@@ -112,7 +119,7 @@ export function AdminShell({
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
+60 -1
View File
@@ -1,6 +1,12 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
import {
adminRoutes,
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
@@ -40,6 +46,16 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
);
});
test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'gray-release',
label: '灰度发布',
hash: '#gray-release',
});
expect(resolveAdminRoute('#gray-release')).toBe('gray-release');
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -59,3 +75,46 @@ test('后台精选审核路由可通过导航和 hash 访问', () => {
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
});
test('后台充值管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'recharge-orders',
label: '充值管理',
hash: '#recharge-orders',
});
expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders');
expect(routeHash('recharge-orders')).toBe('#recharge-orders');
});
test('owner 可访问全部业务 Tab 和账号管理', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'owner',
tabPermissions: [],
});
expect(routes).toEqual(adminRoutes);
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
});
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'recharge-orders'],
});
expect(routes.map((route) => route.id)).toEqual([
'tracking',
'recharge-orders',
]);
expect(resolveAccessibleAdminRoute('#accounts', routes)).toBe('tracking');
expect(resolveAccessibleAdminRoute('#recharge-orders', routes)).toBe(
'recharge-orders',
);
});
test('零权限 member 不回落到 Dashboard', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: [],
});
expect(routes).toEqual([]);
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
});
+40 -1
View File
@@ -5,23 +5,29 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'gray-release'
| 'redeem'
| 'invite'
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
| 'work-visibility'
| 'accounts';
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
hash: string;
ownerOnly?: boolean;
}
export const adminRoutes: AdminRouteDefinition[] = [
@@ -30,19 +36,52 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'tables', label: '表查询', hash: '#tables'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
{id: 'gray-release', label: '灰度发布', hash: '#gray-release'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
];
export interface AdminRouteAccess {
accountRole: 'owner' | 'member';
tabPermissions: string[];
}
export function getAccessibleAdminRoutes(
admin: AdminRouteAccess,
): AdminRouteDefinition[] {
if (admin.accountRole === 'owner') {
return adminRoutes;
}
const permissions = new Set(admin.tabPermissions);
return adminRoutes.filter(
(route) => !route.ownerOnly && permissions.has(route.id),
);
}
export function resolveAccessibleAdminRoute(
hash: string,
routes: AdminRouteDefinition[],
): AdminRouteId | null {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
routes.find((route) => route.hash === normalizedHash)?.id ??
routes[0]?.id ??
null
);
}
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
@@ -0,0 +1,214 @@
/* @vitest-environment jsdom */
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminUserDetail,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {AdminUserReferenceButton} from './AdminUserReferenceButton';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminUserDetail: vi.fn(),
isAdminApiError: vi.fn(() => false),
updateAdminWalletRestriction: vi.fn(),
}));
const wallet: AdminProfileWalletPayload = {
userId: 'user-1',
totalBalance: 96,
spendableBalance: 40,
dailyFreePoints: 6,
membershipLimitedPoints: 20,
permanentPoints: 70,
heldPoints: 5,
refundDebtPoints: 25,
manualFrozen: false,
refundDebtFrozen: true,
walletFrozen: true,
manualRestriction: null,
};
const detail: AdminUserDetailResponse = {
userId: 'user-1',
publicUserCode: 'TN1001',
displayName: '陶泥用户',
avatarUrl: 'https://example.com/avatar.png',
phoneNumberMasked: '138****5678',
loginMethod: 'phone',
bindingStatus: 'bound',
phoneBound: true,
wechatBound: true,
wallet,
rechargeOrders: [
{
orderId: 'order-1',
userId: 'user-1',
user: null,
productId: 'points_60',
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
providerTransactionId: 'wx-1',
createdAtMicros: 1_720_000_000_000_000,
pointsDelta: 60,
cumulativeSuccessRefundCents: 300,
targetRecoveryPoints: 30,
recoveredPoints: 5,
unrecoveredPoints: 25,
recoveryStatus: 'shortfall',
wallet,
refunds: [],
activeHold: null,
remainingRefundableCents: 300,
refundEligible: false,
refundBlockReasonCode: 'refund_reconciliation_pending',
},
],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
});
test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => {
const user = userEvent.setup();
const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>
</div>,
);
const trigger = screen.getByRole('button', {name: '查看用户信息'});
await user.click(trigger);
expect(parentClick).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog', {name: '用户详情'})).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: 'user-1',
publicUserCode: undefined,
});
expect(screen.getByText('陶泥用户')).toBeTruthy();
expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('138****5678')).toBeTruthy();
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog', {name: '用户详情'})).toBeNull());
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
publicUserCode="TN1001"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: undefined,
publicUserCode: 'TN1001',
});
});
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
const user = userEvent.setup();
const manuallyFrozenWallet: AdminProfileWalletPayload = {
...wallet,
manualFrozen: true,
walletFrozen: true,
manualRestriction: {
frozen: true,
reason: '风险核查',
createdByAdminUserId: 'admin:root',
createdByAdminDisplayName: '后台负责人',
createdAtMicros: 1_720_000_000_000_000,
updatedByAdminUserId: 'admin:root',
updatedByAdminDisplayName: '后台负责人',
updatedAtMicros: 1_720_000_000_000_000,
},
};
vi.mocked(updateAdminWalletRestriction)
.mockResolvedValueOnce({wallet: manuallyFrozenWallet})
.mockResolvedValueOnce({wallet: {...wallet, manualFrozen: false}});
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '异常登录');
await user.click(screen.getByRole('button', {name: '人工冻结钱包'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(1, 'admin-token', {
userId: 'user-1',
frozen: true,
reason: '异常登录',
});
});
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
expect(screen.queryByText(/admin:root/)).toBeNull();
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
await user.click(screen.getByRole('button', {name: '解除人工冻结'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(2, 'admin-token', {
userId: 'user-1',
frozen: false,
reason: '核查完成',
});
});
expect(screen.getByText('退款欠账限制')).toBeTruthy();
});
test('用户详情读取失败后可以重试', async () => {
const user = userEvent.setup();
vi.mocked(getAdminUserDetail)
.mockRejectedValueOnce(new Error('读取失败'))
.mockResolvedValueOnce(detail);
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
expect(await screen.findByText('读取失败')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '重试'}));
expect(await screen.findByText('陶泥用户')).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,427 @@
import {RefreshCcw, ShieldAlert, UserRound, X} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {
formatAdminApiError,
getAdminUserDetail,
isAdminApiError,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from './useAdminWriteConfirm';
interface AdminUserDetailDialogProps {
token: string;
userId?: string | null;
publicUserCode?: string | null;
onClose: () => void;
onUnauthorized: (message?: string) => void;
}
export function AdminUserDetailDialog({
token,
userId,
publicUserCode,
onClose,
onUnauthorized,
}: AdminUserDetailDialogProps) {
const [detail, setDetail] = useState<AdminUserDetailResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const requestVersionRef = useRef(0);
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
useEffect(() => {
void loadDetail();
return () => {
requestVersionRef.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, userId, publicUserCode]);
useEffect(() => {
closeButtonRef.current?.focus();
}, []);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
event.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isConfirming, isSavingRestriction, onClose]);
async function loadDetail() {
const requestVersion = requestVersionRef.current + 1;
requestVersionRef.current = requestVersion;
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminUserDetail(token, {
userId: userId?.trim() || undefined,
publicUserCode: userId?.trim()
? undefined
: publicUserCode?.trim() || undefined,
});
if (requestVersionRef.current === requestVersion) {
setDetail(response);
}
} catch (error: unknown) {
if (requestVersionRef.current !== requestVersion) {
return;
}
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setErrorMessage(formatAdminApiError(error));
} finally {
if (requestVersionRef.current === requestVersion) {
setIsLoading(false);
}
}
}
async function handleRestrictionChange() {
if (!detail || isSavingRestriction) {
return;
}
const reason = restrictionReason.trim();
if (!reason) {
setErrorMessage('请填写人工冻结操作原因');
return;
}
const nextFrozen = !detail.wallet.manualFrozen;
const action = nextFrozen ? '人工冻结钱包' : '解除人工冻结';
const confirmed = await confirmWrite({
action,
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
});
if (!confirmed) {
return;
}
setIsSavingRestriction(true);
setErrorMessage('');
try {
const response = await updateAdminWalletRestriction(token, {
userId: detail.userId,
frozen: nextFrozen,
reason,
});
setDetail((current) =>
current ? {...current, wallet: response.wallet} : current,
);
setRestrictionReason('');
} catch (error: unknown) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
} else {
setErrorMessage(formatAdminApiError(error));
}
} finally {
setIsSavingRestriction(false);
}
}
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
aria-modal="true"
className="admin-confirm-backdrop admin-user-detail-backdrop"
role="dialog"
aria-labelledby="admin-user-detail-title"
onMouseDown={(event) => {
if (
event.target === event.currentTarget &&
!isSavingRestriction &&
!isConfirming
) {
onClose();
}
}}
>
<section className="admin-detail-panel admin-user-detail-panel">
<div className="admin-panel-heading">
<div>
<h3 id="admin-user-detail-title"></h3>
<span>{detail?.publicUserCode || publicUserCode || userId || '-'}</span>
</div>
<div className="admin-detail-actions">
<button
aria-label="刷新用户信息"
className="admin-ghost-button"
disabled={isLoading}
title="刷新"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
</button>
<button
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction}
title="关闭"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
{isLoading ? (
<div className="admin-user-detail-loading" role="status">
<div className="admin-loading-mark" />
<span></span>
</div>
) : errorMessage && !detail ? (
<div className="admin-user-detail-error">
<div className="admin-alert" role="status">
{errorMessage}
</div>
<button
className="admin-secondary-button"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span></span>
</button>
</div>
) : detail ? (
<>
<UserIdentityHeader detail={detail} />
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<WalletSection wallet={detail.wallet} />
<section className="admin-user-restriction-section">
<div className="admin-panel-heading">
<h3></h3>
<span>
{detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
</span>
</div>
{detail.wallet.manualRestriction ? (
<div className="admin-user-restriction-record">
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
</small>
</div>
) : null}
{detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? (
<div className="admin-alert admin-alert-warning" role="status">
<ShieldAlert size={17} aria-hidden="true" />
<span>退</span>
</div>
) : null}
<div className="admin-user-restriction-actions">
<label className="admin-field admin-field-fill">
<span></span>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
</label>
<button
className={
detail.wallet.manualFrozen
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={isSavingRestriction || !restrictionReason.trim()}
type="button"
onClick={() => void handleRestrictionChange()}
>
<ShieldAlert size={17} aria-hidden="true" />
<span>
{isSavingRestriction
? '处理中'
: detail.wallet.manualFrozen
? '解除人工冻结'
: '人工冻结钱包'}
</span>
</button>
</div>
</section>
<section className="admin-user-recharge-section">
<div className="admin-panel-heading">
<h3></h3>
<span>{detail.rechargeOrders.length} </span>
</div>
{detail.rechargeOrders.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-user-recharge-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
</thead>
<tbody>
{detail.rechargeOrders.map((order) => (
<tr key={order.orderId}>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
</td>
<td>{formatOrderStatus(order.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</>
) : null}
</section>
{confirmDialog}
</div>,
document.body,
);
}
function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
return (
<section className="admin-user-identity">
<div className="admin-user-avatar">
{detail.avatarUrl ? (
<img alt={`${detail.displayName || detail.publicUserCode}头像`} src={detail.avatarUrl} />
) : (
<UserRound size={30} aria-hidden="true" />
)}
</div>
<div className="admin-user-identity-primary">
<strong>{detail.displayName || '未设置昵称'}</strong>
<span>{detail.publicUserCode || '未分配陶泥号'}</span>
</div>
<dl className="admin-info-list admin-user-identity-list">
<div>
<dt> ID</dt>
<dd>{detail.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.phoneNumberMasked || '未绑定'}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{detail.bindingStatus || '-'} / {detail.phoneBound ? '已绑定' : '未绑定'} /
{detail.wechatBound ? '已绑定' : '未绑定'}
</dd>
</div>
</dl>
</section>
);
}
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
const metrics = [
['总余额', wallet.totalBalance],
['可消费', wallet.spendableBalance],
['永久泥点', wallet.permanentPoints],
['每日免费', wallet.dailyFreePoints],
['会员限时', wallet.membershipLimitedPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
] as const;
return (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
) : null}
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok"></span> : null}
</div>
</div>
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
);
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
function formatMicros(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', {
hour12: false,
});
}
function formatOrderStatus(status: string) {
const labels: Record<string, string> = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
closed: '已关闭',
};
return labels[status.toLowerCase()] ?? status;
}
@@ -0,0 +1,73 @@
import {UserRoundSearch} from 'lucide-react';
import {MouseEvent, useRef, useState} from 'react';
import {AdminUserDetailDialog} from './AdminUserDetailDialog';
interface AdminUserReferenceButtonProps {
token: string;
userId?: string | null;
publicUserCode?: string | null;
onUnauthorized: (message?: string) => void;
}
export function AdminUserReferenceButton({
token,
userId,
publicUserCode,
onUnauthorized,
}: AdminUserReferenceButtonProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const normalizedUserId = normalizeUserReference(userId);
const normalizedPublicUserCode = normalizeUserReference(publicUserCode);
const lookup = normalizedUserId
? {userId: normalizedUserId}
: normalizedPublicUserCode
? {publicUserCode: normalizedPublicUserCode}
: null;
if (!lookup) {
return null;
}
function openDialog(event: MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
setOpen(true);
}
function closeDialog() {
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}
return (
<>
<button
ref={triggerRef}
aria-label="查看用户信息"
className="admin-ghost-button admin-user-reference-button"
title="查看用户信息"
type="button"
onClick={openDialog}
>
<UserRoundSearch size={16} aria-hidden="true" />
</button>
{open ? (
<AdminUserDetailDialog
token={token}
{...lookup}
onClose={closeDialog}
onUnauthorized={onUnauthorized}
/>
) : null}
</>
);
}
function normalizeUserReference(value?: string | null) {
const normalized = value?.trim() ?? '';
if (!normalized || normalized.toLowerCase().startsWith('admin:')) {
return '';
}
return normalized;
}
@@ -101,5 +101,9 @@ export function useAdminWriteConfirm() {
</div>
) : null;
return {confirmWrite, confirmDialog};
return {
confirmWrite,
confirmDialog,
isConfirming: pendingConfirm !== null,
};
}
@@ -0,0 +1,305 @@
import {Plus, RefreshCcw, Save} from 'lucide-react';
import {type FormEvent, useEffect, useState} from 'react';
import {
createAdminAccount,
listAdminAccounts,
updateAdminAccount,
} from '../api/adminApiClient';
import type {AdminAccountPayload} from '../api/adminApiTypes';
import {adminRoutes} from '../app/adminRoutes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminAccountsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
export function AdminAccountsPage({
token,
onUnauthorized,
}: AdminAccountsPageProps) {
const [accounts, setAccounts] = useState<AdminAccountPayload[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshAccounts() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminAccounts(token);
setAccounts(response.accounts);
const selected = response.accounts.find(
(account) => account.accountId === selectedAccountId,
);
if (selected) {
fillForm(selected);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function startCreate() {
setSelectedAccountId('');
setUsername('');
setDisplayName('');
setPassword('');
setEnabled(true);
setTabPermissions([]);
setErrorMessage('');
}
function fillForm(account: AdminAccountPayload) {
setSelectedAccountId(account.accountId);
setUsername(account.username);
setDisplayName(account.displayName);
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setErrorMessage('');
}
function togglePermission(permission: string, checked: boolean) {
setTabPermissions((current) =>
checked
? assignableRoutes
.map((route) => route.id)
.filter((routeId) =>
routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedUsername = username.trim();
const normalizedDisplayName = displayName.trim();
if (!selectedAccountId && !normalizedUsername) {
setErrorMessage('请输入用户名');
return;
}
if (!normalizedDisplayName) {
setErrorMessage('请输入显示名称');
return;
}
if (!selectedAccountId && !password) {
setErrorMessage('请输入密码');
return;
}
const confirmed = await confirmWrite({
action: selectedAccountId ? '更新后台账号' : '创建后台账号',
target: normalizedUsername,
});
if (!confirmed) {
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
enabled,
})
: await createAdminAccount(token, {
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
tabPermissions,
enabled,
});
setAccounts((current) => {
const rest = current.filter(
(account) => account.accountId !== response.account.accountId,
);
return [...rest, response.account].sort((left, right) =>
left.username.localeCompare(right.username),
);
});
fillForm(response.account);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span></span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-accounts-layout">
<section className="admin-panel admin-account-list">
<div className="admin-panel-heading">
<h3></h3>
<span>{accounts.length}</span>
</div>
{accounts.length ? (
<div className="admin-account-list-items">
{accounts.map((account) => (
<button
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={account.accountRole === 'owner' ? 'owner' : account.username}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
fillForm(account);
}
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无成员账号'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-panel-heading">
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访</legend>
<div className="admin-permission-grid">
{assignableRoutes.map((route) => (
<label key={route.id}>
<input
checked={tabPermissions.includes(route.id)}
type="checkbox"
onChange={(event) =>
togglePermission(route.id, event.target.checked)
}
/>
<span>{route.label}</span>
</label>
))}
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</div>
{confirmDialog}
</section>
);
}
@@ -1,14 +1,17 @@
/* @vitest-environment jsdom */
import {render, screen, waitFor} from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage';
import {
AdminDatabaseTablesPage,
resolveAdminDatabaseUserReference,
} from './AdminDatabaseTablesPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -19,6 +22,51 @@ vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(() => false),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
userId?: string;
publicUserCode?: string;
}) => (
<button
aria-label={`查看用户 ${userId || publicUserCode}`}
data-public-user-code={publicUserCode}
data-user-id={userId}
type="button"
onClick={(event) => event.stopPropagation()}
/>
),
}));
const referralRows = [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
];
beforeEach(() => {
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
@@ -28,78 +76,187 @@ beforeEach(() => {
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
rows: [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: [
'u-b',
'u-a',
'INV-1001',
'2026-05-02T00:00:00Z',
],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
],
rows: referralRows,
page: 1,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
});
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup();
const {container} = render(
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id'],
limit: 100,
page: 1,
rows: [
{
cells: { invitee_user_id: 'u-b' },
raw: ['u-b'],
},
],
scannedCount: 50000,
scanLimit: 50000,
scanLimitReached: true,
tableName: 'profile_referral_relation',
totalMatched: 250,
totalReturned: 1,
});
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
expect(pagination.closest('.admin-panel')).toBeNull();
expect(
container.querySelector('.admin-database-tables-page')?.lastElementChild,
).toBe(pagination);
expect(
screen.getByText(
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: '',
limit: 100,
page: 2,
search: '',
}),
);
});
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await screen.findByText('2026-05-02 08:00:00');
const tableWrap = container.querySelector('.admin-table-wrap');
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation'}).getAttribute('title')).toBe(
'原始表名:profile_referral_relation。邀请关系记录表。',
);
expect(screen.getByText('已选表:邀请关系(profile_referral_relation')).toBeTruthy();
expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe(
'原始表名:profile_referral_relation。邀请关系记录表。',
);
expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。',
);
expect(
screen
.getByRole('option', { name: '邀请关系(profile_referral_relation' })
.getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByText('已选表:邀请关系(profile_referral_relation'),
).toBeTruthy();
expect(
screen.getByRole('heading', { name: '邀请关系' }).getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'asc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
});
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'desc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
).toEqual({ userId: 'u-1' });
expect(
resolveAdminDatabaseUserReference(
'auth_store_projection',
'public_user_code',
'TN1001',
),
).toEqual({ publicUserCode: 'TN1001' });
expect(
resolveAdminDatabaseUserReference('audit_log', 'operator_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,13 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminAssetReadUrl,
@@ -9,10 +15,11 @@ import {
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
updateAdminEditorShowcaseDisplay,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminEditorShowcaseCampaign,
} from '../api/adminApiClient';
import type {AdminEditorShowcaseAssetPayload} from '../api/adminApiTypes';
import {AdminEditorShowcaseReviewPage} from './AdminEditorShowcaseReviewPage';
import type { AdminEditorShowcaseAssetPayload } from '../api/adminApiTypes';
import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
@@ -20,9 +27,24 @@ vi.mock('../api/adminApiClient', () => ({
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
updateAdminEditorShowcaseDisplay: vi.fn(),
uploadAdminEditorShowcaseCampaignImage: vi.fn(),
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
userId?: string;
publicUserCode?: string | null;
}) => (
<button
aria-label="查看精选作者"
data-public-user-code={publicUserCode ?? undefined}
data-user-id={userId}
type="button"
/>
),
}));
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
showcaseId: 'showcase-1',
assetId: 'asset-1',
@@ -40,7 +62,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
provider: 'character',
taskId: 'task-1',
assetKind: 'character',
generationInputs: {style: 'clay'},
generationInputs: { style: 'clay' },
generationCostMudPoints: 12,
refundMudPoints: 6,
reviewStatus: 'pending',
@@ -56,6 +78,7 @@ const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
approvedAt: null,
rejectedAt: null,
updatedAt: '2026-07-04T10:00:00Z',
showcaseCategory: null,
};
const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
@@ -65,6 +88,7 @@ const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
label: '角色形象 2',
reviewStatus: 'approved',
displayEnabled: true,
showcaseCategory: 'characters',
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
};
@@ -80,6 +104,9 @@ beforeEach(() => {
enabled: true,
title: '活动卡',
imageSrc: '/campaign.png',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
prompt: '活动提示词',
author: '官方',
costText: '12 泥点',
@@ -97,7 +124,7 @@ beforeEach(() => {
entry: {
...pendingShowcaseAsset,
reviewStatus: 'approved',
displayEnabled: true,
displayEnabled: false,
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
},
@@ -113,12 +140,24 @@ beforeEach(() => {
enabled: false,
title: '新活动卡',
imageSrc: '/new-campaign.png',
imageObjectKey: null,
imageWidth: null,
imageHeight: null,
prompt: '新活动提示词',
author: '官方',
costText: '6 泥点',
updatedAt: '2026-07-04T10:20:00Z',
},
});
vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValue({
imageSrc: '/generated-character-drafts/editor/showcase-campaign/card.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/card.png',
imageWidth: 900,
imageHeight: 1200,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/card.png',
});
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
@@ -131,18 +170,77 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
const userButton = screen.getByRole('button', { name: '查看精选作者' });
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('12 泥点')).toBeTruthy();
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
expect(screen.getByLabelText('状态')).toHaveProperty('value', 'pending');
expect(listAdminEditorShowcaseAssets).toHaveBeenCalledWith('admin-token', {
ownerUserId: null,
assetKind: null,
reviewStatus: 'pending',
submittedAfter: null,
submittedBefore: null,
limit: 80,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
submittedAt: '1783231493.573727Z',
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByText('showcase-1')).toBeNull();
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台精选审核音频素材使用统一封面缩略图', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
showcaseId: 'showcase-audio-1',
assetId: 'asset-audio-1',
label: '胜利音效',
imageSrc: '/generated-editor-audios/sfx.mp3',
objectKey: 'generated-editor-audios/sfx.mp3',
assetKind: 'sound-effect',
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', { name: '精选素材:胜利音效' });
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
});
test('后台精选审核可以通过素材并查看完整提示词', async () => {
@@ -153,11 +251,10 @@ test('后台精选审核可以通过素材并查看完整提示词', async () =>
/>,
);
fireEvent.change(
await screen.findByLabelText('审核备注:角色形象 1'),
{target: {value: '质量通过'}},
);
fireEvent.click(screen.getByRole('button', {name: '通过'}));
fireEvent.change(await screen.findByLabelText('审核备注:角色形象 1'), {
target: { value: '质量通过' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() => {
expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', {
@@ -167,8 +264,8 @@ test('后台精选审核可以通过素材并查看完整提示词', async () =>
});
});
fireEvent.click(screen.getByRole('button', {name: '完整提示词内容'}));
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
fireEvent.click(screen.getByRole('button', { name: '完整提示词内容' }));
const dialog = screen.getByRole('dialog', { name: '完整提示词' });
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
});
@@ -185,27 +282,28 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
/>,
);
fireEvent.click(await screen.findByRole('button', {name: '隐藏'}));
fireEvent.click(await screen.findByRole('button', { name: '隐藏' }));
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'characters',
},
);
});
fireEvent.change(await screen.findByLabelText('标题'), {
target: {value: '新活动卡'},
target: { value: '新活动卡' },
});
fireEvent.change(screen.getByLabelText('图片地址'), {
target: {value: '/new-campaign.png'},
target: { value: '/new-campaign.png' },
});
fireEvent.change(screen.getByLabelText('成本文案'), {
target: {value: '6 泥点'},
target: { value: '6 泥点' },
});
fireEvent.click(screen.getByRole('button', {name: '保存活动卡'}));
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
await waitFor(() => {
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
@@ -213,8 +311,164 @@ test('后台精选审核可以切换展示和保存活动卡', async () => {
expect.objectContaining({
title: '新活动卡',
imageSrc: '/new-campaign.png',
imageObjectKey: null,
costText: '6 泥点',
}),
);
});
});
test('后台精选活动卡上传图片时替换图片引用并隐藏 objectKey', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByDisplayValue('活动卡');
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('上传活动卡图片'), {
target: { files: [file] },
});
await waitFor(() => {
expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith(
'admin-token',
file,
);
});
expect(
await screen.findByDisplayValue(
'/generated-character-drafts/editor/showcase-campaign/card.png',
),
).toBeTruthy();
expect(
screen.queryByDisplayValue(
'generated-character-drafts/editor/showcase-campaign/card.png',
),
).toBeNull();
vi.mocked(uploadAdminEditorShowcaseCampaignImage).mockResolvedValueOnce({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/replacement.png',
imageWidth: 1024,
imageHeight: 1536,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
});
const replacementFile = new File(['replacement-bytes'], 'replacement.png', {
type: 'image/png',
});
fireEvent.change(screen.getByLabelText('上传活动卡图片'), {
target: { files: [replacementFile] },
});
await waitFor(() => {
expect(uploadAdminEditorShowcaseCampaignImage).toHaveBeenCalledWith(
'admin-token',
replacementFile,
);
});
expect(
await screen.findByDisplayValue(
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '保存活动卡' }));
await waitFor(() => {
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/replacement.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/replacement.png',
imageWidth: 1024,
imageHeight: 1536,
}),
);
});
});
test('后台精选审核已通过素材可以设置精选分类', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
},
],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: 'ui',
displayEnabled: false,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: { value: 'ui' },
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'ui',
},
);
});
});
test('后台精选审核清空精选分类时不会自动隐藏素材', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [approvedShowcaseAsset],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: true,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: { value: '' },
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: true,
showcaseCategory: '',
},
);
});
});

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