diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1aca900b..ebbeb6d6 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,30 @@ --- +## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 + +- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。 +- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。 +- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。 +- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。 + +## 2026-06-10 公开作品互动能力进入后台全局配置 + +- 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 +- 决策:公开作品点赞 / 改造能力作为 `creation_entry_config.public_work_interactions_json` 的全局矩阵保存,不进入单个 `creation_entry_type_config`。`GET /api/creation-entry/config` 下发 `publicWorkInteractions`;后台通过 `/admin/api/creation-entry/config/interactions` 按 `sourceType` 保存点赞、改造开关和关闭提示;api-server 只对已经接入后端动作的 RPG / custom-world、大鱼吃小鱼和拼图 like / remix 路由做同源熔断,公开列表、详情读取、已发布作品启动和运行态请求不受影响。 +- 影响范围:`CreationEntryConfigResponse`、`AdminCreationEntryConfigResponse`、`module-runtime` 默认矩阵、`spacetime-module` 表字段和 procedure、`spacetime-client` 绑定、后台入口开关页、平台作品详情点赞 / 改造意图解析。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime public_work_interaction_config_defaults_and_overrides --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server public_work_interactions --manifest-path server-rs/Cargo.toml`、后台和前台作品详情互动相关前端测试。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-10 dev Gitea 提供内网 HTTP 入口 + +- 背景:release / dev 目标 agent 需要从 dev 自托管 Gitea 拉取仓库;继续走 `https://git.genarrative.world/...` 会绕公网链路,`10.2.0.10:3000` 又受云侧端口策略影响不能作为稳定入口。 +- 决策:dev 上 Gitea 进程保持 `HTTP_ADDR = 127.0.0.1`、`HTTP_PORT = 3000`,公网 `ROOT_URL = https://git.genarrative.world/` 不变;新增 Nginx 内网 vhost `/etc/nginx/conf.d/gitea-internal.conf`,只允许 `10.2.0.0/16` 与本机访问,并把 `http://10.2.0.10/` 反代到本机 Gitea。内网 agent 统一使用 `http://10.2.0.10/GenarrativeAI/Genarrative.git` 作为可直连 Git 源。 +- 影响范围:dev Gitea / Nginx 运维配置、Jenkins `SOURCE_GIT_REMOTE_URL`、release / dev 目标 agent checkout 口径。 +- 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-06-08 微信能力按领域收口 - 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 @@ -511,6 +535,7 @@ - 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。 - 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。 +- 追加决策(2026-06-10):`Prepare Provision Tools` 必须先读取目标机现状,再准备需要的文件。目标机 `/usr/local/bin/otelcol-contrib` 版本匹配 `OTELCOL_VERSION` 时直接复用;`${SPACETIME_ROOT}/bin/current/spacetimedb-cli` 和 `spacetimedb-standalone` 存在且 CLI 版本匹配 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 中的版本时,直接复用当前安装生成 `provision-tools/`。只有目标机缺失、不可执行或版本不匹配时,才消费 `PROVISION_DOWNLOADS_DIR` 中的本地包或进入下载分支。 - 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。 - 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 @@ -672,7 +697,7 @@ - 背景:Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options,新增玩法容易遗漏同一请求骨架。 - 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。 - 追加决策:Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId`、`runId`)仍留在各玩法 client,通用 Module 只承接请求骨架。 -- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;Puzzle `pause` 与 `props` 仍保留原账号态 auth options,不直接接入 runtime guest auth。 +- 追加决策:Puzzle 的 start / get / swap / drag / next-level / leaderboard / pause / props 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`;只要调用方传入 Runtime Guest Token,所有正式 runtime 请求都统一带局部 Authorization、`skipAuth` 与 `skipRefresh`。 - 追加决策:Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`;Wooden Fish 的 `clientEventId` 生成仍留在木鱼 client,Visual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。 - 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。 - 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 @@ -821,7 +846,7 @@ ## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 - 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。 -- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 +- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo-runtime-hud.webp` 产品 logo 小图;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 - 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。 - 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -2156,3 +2181,11 @@ - 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。 - 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-06-11 资产计费边界改为 fail-closed 并补偿退款 + +- 背景:图片 / 资产生成入口曾在钱包或 SpacetimeDB 预扣费连通性异常时允许继续生成,且失败后同步退款如果遇到 SpacetimeDB 短暂不可用缺少本地补偿;拼图首图后台任务还使用 api-server 进程内 HashSet 互斥,多实例下不能防重复。 +- 决策:暂不实现 token 限流。所有资产生成预扣费改为 fail-closed,预扣费失败直接返回错误;支持 retry 的计费 ledger id 统一包含 HTTP `request_id`,前端静默刷新重试复用同一个 `x-request-id`。生成失败后的退款先同步调用 SpacetimeDB,失败则写入 `wallet-refund-outbox` 本地文件并由后台 worker 重放。拼图首图后台生成互斥改为 SpacetimeDB `puzzle_background_compile_task` 表,使用 `task_id + request_id` 作为 claim id,释放时校验 claim id,避免旧任务误删新租约。 +- 影响范围:`api-server` 资产计费包裹、钱包退款补偿、拼图首图后台生成、`spacetime-module` 拼图 task 表、`spacetime-client` bindings/facade、前端 API request id 复用和后端架构文档。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`node scripts/check-server-rs-ddd-boundaries.mjs`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wallet_refund_outbox`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml asset_operation`、`npm run test -- src/services/apiClient.test.ts`、`npm run check:encoding`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index f34cf07f..706dec37 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -95,6 +95,15 @@ npm run dev:web npm run dev:admin-web ``` +本地 SSH 服务器管理面板: + +```bash +npm run server-manager:panel +``` + +该命令启动 `server-rs/crates/server-manager-panel` 的 egui 桌面工具,从本机 `~/.ssh/config` 读取可用 `Host` alias,支持多服务器健康巡检、可折叠侧边栏和受控 systemd 服务启停。服务操作通过远端 `sudo -n systemctl start|stop|restart ` 执行,目标服务器需要提前配置对应 unit 的免交互 sudo 权限。 +面板启动时会自动注入本机中文字体;如开发机中文仍显示为方块,可设置 `GENARRATIVE_SERVER_PANEL_CJK_FONT=/path/to/font.ttc|index` 指向本机 CJK 字体。 + `npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 2972bbf6..c2e411de 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,22 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 生产冷备份后 API 不能只依赖 SpacetimeDB 自恢复 + +- 现象:release 机器 `03:20` 冷备份后,`spacetimedb.service` 已恢复,但作品列表、创作入口配置或公开 gallery 继续超时 / 502 / 504,`genarrative-api.service` 保持 stopped。 +- 原因:`genarrative-api.service` 配置了 `Requires=spacetimedb.service`,冷备份停止 `spacetimedb.service` 时 API 会被 systemd 依赖关系一并停止;如果 `genarrative-database-backup.service` 只传 `--stop-service spacetimedb.service` 而漏掉 `--restart-service-after genarrative-api.service`,备份脚本只会恢复数据库,不会再拉起 API。 +- 处理:生产冷备份 unit 和发布脚本必须带 `--restart-service-after genarrative-api.service`;仓库用 `npm run check:production-ops` 检查 systemd 模板、API build/deploy 归档和健康巡检链路。现场修复后执行 `systemctl daemon-reload`,但不要为了验证而手动触发冷备份。 +- 验证:`systemctl cat genarrative-database-backup.service` 应包含该参数;`systemctl is-active spacetimedb.service genarrative-api.service nginx.service` 全为 `active`;`curl -fsS http://127.0.0.1:3101/v1/ping`、`/healthz`、`/readyz` 和代表性 `/api/runtime/puzzle/gallery` 均成功。 +- 关联:`deploy/systemd/genarrative-database-backup.service`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## SpacetimeDB 45 秒超时要看 api-server 记录的阶段 + +- 现象:release 上 Nginx 能立刻连到 `api-server`,但 `/api/runtime/*/gallery`、`/api/creation-entry/config` 等请求在约 `GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS` 后返回 `502` / `504`。 +- 原因:旧日志只能看到 HTTP 总耗时和最终状态,无法区分卡在连接池、SDK 建连、等待 `on_connect`、订阅 read model、等待 procedure / reducer 回调还是本地订阅 cache 读取。 +- 处理:`spacetime-client` 内置阶段化健康检查和失败日志;`/readyz` 用 `GENARRATIVE_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS` 短窗口检查 SpacetimeDB 连接租约,业务失败日志包含 `operation_kind`、`operation_name`、`spacetime_stage`、`elapsed_ms`。 +- 验证:`/readyz` 失败时看 `details.spacetime.stage`;业务请求超时时查 `journalctl -u genarrative-api.service` 中同一时间窗口的 `SpacetimeDB client operation failed`,优先按 `pool_acquire`、`connect_build`、`connect_handshake`、`read_model_subscribe`、`procedure_result`、`reducer_result`、`read_cache` 分阶段处理。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/api-server/src/health.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 新建草稿扣费不能和入口卡泥点配置分离 - 现象:后台修改创作入口的 `mudPointCost` 后,入口卡和前置余额提示可能显示新数值,但用户真实钱包流水仍按代码常量扣除。 @@ -1119,6 +1135,8 @@ - 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。 - 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。 - 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。 +- 追加处理:`/api/auth/refresh` 只有明确返回 `401` / `403` 时才代表登录态权威失效,可以清本地 access token 并触发全局 auth 变化;服务器重启、Nginx 502/503/504、浏览器 `Failed to fetch` 或 refresh 响应契约异常都属于暂时不可用,不能把已有本地 token 清掉,否则重启窗口会把所有打开页面踢成未登录。 +- 契约:`/api/auth/refresh` 成功响应按共享契约 `RefreshSessionResponse { token }` 解析;测试 mock 不要额外塞 `{ ok: true, token }` 遮住真实恢复路径。 - 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。 - 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。 @@ -1131,6 +1149,7 @@ - 追加处理:generated 私有图片换签 `/api/assets/read-url` 也属于展示层后台请求;推荐页拼图运行态挂载后会立即解析封面图,若换签 401 触发全局鉴权事件,也会表现成“进入拼图作品后瞬间未登录”。资源换签失败只应让当前图片为空,不应清 token、广播 auth 事件或主动 refresh。 - 追加处理:从推荐页点进公开拼图作品并启动完整运行态后,`startPuzzleRun`、通关自动 `submitPuzzleLeaderboard`、下一关 `advancePuzzleNextLevel` 和重开同样属于当前玩法局部同步;这些请求失败时只应留在拼图错误态,不应清 token 或广播 auth 事件。 - 追加处理:通关后 `refreshSaveArchives()`、首屏 bootstrap 的个人看板/作品架/浏览历史读写也只是平台投影刷新,失败应显示局部错误,不能充当全局登录态判定。 +- 追加处理:未登录推荐页启动任一公开正式玩法时,`/api/runtime/*` 局内路由必须使用 `RuntimePrincipal`,前端通过 `PlatformEntryFlowShellImpl` 的统一 request options helper 给 start / checkpoint / finish / input / drop / click / restart / time-up / leaderboard / next-level 等动作透传 runtime guest token;公开 runtime detail 读取如跳一跳、敲木鱼必须显式 `skipAuth/skipRefresh`,匿名推荐流不能补读受保护创作详情,否则会在真正开局前打出 `/api/auth/refresh 401`。 - 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。 - 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。 @@ -1314,6 +1333,14 @@ - 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## Server-Provision 不要无条件下载工具包 + +- 现象:目标 dev / release 机器已经安装正确版本的 SpacetimeDB 或 `otelcol-contrib`,但 `Prepare Provision Tools` 仍每次下载 release tarball,网络慢或 GitHub 不稳时会把服务器初始化卡在准备阶段。 +- 原因:工具准备阶段如果只按“生成交付包”理解,会忽略它已经运行在目标部署 agent 上这一事实;此时目标机本地的 `/usr/local/bin/otelcol-contrib` 与 `${SPACETIME_ROOT}/bin/current` 就是可信状态源。 +- 处理:`scripts/prepare-server-provision-tools.sh` 必须先检查目标机状态:`otelcol-contrib --version` 命中 `OTELCOL_VERSION` 时复制现有二进制;`spacetimedb-cli --version` 命中 `SPACETIME_EXPECTED_VERSION` 或 `SPACETIME_DOWNLOAD_ROOT` 推导出的版本且 standalone 同时存在时,复制 `${SPACETIME_ROOT}/bin` 并生成 wrapper。只有缺失、不可执行或版本不匹配时,才查 `PROVISION_DOWNLOADS_DIR` 或下载源。 +- 验证:运行 `bash scripts/check-server-provision-tools.sh`;Jenkins 日志应先出现“检查目标机 ...”,已有版本命中时出现“复用目标机已有 ...”,且不出现“下载 ...”。 +- 关联:`scripts/prepare-server-provision-tools.sh`、`jenkins/Jenkinsfile.production-server-provision`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 个人任务 scope 不得扩成 work/site/module - 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。 @@ -2163,3 +2190,11 @@ - 待处理:将跳一跳生成改为后端任务化 / 可轮询真实阶段进度,按背景、返回按钮、图集、切片、持久化、写草稿分阶段落库;统一后端全局生成 deadline、VectorEngine 重试预算、前端等待窗口和失败态回写。超时后再次进入同一 session 应优先恢复正在运行或已完成的任务,不应重复生图。 - 验证:模拟首张 image2 超长耗时或超时重试时,生成页应显示真实阶段和可恢复状态;前端请求超时不应把最终成功草稿标记为失败;刷新 `/creation/jump-hop/generating?sessionId=` 后应能恢复到后端真实状态;同一 session 重试不得重复生成已完成阶段。 - 关联:`src/services/jump-hop/jumpHopClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/jump_hop.rs`、`server-rs/crates/platform-image/src/vector_engine/client.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## SpacetimeDB 连接池租约必须有 Drop 兜底,acquire 不允许无界自旋 + +- 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。 +- 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。 +- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。 +- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index ef176285..0a3a1792 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -20,6 +20,7 @@ import type { AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, + AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, ApiErrorEnvelope, ApiMeta, @@ -129,16 +130,16 @@ export async function request( export function loginAdmin(username: string, password: string) { return request('/admin/api/login', { method: 'POST', - body: {username, password}, + body: { username, password }, }); } export function getAdminMe(token: string) { - return request('/admin/api/me', {token}); + return request('/admin/api/me', { token }); } export function getAdminOverview(token: string) { - return request('/admin/api/overview', {token}); + return request('/admin/api/overview', { token }); } export function getAdminDatabaseTables(token: string) { @@ -154,7 +155,7 @@ export function getAdminDatabaseTableRows( ) { return request( `/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`, - {token}, + { token }, ); } @@ -172,15 +173,14 @@ export function listAdminTrackingEvents( ) { return request( `/admin/api/tracking/events${buildQueryString(query)}`, - {token}, + { token }, ); } - export function getAdminCreationEntryConfig(token: string) { return request( '/admin/api/creation-entry/config', - {token}, + { token }, ); } @@ -213,10 +213,25 @@ export function upsertAdminCreationEntryBanners( ); } +/** 保存公开作品详情页点赞 / 改造能力配置。 */ +export function upsertAdminPublicWorkInteractions( + token: string, + payload: AdminUpsertPublicWorkInteractionConfigRequest, +) { + return request( + '/admin/api/creation-entry/config/interactions', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminWorkVisibility(token: string) { return request( '/admin/api/works/visibility', - {token}, + { token }, ); } @@ -237,7 +252,7 @@ export function updateAdminWorkVisibility( export function listProfileRedeemCodes(token: string) { return request( '/admin/api/profile/redeem-codes', - {token}, + { token }, ); } @@ -258,7 +273,7 @@ export function upsertProfileRedeemCode( export function listProfileInviteCodes(token: string) { return request( '/admin/api/profile/invite-codes', - {token}, + { token }, ); } @@ -293,7 +308,7 @@ export function disableProfileRedeemCode( export function listProfileTaskConfigs(token: string) { return request( '/admin/api/profile/tasks', - {token}, + { token }, ); } @@ -325,7 +340,7 @@ export function disableProfileTaskConfig( export function listProfileRechargeProducts(token: string) { return request( '/admin/api/profile/recharge-products', - {token}, + { token }, ); } @@ -414,13 +429,13 @@ function buildAdminApiError( ) { const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null; const errorPayload = envelope?.error; - const details = isRecord(errorPayload?.details) - ? errorPayload.details - : null; + const details = isRecord(errorPayload?.details) ? errorPayload.details : null; const detailsMessage = typeof details?.message === 'string' ? details.message.trim() : ''; const payloadMessage = - typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : ''; + typeof errorPayload?.message === 'string' + ? errorPayload.message.trim() + : ''; const topLevelMessage = typeof envelope?.message === 'string' ? envelope.message.trim() : ''; const message = diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 2eb58477..d3cb8ebe 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -107,12 +107,7 @@ export interface AdminDebugHeaderInput { value: string; } -export type AdminDebugHttpMethod = - | 'GET' - | 'POST' - | 'PUT' - | 'PATCH' - | 'DELETE'; +export type AdminDebugHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; export interface AdminDebugHttpRequest { method: AdminDebugHttpMethod; @@ -143,11 +138,11 @@ export interface AdminTrackingEventListQuery { limit?: number; } - /** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */ export interface AdminCreationEntryConfigResponse { entries: AdminCreationEntryTypeConfigPayload[]; eventBanners: AdminCreationEntryEventBannerPayload[]; + publicWorkInteractions: PublicWorkInteractionConfigPayload[]; } /** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */ @@ -201,6 +196,20 @@ export interface AdminUpsertCreationEntryEventBannersRequest { eventBannersJson: string; } +/** 后台公开作品详情页互动能力配置项。 */ +export interface PublicWorkInteractionConfigPayload { + sourceType: string; + likeEnabled: boolean; + remixEnabled: boolean; + likeDisabledMessage: string; + remixDisabledMessage: string; +} + +/** 后台保存公开作品点赞 / 改造能力配置请求体。 */ +export interface AdminUpsertPublicWorkInteractionConfigRequest { + publicWorkInteractions: PublicWorkInteractionConfigPayload[]; +} + /** 后台统一创作工作台契约表单的传输结构。 */ export interface UnifiedCreationSpecPayload { playId: string; diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx index 4400b5da..0022c263 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx @@ -1,19 +1,26 @@ /* @vitest-environment jsdom */ -import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} 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 { getAdminCreationEntryConfig, upsertAdminCreationEntryBanners, upsertAdminCreationEntryConfig, + upsertAdminPublicWorkInteractions, } from '../api/adminApiClient'; import type { AdminCreationEntryConfigResponse, UnifiedCreationSpecPayload, } from '../api/adminApiTypes'; -import {AdminCreationEntrySwitchPage} from './AdminCreationEntrySwitchPage'; +import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => @@ -23,6 +30,7 @@ vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn(() => false), upsertAdminCreationEntryBanners: vi.fn(), upsertAdminCreationEntryConfig: vi.fn(), + upsertAdminPublicWorkInteractions: vi.fn(), })); const puzzleSpec: UnifiedCreationSpecPayload = { @@ -55,6 +63,15 @@ const configResponse: AdminCreationEntryConfigResponse = { htmlCode: '
后台公告
', }, ], + publicWorkInteractions: [ + { + sourceType: 'puzzle', + likeEnabled: true, + remixEnabled: true, + likeDisabledMessage: '拼图点赞暂不可用。', + remixDisabledMessage: '拼图作品改造暂不可用。', + }, + ], entries: [ { id: 'puzzle', @@ -79,16 +96,24 @@ beforeEach(() => { vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse); vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse); vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse); + vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue( + configResponse, + ); }); test('创作入口后台展示并保存统一创作契约', async () => { const user = userEvent.setup(); - const {container} = render( - , + const { container } = render( + , ); await screen.findByText('pictureDescription'); - expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull(); + expect( + container.querySelector('.admin-subsection .admin-info-list'), + ).not.toBeNull(); expect( container.querySelector('.admin-subsection .admin-info-list')?.textContent, ).toContain('拼图'); @@ -97,22 +122,22 @@ test('创作入口后台展示并保存统一创作契约', async () => { expect(screen.queryByLabelText('契约 JSON')).toBeNull(); expect(screen.queryByText('puzzle-generating')).toBeNull(); - await user.click(screen.getByRole('button', {name: '修改契约'})); - const dialog = screen.getByRole('dialog', {name: '统一创作契约'}); + await user.click(screen.getByRole('button', { name: '修改契约' })); + const dialog = screen.getByRole('dialog', { name: '统一创作契约' }); expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull(); expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull(); expect(within(dialog).queryByLabelText('生成阶段')).toBeNull(); expect(within(dialog).queryByLabelText('结果阶段')).toBeNull(); fireEvent.change(within(dialog).getByLabelText('泥点消耗'), { - target: {value: '12'}, + target: { value: '12' }, }); - await user.click(within(dialog).getByRole('button', {name: '应用修改'})); + await user.click(within(dialog).getByRole('button', { name: '应用修改' })); - expect(screen.queryByRole('dialog', {name: '统一创作契约'})).toBeNull(); + expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull(); expect(screen.getByText('12泥点数')).toBeTruthy(); - await user.click(screen.getByRole('button', {name: '保存入库'})); - await user.click(screen.getByRole('button', {name: '确认'})); + await user.click(screen.getByRole('button', { name: '保存入库' })); + await user.click(screen.getByRole('button', { name: '确认' })); await waitFor(() => { expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith( @@ -150,9 +175,11 @@ test('创作入口后台拒绝 playId 不一致的统一创作契约', async () ); await screen.findByText('pictureDescription'); - await user.click(screen.getByRole('button', {name: '保存入库'})); + await user.click(screen.getByRole('button', { name: '保存入库' })); - expect(await screen.findByText('统一创作契约 playId 必须与入口 ID 一致')).toBeTruthy(); + expect( + await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'), + ).toBeTruthy(); expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled(); }); @@ -166,23 +193,25 @@ test('创作入口后台用表单保存公告配置', async () => { />, ); - expect(await screen.findAllByRole('heading', {name: '创作入口公告'})).toHaveLength(2); + expect( + await screen.findAllByRole('heading', { name: '创作入口公告' }), + ).toHaveLength(2); expect(screen.queryByLabelText('公告代码 JSON')).toBeNull(); fireEvent.change(await screen.findByLabelText('公告 1 标题'), { - target: {value: '周末创作赛'}, + target: { value: '周末创作赛' }, }); fireEvent.change(screen.getByLabelText('公告 1 HTML'), { - target: {value: '
新的入口公告
'}, + target: { value: '
新的入口公告
' }, }); - await user.click(screen.getByRole('button', {name: '新增公告'})); + await user.click(screen.getByRole('button', { name: '新增公告' })); fireEvent.change(screen.getByLabelText('公告 2 标题'), { - target: {value: '第二条公告'}, + target: { value: '第二条公告' }, }); fireEvent.change(screen.getByLabelText('公告 2 HTML'), { - target: {value: '
轮播第二条
'}, + target: { value: '
轮播第二条
' }, }); - await user.click(screen.getByRole('button', {name: '保存公告'})); - await user.click(screen.getByRole('button', {name: '确认'})); + await user.click(screen.getByRole('button', { name: '保存公告' })); + await user.click(screen.getByRole('button', { name: '确认' })); await waitFor(() => { expect(upsertAdminCreationEntryBanners).toHaveBeenCalled(); @@ -206,6 +235,42 @@ test('创作入口后台用表单保存公告配置', async () => { ); }); +test('创作入口后台用表单保存作品互动配置', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByText('作品互动'); + const likeToggle = screen.getAllByRole('checkbox')[0]!; + await user.click(likeToggle); + fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), { + target: { value: '拼图点赞维护中。' }, + }); + await user.click(screen.getByRole('button', { name: '保存作品互动' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith( + 'admin-token', + { + publicWorkInteractions: [ + { + sourceType: 'puzzle', + likeEnabled: false, + remixEnabled: true, + likeDisabledMessage: '拼图点赞维护中。', + remixDisabledMessage: '拼图作品改造暂不可用。', + }, + ], + }, + ); + }); +}); + test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => { vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({ ...configResponse, @@ -251,12 +316,12 @@ test('创作入口后台拒绝空公告表单', async () => { ); fireEvent.change(await screen.findByLabelText('公告 1 标题'), { - target: {value: ''}, + target: { value: '' }, }); fireEvent.change(screen.getByLabelText('公告 1 HTML'), { - target: {value: ''}, + target: { value: '' }, }); - await user.click(screen.getByRole('button', {name: '保存公告'})); + await user.click(screen.getByRole('button', { name: '保存公告' })); expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy(); expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled(); diff --git a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx index e00c848b..8a674650 100644 --- a/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx +++ b/apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx @@ -5,10 +5,12 @@ import { getAdminCreationEntryConfig, upsertAdminCreationEntryBanners, upsertAdminCreationEntryConfig, + upsertAdminPublicWorkInteractions, } from '../api/adminApiClient'; import type { AdminCreationEntryEventBannerPayload, AdminCreationEntryTypeConfigPayload, + PublicWorkInteractionConfigPayload, UnifiedCreationFieldPayload, UnifiedCreationSpecPayload, } from '../api/adminApiTypes'; @@ -129,14 +131,28 @@ const DEFAULT_UNIFIED_CREATION_STAGE_MAP: Record< let announcementFormItemSequence = 0; let unifiedCreationSpecFieldSequence = 0; +const PUBLIC_WORK_SOURCE_LABELS: Record = { + 'custom-world': 'RPG', + 'big-fish': '摸鱼', + puzzle: '拼图', + 'puzzle-clear': '拼消消', + 'jump-hop': '跳一跳', + 'wooden-fish': '敲木鱼', + match3d: '抓大鹅', + 'square-hole': '方洞挑战', + 'visual-novel': '视觉小说', + 'bark-battle': '汪汪声浪', + edutainment: '宝贝识物', +}; + export function AdminCreationEntrySwitchPage({ token, onUnauthorized, mode = 'switches', }: AdminCreationEntrySwitchPageProps) { - const [entries, setEntries] = useState< - AdminCreationEntryTypeConfigPayload[] - >([]); + const [entries, setEntries] = useState( + [], + ); const [selectedId, setSelectedId] = useState('puzzle'); const [title, setTitle] = useState(''); const [subtitle, setSubtitle] = useState(''); @@ -157,12 +173,17 @@ export function AdminCreationEntrySwitchPage({ const [announcementItems, setAnnouncementItems] = useState< AnnouncementFormItem[] >([]); + const [publicWorkInteractions, setPublicWorkInteractions] = useState< + PublicWorkInteractionConfigPayload[] + >([]); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isSavingBanners, setIsSavingBanners] = useState(false); + const [isSavingInteractions, setIsSavingInteractions] = useState(false); const [listErrorMessage, setListErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [bannerErrorMessage, setBannerErrorMessage] = useState(''); + const [interactionErrorMessage, setInteractionErrorMessage] = useState(''); const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); const isAnnouncementMode = mode === 'announcements'; @@ -179,6 +200,7 @@ export function AdminCreationEntrySwitchPage({ const nextEntries = sortEntries(response.entries); setEntries(nextEntries); setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); + setPublicWorkInteractions(response.publicWorkInteractions ?? []); fillForm( nextEntries.find((entry) => entry.id === selectedId) ?? nextEntries[0] ?? @@ -234,6 +256,7 @@ export function AdminCreationEntrySwitchPage({ const nextEntries = sortEntries(response.entries); setEntries(nextEntries); setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); + setPublicWorkInteractions(response.publicWorkInteractions ?? []); fillForm(nextEntries.find((entry) => entry.id === targetId) ?? null); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -269,6 +292,7 @@ export function AdminCreationEntrySwitchPage({ }); setEntries(sortEntries(response.entries)); setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); + setPublicWorkInteractions(response.publicWorkInteractions ?? []); } catch (error: unknown) { handlePageError(error, onUnauthorized, setBannerErrorMessage); } finally { @@ -276,6 +300,41 @@ export function AdminCreationEntrySwitchPage({ } } + /** 保存公开作品详情页点赞 / 改造能力开关。 */ + async function handleSavePublicWorkInteractions() { + if (isSavingInteractions) { + return; + } + + setInteractionErrorMessage(''); + const confirmed = await confirmWrite({ + action: '保存作品互动配置', + target: 'public-work-interactions', + }); + if (!confirmed) { + return; + } + + setIsSavingInteractions(true); + try { + const response = await upsertAdminPublicWorkInteractions(token, { + publicWorkInteractions: publicWorkInteractions.map((item) => ({ + ...item, + sourceType: item.sourceType.trim(), + likeDisabledMessage: item.likeDisabledMessage.trim(), + remixDisabledMessage: item.remixDisabledMessage.trim(), + })), + }); + setEntries(sortEntries(response.entries)); + setAnnouncementItems(formatEventBannersFormItems(response.eventBanners)); + setPublicWorkInteractions(response.publicWorkInteractions ?? []); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setInteractionErrorMessage); + } finally { + setIsSavingInteractions(false); + } + } + function fillForm(entry: AdminCreationEntryTypeConfigPayload | null) { if (!entry) { return; @@ -361,7 +420,10 @@ export function AdminCreationEntrySwitchPage({ currentForm ? { ...currentForm, - fields: [...currentForm.fields, createUnifiedCreationSpecFieldFormItem()], + fields: [ + ...currentForm.fields, + createUnifiedCreationSpecFieldFormItem(), + ], } : currentForm, ); @@ -377,7 +439,10 @@ export function AdminCreationEntrySwitchPage({ ); return { ...currentForm, - fields: fields.length > 0 ? fields : [createUnifiedCreationSpecFieldFormItem()], + fields: + fields.length > 0 + ? fields + : [createUnifiedCreationSpecFieldFormItem()], }; }); } @@ -414,6 +479,26 @@ export function AdminCreationEntrySwitchPage({ }); } + /** 更新单条公开作品互动配置。 */ + function updatePublicWorkInteraction( + index: number, + patch: Partial< + Pick< + PublicWorkInteractionConfigPayload, + | 'likeEnabled' + | 'remixEnabled' + | 'likeDisabledMessage' + | 'remixDisabledMessage' + > + >, + ) { + setPublicWorkInteractions((currentItems) => + currentItems.map((item, itemIndex) => + itemIndex === index ? { ...item, ...patch } : item, + ), + ); + } + return (
@@ -515,191 +600,288 @@ export function AdminCreationEntrySwitchPage({ ) : null} {!isAnnouncementMode ? ( -
-
-
- - - + <> +
+
+

作品互动

+ {`${publicWorkInteractions.length} 类`}
- -
- - -
- - - - - - - -
- - -
- - - -
-
- 统一创作契约 - - {unifiedCreationSpec ? '已配置' : '未配置'} - -
-
- - {unifiedCreationSpec ? ( - - ) : null} -
- {unifiedCreationSpec ? ( - - ) : ( -
未配置统一创作页契约
- )} -
- - {errorMessage ? ( -
- {errorMessage} -
- ) : null} - -
- -
- - -
- - - - - - + + + + + - {entries.map((entry) => ( - + {publicWorkInteractions.map((item, index) => ( + + + + + - - - - - ))}
入口展示开放统一契约分类排序作品类型点赞点赞关闭提示改造改造关闭提示
{formatPublicWorkSourceLabel(item.sourceType)} - + + + + updatePublicWorkInteraction(index, { + likeDisabledMessage: event.target.value, + }) + } + /> + + + + + updatePublicWorkInteraction(index, { + remixDisabledMessage: event.target.value, + }) + } + /> {entry.visible ? '是' : '否'}{entry.open ? '是' : '否'}{entry.unifiedCreationSpec ? '是' : '否'}{entry.categoryLabel || entry.categoryId}{entry.sortOrder}
+ {interactionErrorMessage ? ( +
+ {interactionErrorMessage} +
+ ) : null} +
+ +
-
+ +
+
+
+ + + +
+ +
+ + +
+ + + + + + + +
+ + +
+ + + +
+
+ 统一创作契约 + {unifiedCreationSpec ? '已配置' : '未配置'} +
+
+ + {unifiedCreationSpec ? ( + + ) : null} +
+ {unifiedCreationSpec ? ( + + ) : ( +
未配置统一创作页契约
+ )} +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ +
+
+ +
+
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
入口展示开放统一契约分类排序
+ + {entry.visible ? '是' : '否'}{entry.open ? '是' : '否'}{entry.unifiedCreationSpec ? '是' : '否'}{entry.categoryLabel || entry.categoryId}{entry.sortOrder}
+
+
+
+ ) : null} {confirmDialog} @@ -776,7 +958,10 @@ export function AdminCreationEntrySwitchPage({
{unifiedCreationSpecForm.fields.map((field, index) => ( -
+
{`字段 ${index + 1}`}