feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #46
This commit is contained in:
2026-08-31 23:17:23 +08:00
parent ab21344773
commit 4ed81f0030
30 changed files with 3527 additions and 91 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-08-29 - **更新时间**2026-08-31
- **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment` - **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY``BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixtureup-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY``BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。
15. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md` 15. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。issue 46 已提供 `bat-api` 内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json` 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
@@ -217,12 +217,12 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
| 角色 | 所有者 | 状态 | | 角色 | 所有者 | 状态 |
|---|---|---| |---|---|---|
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 | | 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness | | 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard |
| daemon RPC client | `internal/backendrpc` | 完成 | | daemon RPC client | `internal/backendrpc` | 完成 |
| 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 | | 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 |
| FFI | `internal/ffi` | 可选 | | FFI | `internal/ffi` | 可选 |
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 | | 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
| Web | `web/` | 空(G-010 | | Web | `web/` | 内嵌 dashboard MVPissue #46);完整协作后台仍未完成 |
默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI)。 默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI)。
@@ -256,7 +256,7 @@ make check-docs
- 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。 - 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。
- `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke``/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。 - `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke``/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。
- WebG-010)。 - 完整 Web 协作后台G-010 剩余部分)。
--- ---
@@ -271,7 +271,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch --watch
``` ```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh``schedule.*``task.*` 查询/取消、`daemon.logs``parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update``translation.worker.run``translation.proofread``localized.publish``localized.rollback` 可经 dashboard 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。
生产要求: 生产要求:
@@ -292,8 +292,8 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
下一阶段必须优先完成: 下一阶段必须优先完成:
1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/restart/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`schedule.*`list/add/update/remove/run)、`parse.*`status/text_units/errors)、`translation.*`tasks/handoff/task.update/proofread/worker.run)、`localized.*`status/publish/rollback)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:通用 manifest 驱动发布、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/restart/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`schedule.*`list/add/update/remove/run)、`parse.*`status/text_units/errors)、`translation.*`tasks/handoff/task.update/proofread/worker.run)、`localized.*`status/publish/rollback)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:通用 manifest 驱动发布、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、translation admin 查询/控制、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。 2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制、内嵌 dashboard、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。 3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,且支持人工校对从当前 TextUnit 索引提交 `translation_results``translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。
4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。 4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。
5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。 5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。
6. 通用 Binary/JSON/Text Patch 基础已落地;受支持 localized patch 发布/rollback 已具备回归测试,复杂 AssetBundle 重打包、通用 manifest 发布和真实翻译记忆仍后置。 6. 通用 Binary/JSON/Text Patch 基础已落地;受支持 localized patch 发布/rollback 已具备回归测试,复杂 AssetBundle 重打包、通用 manifest 发布和真实翻译记忆仍后置。
@@ -314,5 +314,5 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
--- ---
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。 - **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包与通用 manifest 发布未完成。 - **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包、完整 Web 协作后台与通用 manifest 发布未完成。
- **下一工程里程碑**:翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。 - **下一工程里程碑**:翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。
+4 -4
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引 # BlueArchiveToolkit 文档索引
- **更新时间**2026-08-29 - **更新时间**2026-08-31
- **说明**:本索引用于快速定位当前权威文档和历史资料。 - **说明**:本索引用于快速定位当前权威文档和历史资料。
--- ---
@@ -15,7 +15,7 @@
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/guides/bat-api-local-live-smoke.md`:同机 Rust `bat` / Go `bat-api` live socket 联调 runbook。 - `docs/guides/bat-api-local-live-smoke.md`:同机 Rust `bat` / Go `bat-api` live socket 联调 runbook。
- `docs/guides/bat-workflows.md`Rust `bat``res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。 - `docs/guides/bat-workflows.md`Rust `bat``res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` dashboard 接口。
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
- `docs/architecture/resource-release-layout.md`release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。 - `docs/architecture/resource-release-layout.md`release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
- `docs/architecture/assetbundle.md`AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。 - `docs/architecture/assetbundle.md`AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。
@@ -31,7 +31,7 @@
- `docs/architecture/README.md`:总体架构设计。 - `docs/architecture/README.md`:总体架构设计。
- `docs/api/README.md`API 设计入口。 - `docs/api/README.md`API 设计入口。
- `api/openapi/bat-api.yaml`:当前 `bat-api` 资源 bootstrap/分发 HTTP OpenAPI 静态规范。 - `api/openapi/bat-api.yaml`:当前 `bat-api` 资源 bootstrap/分发、内嵌 dashboard 和管理控制面 HTTP OpenAPI 静态规范。
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。 - `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `docs/guides/development.md`:开发指南。 - `docs/guides/development.md`:开发指南。
- `docs/guides/deployment.md`:部署指南。 - `docs/guides/deployment.md`:部署指南。
@@ -112,7 +112,7 @@
- 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`Patch 发布完成后才进入 `localized` - 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`Patch 发布完成后才进入 `localized`
- 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。 - 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。 - `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
- `cmd/bat-api` 资源分发 HTTP MVP(进度见 `docs/reports/GO_STATUS.md`)。 - `cmd/bat-api` 资源分发 HTTP MVP 与内嵌 dashboard(进度见 `docs/reports/GO_STATUS.md`)。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。 - 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat-api` 同机 live smoke 已固化为 `scripts/bat-api-local-live-smoke.sh``make bat-api-local-live-smoke`,覆盖真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径。 - `bat-api` 同机 live smoke 已固化为 `scripts/bat-api-local-live-smoke.sh``make bat-api-local-live-smoke`,覆盖真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径。
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。 - `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
+8 -8
View File
@@ -36,19 +36,19 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
11. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;issue 44 已接入 `translation.worker.run` provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;schedule CRUD、`translation.tasks` / `translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 已经通过 `bat.sock``bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 11. issue 43 已补齐 Rust `bat``res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;issue 44 已接入 `translation.worker.run` provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;issue 46 已提供 `bat-api` 内嵌 dashboard MVP,直接调用已有鉴权接口控制资源、调度、任务、日志、parse、翻译和 localized 发布/回滚;schedule CRUD、`translation.tasks` / `translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 已经通过 `bat.sock``bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。
12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
### 仍是骨架或占位 ### 仍是骨架或占位
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。 1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。 2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。 3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。 5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。 6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。 8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。 9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
### 已验证 ### 已验证
@@ -340,12 +340,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### Milestone 10Web 管理后台 ### Milestone 10Web 管理后台
**目标**:为翻译协作和资源管理提供可用后台。 **目标**在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。
交付物: 交付物:
1. 登录、权限、用户角色。 1. 登录、权限、用户角色。
2. Dashboard:同步状态、翻译进度、质量问题、队列状态。 2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制
3. 翻译审核:列表、详情、Diff、批量操作。 3. 翻译审核:列表、详情、Diff、批量操作。
4. 术语管理:搜索、冲突提示、审核。 4. 术语管理:搜索、冲突提示、审核。
5. 资源浏览:版本、资源、Bundle、文本定位。 5. 资源浏览:版本、资源、Bundle、文本定位。
@@ -384,7 +384,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、真实官方资源长期运行验证,以及 provider worker 近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是翻译记忆、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证
建议顺序: 建议顺序:
@@ -392,7 +392,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 完成 Milestone 5,再开始翻译系统。 2. 完成 Milestone 5,再开始翻译系统。
3. 完成 Milestone 6 和 7,建立可审计翻译流程。 3. 完成 Milestone 6 和 7,建立可审计翻译流程。
4. 完成 Milestone 8,形成可交付补丁。 4. 完成 Milestone 8,形成可交付补丁。
5. 最后补齐 CLI/API/Web/发布工程。 5. 最后补齐完整 CLI/API/Web 协作后台和发布工程。
--- ---
@@ -459,7 +459,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。 按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
--- ---
+9 -9
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写CDN path 只读分发);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、Web、AssetBundle 引擎、翻译和 Patch 仍在后续阶段。 当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台、复杂 AssetBundle 重打包、翻译记忆和通用 Patch 发布仍在后续阶段。
--- ---
@@ -15,7 +15,7 @@
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*``resource.*``parse.*``translation.tasks/handoff/task.update``localized.status``catalog.*``task.*``patch.apply``unityfs.patch_*`)。 - `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*``resource.*``parse.*``translation.tasks/handoff/task.update``localized.status``catalog.*``task.*``patch.apply``unityfs.patch_*`)。
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。 - `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单translation admin 查询/控制入口;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。 - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation admin 查询控制入口和无构建内嵌 dashboard`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)G-008 已关闭:同步 CLI = Rust `bat`)。 - Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)G-008 已关闭:同步 CLI = Rust `bat`)。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。 - 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。
@@ -27,11 +27,11 @@
仍未完成: 仍未完成:
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面launcher 资源引导兼容已可用。 - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。 - 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。
- Translation Memory、Glossary、AI Provider。 - Translation Memory、Glossary、AI Provider。
- SDK、Web 管理后台。 - SDK、完整 Web 协作后台。
详细状态见: 详细状态见:
@@ -143,11 +143,11 @@ make official-smoke
## 技术栈 ## 技术栈
- RustCAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。 - RustCAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。
- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。 - Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务、内嵌 dashboard `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。
- `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。 - `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。
- PostgreSQL:计划中的服务端主数据库。 - PostgreSQL:计划中的服务端主数据库。
- Redis:计划中的缓存、队列状态、限流和短期锁。 - Redis:计划中的缓存、队列状态、限流和短期锁。
- Vue 3 + TypeScript:计划中的 Web 管理后台 - Vue 3 + TypeScript:计划中的完整 Web 协作后台;当前已先提供无构建内嵌 dashboard
- Docker / Docker Compose:数据库和后续服务部署配置。 - Docker / Docker Compose:数据库和后续服务部署配置。
--- ---
@@ -169,7 +169,7 @@ BlueArchiveToolkit/
├── cmd/ # Go CLI 试验骨架与后续产品入口 ├── cmd/ # Go CLI 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现 ├── pkg/ # Go SDK 包,尚未实现
├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现 ├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现
├── web/ # Web 管理后台,尚未实现 ├── web/ # bat-api 内嵌 dashboard 静态资产;完整协作后台仍在后续阶段
├── deployments/ # Docker 和部署配置 ├── deployments/ # Docker 和部署配置
├── docs/ # 文档、历史报告和分析资料 ├── docs/ # 文档、历史报告和分析资料
├── Cargo.toml ├── Cargo.toml
@@ -183,13 +183,13 @@ BlueArchiveToolkit/
近期优先级: 近期优先级:
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口;`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI。 1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard`cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整产品 CLI。
2. 补齐 AssetBundle UnityFS 引擎级解析。 2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 基于 `translation.worker.run` provider worker 推进翻译记忆、完整 Patch 构建和发布/回滚闭环。 4. 基于 `translation.worker.run` provider worker 推进翻译记忆、完整 Patch 构建和发布/回滚闭环。
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI 当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在翻译记忆、权限模型和持久化 API 明确后推进
--- ---
+12 -2
View File
@@ -137,7 +137,16 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
| `GET /openapi.yaml` | bat-api OpenAPI 文档 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 |
| `GET /admin/` | 管理控制入口与允许操作列表 | | `GET /admin/` | 管理控制入口与允许操作列表 |
| `GET /admin/dashboard/` | 内嵌管理 dashboard 静态页面;页面调用的管理 API 仍需要 token |
| `GET /admin/diagnostics` | 读取 Rust daemon 诊断;需要管理 token |
| `GET /admin/logs?tail=200` | 读取 Rust daemon 日志尾部;需要管理 token |
| `GET /admin/tasks` | 读取 Rust daemon 任务列表;需要管理 token |
| `GET /admin/tasks/status?task_id=...` | 读取单项任务状态;需要管理 token |
| `GET /admin/tasks/logs?task_id=...` | 读取单项任务日志;需要管理 token |
| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token | | `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
| `GET /admin/parse/status` | 读取当前 release 解析状态;需要管理 token |
| `GET /admin/parse/text-units?...` | 分页查询当前 release TextUnit 明细;需要管理 token |
| `GET /admin/parse/errors?...` | 分页查询当前 release 解析错误;需要管理 token |
| `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token | | `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token |
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token | | `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token | | `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
@@ -147,7 +156,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志: 生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>``X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*``/admin/schedules``/admin/translation/*` 需要此 token。 - `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>``X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*``/admin/schedules``/admin/tasks*``/admin/logs``/admin/diagnostics``/admin/parse/*``/admin/translation/*` 需要此 token`/admin/dashboard/` 静态资产默认免鉴权,便于浏览器打开后再在页面内配置 token。
- `BAT_API_AUTH_QUERY_PARAM`query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。 - `BAT_API_AUTH_QUERY_PARAM`query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz` - `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。 - `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
@@ -170,7 +179,8 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report | | `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report |
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report | | `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 | | `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 | | `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 |
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task | | `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 | | `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | | `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
+212 -2
View File
@@ -102,12 +102,210 @@ paths:
responses: responses:
"200": "200":
description: OpenAPI YAML. description: OpenAPI YAML.
/admin/dashboard/:
get:
summary: Embedded bat-api dashboard
security: []
responses:
"200":
description: Static dashboard HTML.
/admin/: /admin/:
get: get:
summary: Admin control entry summary: Admin control entry
responses: responses:
"200": "200":
description: Admin links and allowlisted control actions. description: Admin links and allowlisted control actions.
/admin/diagnostics:
get:
summary: Read Rust daemon doctor diagnostics
responses:
"200":
description: Current daemon.doctor report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat diagnostics backend is unavailable.
/admin/logs:
get:
summary: Read Rust daemon log tail
parameters:
- name: tail
in: query
schema:
type: integer
minimum: 1
maximum: 2000
responses:
"200":
description: Current daemon.logs report.
"400":
description: Invalid log query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat log backend is unavailable.
/admin/tasks:
get:
summary: List Rust-owned async daemon tasks
responses:
"200":
description: Current task.list report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/status:
get:
summary: Read one Rust-owned async daemon task
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.status report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/logs:
get:
summary: Read one Rust-owned async daemon task log
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.logs report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/parse/status:
get:
summary: Read Rust-owned parse/TextUnit index status
responses:
"200":
description: Current parse.status report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/text-units:
get:
summary: Query Rust-owned TextUnit index entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.text_units report.
"400":
description: Invalid TextUnit query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/errors:
get:
summary: Query Rust-owned TextUnit extraction diagnostics
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.errors report.
"400":
description: Invalid parse error query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/schedules: /admin/schedules:
get: get:
summary: List Rust-owned resource workflow schedules summary: List Rust-owned resource workflow schedules
@@ -225,7 +423,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -278,7 +476,19 @@ paths:
type: string type: string
provider: provider:
type: string type: string
enum: [mock, crowdin] translation_results:
type: array
items:
type: object
required: [unit_id, source_text, translated_text]
additionalProperties: false
properties:
unit_id:
type: string
source_text:
type: string
translated_text:
type: string
fixture_path: fixture_path:
type: string type: string
concurrency: concurrency:
+31 -4
View File
@@ -231,15 +231,26 @@ bat parse schedule list --state-dir /tmp/bat-schedule
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N` `schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
限制本轮最多执行 N 个到期计划。 限制本轮最多执行 N 个到期计划。
## bat-api 调度接口 ## bat-api 调度与 dashboard 接口
dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划状态。Rust RPC 方法为: 内嵌 dashboard `bat-api` 直接服务于 `GET /admin/dashboard/`。页面静态资产免
token 读取,但资源、调度、任务、日志、解析和翻译控制都通过 `bat-api` 转发到
Rust `bat.sock`,不维护第二份计划状态或翻译状态。Rust RPC 方法为:
- `schedule.list` - `schedule.list`
- `schedule.add` - `schedule.add`
- `schedule.update` - `schedule.update`
- `schedule.remove` - `schedule.remove`
- `schedule.run` - `schedule.run`
- `task.list`
- `task.status`
- `task.logs`
- `task.cancel`
- `daemon.logs`
- `daemon.doctor`
- `parse.status`
- `parse.text_units`
- `parse.errors`
`bat-api` 对应接口为 `GET /admin/schedules` `bat-api` 对应接口为 `GET /admin/schedules`
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run` `POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`
@@ -250,6 +261,18 @@ contract`id`、`group`、`action`、`args`、`next_run_unix_seconds`、
`enabled``schedule.list` 额外接受 `id``group``enabled` 过滤, `enabled``schedule.list` 额外接受 `id``group``enabled` 过滤,
`schedule.run` 额外接受 `group``force``max_runs` `schedule.run` 额外接受 `group``force``max_runs`
任务和诊断接口同样要求管理 token:`GET /admin/diagnostics` 转发
`daemon.doctor``GET /admin/logs?tail=200` 转发 `daemon.logs`
`GET /admin/tasks``GET /admin/tasks/status?task_id=...`
`GET /admin/tasks/logs?task_id=...` 转发 `task.*` 查询。取消任务使用
`POST /admin/control/task-cancel`,请求字段为 `task_id`
解析查询接口为 `GET /admin/parse/status`
`GET /admin/parse/text-units``GET /admin/parse/errors`,均只读转发当前
Rust release 的 `parse.*` 数据。`text-units``errors` 支持 `offset`
`limit``destination``path_pattern``archive_entry``path_id``class_id`
`field_path``format` query`limit` 范围为 `1..=1000`
翻译任务状态可由已鉴权的 dashboard 通过 `GET /admin/translation/tasks` 翻译任务状态可由已鉴权的 dashboard 通过 `GET /admin/translation/tasks`
查询,query 过滤项包括 `offset``limit``task_id``release_id` 查询,query 过滤项包括 `offset``limit``task_id``release_id`
`destination``path_pattern``archive_entry``status``worker_status` `destination``path_pattern``archive_entry``status``worker_status`
@@ -259,8 +282,12 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
翻译任务状态也可由已鉴权的 dashboard 通过 翻译任务状态也可由已鉴权的 dashboard 通过
`POST /admin/control/translation-task-update` 回写,请求字段为 `POST /admin/control/translation-task-update` 回写,请求字段为
`task_id``status`,以及可选的 `failure_reason``provider_run_id` `task_id``status`,以及可选的 `failure_reason``provider`
该接口只转发 `translation.task.update` `provider_run_id``translation_results`该接口只转发
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`
并为每个 `translation_results[]` 提供 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。
`POST /admin/control/translation-worker-run` 会触发 Rust 侧 `POST /admin/control/translation-worker-run` 会触发 Rust 侧
`translation.worker.run`,请求字段为 `provider``fixture_path` `translation.worker.run`,请求字段为 `provider``fixture_path`
+2 -2
View File
@@ -155,7 +155,7 @@ make check-docs
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准: Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
- **同步/运维命令行** = Rust `bat`(近乎全自动) - **同步/运维命令行** = Rust `bat`(近乎全自动)
- **资源 bootstrap/分发服务** = `cmd/bat-api``make build-go-api` - **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api``make build-go-api`
- **默认 Go 门禁** = `make test-go-api`(无 FFI - **默认 Go 门禁** = `make test-go-api`(无 FFI
- 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名 - 试验 CLI 产物为 `bin/bat-go``make build-go-cli`),**禁止**与 Rust `bat` 重名
- 修改 FFI 时再跑 `make test-go-ffi` - 修改 FFI 时再跑 `make test-go-ffi`
@@ -172,7 +172,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
--refresh-interval 0 --refresh-interval 0
``` ```
其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。 其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。浏览器检查内嵌 dashboard 时打开 `http://127.0.0.1:18080/admin/dashboard/`,再在页面内填入管理 token。
生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。 生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。
+9 -2
View File
@@ -256,8 +256,12 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable `translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
队列文件,也不主动访问 Crowdin。`status` 支持 `queued``running``failed` 队列文件,也不主动访问 Crowdin。`status` 支持 `queued``running``failed`
`completed``skipped`;进入 `running` 会增加 attempt count`completed` `completed``skipped`;进入 `running` 会增加 attempt count`completed`
记录完成时间,`failed` 可写入 `failure_reason`因此 worker 消费 handoff 后, 记录完成时间,`failed` 可写入 `failure_reason`人工校对流程可以在
bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 `status=completed` 时额外提交 `provider``provider_run_id`
`translation_results[]`,每个结果必须包含 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff` `translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回 不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
`data.available=false` `data.available=false`
@@ -468,6 +472,9 @@ CLI 对应关系:
| `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发 | | `Backend` | `daemon.status``daemon.doctor``resource.state``catalog.status``resource.manifest` | 启动发现、周期刷新和资源分发 |
| `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 | | `ControlBackend` | `daemon.restart``daemon.reload``daemon.refresh``resource.sync``resource.verify``resource.repair``catalog.refresh` | 鉴权后的管理控制白名单 |
| `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 | | `ScheduleBackend` | `schedule.list``schedule.add``schedule.update``schedule.remove``schedule.run` | 鉴权后的 dashboard 调度计划控制 |
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
| `TaskBackend` | `task.list``task.status``task.logs``task.cancel` | 鉴权后的 daemon 任务查询和取消 |
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
| `TranslationBackend` | `translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 | | `TranslationBackend` | `translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | | `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
+8 -3
View File
@@ -259,19 +259,24 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。 排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。
### G-010Web 管理后台未实现 ### G-010完整 Web 管理后台未实现
现象: 现象:
- `web/` 只有目录结构。 - `web/` 已提供无构建内嵌 dashboard MVP,作为 `bat-api` 静态资产服务于
`/admin/dashboard/`
- 当前页面可调用已鉴权的资源、调度、任务、日志、parse、翻译和 localized 发布/回滚
接口,但不提供独立登录、角色权限、术语管理、批量审核工作流或构建型前端工程。
影响: 影响:
- 翻译审核、术语管理、Dashboard 无 UI - 基础资源/调度/翻译控制可以在 dashboard 上完成
- 协作式翻译审核、术语管理和权限隔离仍缺少完整 UI。
验收: 验收:
- 登录、权限、翻译审核、术语管理基础流程可用。 - 登录、权限、翻译审核、术语管理基础流程可用。
- Dashboard E2E、静态资产构建/发布策略和错误态交互纳入常规门禁。
--- ---
+10 -10
View File
@@ -1,6 +1,6 @@
# Go 侧进度与边界(权威) # Go 侧进度与边界(权威)
- **更新时间**2026-08-29 - **更新时间**2026-08-31
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。 - **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7、`docs/guides/bat-api-local-live-smoke.md` - **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7、`docs/guides/bat-api-local-live-smoke.md`
@@ -11,7 +11,7 @@
| 名称 | 路径 / 产物 | 角色 | 是否产品入口 | | 名称 | 路径 / 产物 | 角色 | 是否产品入口 |
|---|---|---|---| |---|---|---|---|
| **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** | | **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** |
| **Go `bat-api`** | `cmd/bat-api``bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API | **是(bootstrap/分发服务)** | | **Go `bat-api`** | `cmd/bat-api``bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API + 内嵌 dashboard | **是(bootstrap/分发服务与管理入口** |
| **Go 试验 CLI** | `cmd/bat``bin/bat-go`(不得再叫 `bin/bat` | FFI 演示骨架 | **否** | | **Go 试验 CLI** | `cmd/bat``bin/bat-go`(不得再叫 `bin/bat` | FFI 演示骨架 | **否** |
### 1.1 「同步命令行 = Rust `bat`」的含义 ### 1.1 「同步命令行 = Rust `bat`」的含义
@@ -20,7 +20,7 @@
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口: 这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写已发布资源字节),不是再做一套同步 CLI。 - Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写已发布资源字节和内嵌管理 dashboard),不是再做一套同步 CLI。
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。 - Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
### 1.2 `bat` 与 `bat-api` 的关系 ### 1.2 `bat` 与 `bat-api` 的关系
@@ -31,8 +31,8 @@
|---|---|---| |---|---|---|
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata | | 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` | | 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` |
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info`CDN path,组织给客户端/补丁器使用 | | 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info`CDN path`/admin/dashboard/`,组织给客户端/补丁器/维护者使用 |
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh,不持有或写入同步状态 | | 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh、schedule/task/log/parse/translation/localized 方法,不持有或写入同步状态 |
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。 这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
@@ -40,7 +40,7 @@
1. **G-008 决策关闭(wontfix**:不另做产品级 Go 同步/运维 CLI。 1. **G-008 决策关闭(wontfix**:不另做产品级 Go 同步/运维 CLI。
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。 2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 已补,真实官方网络下载仍由独立 smoke 负责。 3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 和内嵌 dashboard MVP 已补,真实官方网络下载仍由独立 smoke 负责。
--- ---
@@ -96,11 +96,11 @@
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README | | 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
| Web | `web/` | **** | G-010 | | Web | `web/` | **内嵌 dashboard MVP** | issue #46;完整协作后台、登录/角色和术语管理仍属 G-010 剩余 |
`go list ./...` 当前包: `go list ./...` 当前包:
@@ -134,9 +134,9 @@ make build-go-cli # 产出 bin/bat-go
| 项 | 状态 | | 项 | 状态 |
|---|---| |---|---|
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat` | | G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、翻译状态回写代理、同机 live smoke 和部署模板;持久化仍另议 | | G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*`、翻译状态回写代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
| issue #19 | **验收完成,本提交关闭**`make bat-api-local-live-smoke` 已在同机隔离环境覆盖 live RPC、release 切换、清单不完整、未 ready、server-info 和 CDN path | | issue #19 | **验收完成,本提交关闭**`make bat-api-local-live-smoke` 已在同机隔离环境覆盖 live RPC、release 切换、清单不完整、未 ready、server-info 和 CDN path |
| G-010 Web | 未开始 | | G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
--- ---
+19 -3
View File
@@ -4190,6 +4190,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
let output_root = temp.path().join("output"); let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
write_textunit_task_queue_fixture(&current_dir); write_textunit_task_queue_fixture(&current_dir);
write_textunit_index_fixture(&current_dir);
write_translation_task_repository_fixture(&current_dir); write_translation_task_repository_fixture(&current_dir);
let envelope = dispatch_rpc_method( let envelope = dispatch_rpc_method(
@@ -4290,7 +4291,13 @@ fn dispatch_translation_tasks_filters_current_queue() {
Some(serde_json::json!({ Some(serde_json::json!({
"task_id": "textunit/v-current/Bundle/a.bundle", "task_id": "textunit/v-current/Bundle/a.bundle",
"status": "completed", "status": "completed",
"provider_run_id": "crowdin-run-2" "provider": "manual",
"provider_run_id": "manual-run-1",
"translation_results": [{
"unit_id": "direct:a#unit:0",
"source_text": "こんにちは",
"translated_text": "你好"
}]
})), })),
), ),
&state_dir, &state_dir,
@@ -4305,7 +4312,16 @@ fn dispatch_translation_tasks_filters_current_queue() {
value["data"]["entry"]["failure_reason"], value["data"]["entry"]["failure_reason"],
serde_json::Value::Null serde_json::Value::Null
); );
assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2"); assert_eq!(value["data"]["entry"]["provider_run_id"], "manual-run-1");
assert_eq!(value["data"]["entry"]["provider"], "manual");
assert_eq!(
value["data"]["entry"]["translation_results"][0]["unit_id"],
"direct:a#unit:0"
);
assert_eq!(
value["data"]["entry"]["translation_results"][0]["translated_text"],
"你好"
);
assert!(value["data"]["entry"]["completed_unix_seconds"].is_number()); assert!(value["data"]["entry"]["completed_unix_seconds"].is_number());
let envelope = dispatch_rpc_method( let envelope = dispatch_rpc_method(
@@ -4329,7 +4345,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated"); assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated");
assert_eq!( assert_eq!(
value["data"]["handoff"]["provider_runs"][0]["provider_run_id"], value["data"]["handoff"]["provider_runs"][0]["provider_run_id"],
"crowdin-run-2" "manual-run-1"
); );
} }
+129 -4
View File
@@ -1,5 +1,12 @@
use super::*; use super::*;
#[derive(Debug, Deserialize)]
struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
}
pub(super) fn build_translation_tasks_report( pub(super) fn build_translation_tasks_report(
state_dir: &Path, state_dir: &Path,
query: OfficialTextUnitTaskQuery, query: OfficialTextUnitTaskQuery,
@@ -170,6 +177,13 @@ pub(super) fn update_translation_task_status_report(
.or_else(|| rpc_string_param(params, "reason")) .or_else(|| rpc_string_param(params, "reason"))
.map(str::to_string); .map(str::to_string);
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string); let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
let provider = rpc_string_param(params, "provider").map(str::to_string);
let result_params = translation_task_result_params(params)?;
if !result_params.is_empty() && status != TranslationTaskStatus::Completed {
return Err(anyhow::anyhow!(
"translation_results 只能随 completed 状态写入"
));
}
let (_, version_state) = read_daemon_resource_state(state_dir)?; let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state let current = version_state
.as_ref() .as_ref()
@@ -182,6 +196,22 @@ pub(super) fn update_translation_task_status_report(
repository_path.display() repository_path.display()
)); ));
} }
let textunit_index = if result_params.is_empty() {
None
} else {
Some(
read_textunit_index_at(&current.resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果")
})?,
)
};
let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string());
let result_timestamp = unix_seconds_now();
let result_provider_run_id = provider_run_id
.clone()
.unwrap_or_else(|| format!("manual-{result_timestamp}"));
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@@ -190,10 +220,36 @@ pub(super) fn update_translation_task_status_report(
let repository = SqliteTranslationTaskRepository::open(&repository_path) let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await .await
.map_err(|error| anyhow::anyhow!("{error}"))?; .map_err(|error| anyhow::anyhow!("{error}"))?;
repository if let Some(index) = textunit_index.as_ref() {
.update_status(task_id, status, failure_reason, provider_run_id) let current_task = repository
.await .find(task_id)
.map_err(|error| anyhow::anyhow!("{error}")) .await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let results = build_manual_translation_results(
&current_task,
index,
&result_params,
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
repository
.update_status_with_results(
task_id,
status,
failure_reason,
Some(result_provider_run_id),
Some(result_provider),
Some(&results),
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
} else {
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
}
})?; })?;
Ok(serde_json::json!({ Ok(serde_json::json!({
"available": true, "available": true,
@@ -215,6 +271,75 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::
}) })
} }
fn translation_task_result_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<Vec<TranslationTaskResultUpdateParam>> {
let Some(value) = params
.and_then(|params| params.get("translation_results"))
.or_else(|| params.and_then(|params| params.get("results")))
else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
serde_json::from_value(value.clone())
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::BTreeMap<_, _>>();
let mut seen = std::collections::BTreeSet::new();
let mut results = Vec::with_capacity(params.len());
for param in params {
let unit_id = param.unit_id.trim();
if unit_id.is_empty() {
return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空"));
}
if !seen.insert(unit_id.to_string()) {
return Err(anyhow::anyhow!(
"translation_results 包含重复 TextUnit{unit_id}"
));
}
let unit = index_by_id
.get(unit_id)
.ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit{unit_id}"))?;
if unit.destination != task.task.destination
|| unit.archive_entry != task.task.archive_entry
{
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 不属于翻译任务 {}",
task.task.task_id
));
}
if param.source_text != unit.source_text {
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
translated_text: param.translated_text.clone(),
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
});
}
Ok(results)
}
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value { pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"task_id": query.task_id.clone(), "task_id": query.task_id.clone(),
+78
View File
@@ -1217,6 +1217,27 @@ impl SqliteTranslationTaskRepository {
status: TranslationTaskStatus, status: TranslationTaskStatus,
failure_reason: Option<String>, failure_reason: Option<String>,
provider_run_id: Option<String>, provider_run_id: Option<String>,
) -> Result<PersistedTranslationTask> {
self.update_status_with_results(
task_id,
status,
failure_reason,
provider_run_id,
None,
None,
)
.await
}
/// Updates provider state and optionally replaces durable TextUnit results.
pub async fn update_status_with_results(
&self,
task_id: &str,
status: TranslationTaskStatus,
failure_reason: Option<String>,
provider_run_id: Option<String>,
provider: Option<String>,
translation_results: Option<&[TranslationTaskUnitResult]>,
) -> Result<PersistedTranslationTask> { ) -> Result<PersistedTranslationTask> {
let current = self.find(task_id).await?; let current = self.find(task_id).await?;
let now = unix_seconds_now_i64(); let now = unix_seconds_now_i64();
@@ -1228,6 +1249,13 @@ impl SqliteTranslationTaskRepository {
current.attempt_count current.attempt_count
}; };
let normalized_reason = failure_reason.filter(|reason| !reason.trim().is_empty()); let normalized_reason = failure_reason.filter(|reason| !reason.trim().is_empty());
let provider_run_id = provider_run_id.filter(|value| !value.trim().is_empty());
let provider = provider.filter(|value| !value.trim().is_empty());
let translation_results_json =
translation_results
.map(serde_json::to_string)
.transpose()
.map_err(|error| bat_core::Error::Serialization(error.to_string()))?;
let completed = (status == TranslationTaskStatus::Completed).then_some(now); let completed = (status == TranslationTaskStatus::Completed).then_some(now);
sqlx::query( sqlx::query(
r#" r#"
@@ -1235,6 +1263,8 @@ impl SqliteTranslationTaskRepository {
SET worker_status = ?2, failure_reason = ?3, attempt_count = ?4, SET worker_status = ?2, failure_reason = ?3, attempt_count = ?4,
updated_unix_seconds = ?5, completed_unix_seconds = ?6, updated_unix_seconds = ?5, completed_unix_seconds = ?6,
provider_run_id = COALESCE(?7, provider_run_id), provider_run_id = COALESCE(?7, provider_run_id),
provider = COALESCE(?8, provider),
translation_results_json = COALESCE(?9, translation_results_json),
lease_owner = NULL, lease_expires_unix_seconds = NULL, lease_owner = NULL, lease_expires_unix_seconds = NULL,
failure_class = NULL, failure_retryable = 0, failure_class = NULL, failure_retryable = 0,
next_attempt_unix_seconds = NULL next_attempt_unix_seconds = NULL
@@ -1248,6 +1278,8 @@ impl SqliteTranslationTaskRepository {
.bind(now) .bind(now)
.bind(completed) .bind(completed)
.bind(provider_run_id) .bind(provider_run_id)
.bind(provider)
.bind(translation_results_json)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
@@ -1696,6 +1728,52 @@ mod tests {
assert_eq!(retrievable[0].attempt_count, 1); assert_eq!(retrievable[0].attempt_count, 1);
} }
#[tokio::test]
async fn sqlite_translation_tasks_persist_manual_results_without_worker_lease() {
let temp = tempfile::TempDir::new().unwrap();
let repository =
SqliteTranslationTaskRepository::new(temp.path().join("translation-tasks.sqlite"))
.await
.unwrap();
let queue = queue(vec![task(
"task-a",
"Bundles/a.bundle",
OfficialTextUnitTaskStatus::QueuedOffline,
Some(OfficialParseStatus::Parsed),
None,
)]);
repository.sync_queue(&queue).await.unwrap();
let result = TranslationTaskUnitResult {
unit_id: "unit-a".to_string(),
source_text: "source".to_string(),
translated_text: "manual translation".to_string(),
provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321,
};
let updated = repository
.update_status_with_results(
"task-a",
TranslationTaskStatus::Completed,
None,
Some("manual-run-1".to_string()),
Some("manual".to_string()),
Some(std::slice::from_ref(&result)),
)
.await
.unwrap();
assert_eq!(updated.task_status, TranslationTaskStatus::Completed);
assert_eq!(updated.provider.as_deref(), Some("manual"));
assert_eq!(updated.provider_run_id.as_deref(), Some("manual-run-1"));
assert_eq!(updated.translation_results, vec![result.clone()]);
assert_eq!(
repository.find("task-a").await.unwrap().translation_results,
vec![result]
);
}
#[tokio::test] #[tokio::test]
async fn sqlite_translation_tasks_recover_expired_leases_for_retry() { async fn sqlite_translation_tasks_recover_expired_leases_for_retry() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
+357 -2
View File
@@ -15,11 +15,17 @@ import (
const adminControlMaxBodyBytes = 1024 const adminControlMaxBodyBytes = 1024
const adminScheduleMaxBodyBytes = 64 * 1024 const adminScheduleMaxBodyBytes = 64 * 1024
const adminDefaultLogTail = 200
const adminMaxLogTail = 2000
type adminControlRequest struct { type adminControlRequest struct {
Force bool `json:"force"` Force bool `json:"force"`
} }
type adminTaskRequest struct {
TaskID string `json:"task_id"`
}
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -36,7 +42,16 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/v1/release", "/v1/release",
"/v1/resources", "/v1/resources",
"/openapi.yaml", "/openapi.yaml",
"/admin/dashboard/",
"/admin/schedules", "/admin/schedules",
"/admin/tasks",
"/admin/tasks/status",
"/admin/tasks/logs",
"/admin/diagnostics",
"/admin/logs",
"/admin/parse/status",
"/admin/parse/text-units",
"/admin/parse/errors",
"/admin/translation/tasks", "/admin/translation/tasks",
"/admin/translation/handoff", "/admin/translation/handoff",
"/admin/translation/status", "/admin/translation/status",
@@ -53,6 +68,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/schedule-update", "/admin/control/schedule-update",
"/admin/control/schedule-remove", "/admin/control/schedule-remove",
"/admin/control/schedule-run", "/admin/control/schedule-run",
"/admin/control/task-cancel",
"/admin/control/translation-task-update", "/admin/control/translation-task-update",
"/admin/control/translation-worker-run", "/admin/control/translation-worker-run",
"/admin/control/translation-proofread", "/admin/control/translation-proofread",
@@ -85,6 +101,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminScheduleControl(w, r, action) s.handleAdminScheduleControl(w, r, action)
return return
} }
if action == "task-cancel" {
s.handleAdminTaskCancel(w, r)
return
}
if action == "translation-task-update" { if action == "translation-task-update" {
s.handleAdminTranslationTaskUpdate(w, r) s.handleAdminTranslationTaskUpdate(w, r)
return return
@@ -188,8 +208,8 @@ func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http
if !decodeAdminTranslationJSON(w, r, &params) { if !decodeAdminTranslationJSON(w, r, &params) {
return return
} }
if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { if err := validateTranslationTaskUpdateParams(params); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status") writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", err.Error())
return return
} }
result, err := backend.TranslationTaskUpdate(r.Context(), params) result, err := backend.TranslationTaskUpdate(r.Context(), params)
@@ -332,6 +352,253 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
writeNoStoreJSON(w, http.StatusOK, result) writeNoStoreJSON(w, http.StatusOK, result)
} }
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
if s.backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat diagnostics backend is unavailable")
return
}
result, err := s.backend.DaemonDoctor(r.Context())
if err != nil {
s.writeControlBackendError(w, "diagnostics", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(DaemonLogsBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "diagnostics_backend_unavailable", "Rust bat log backend is unavailable")
return
}
tail, err := adminLogTail(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_log_query", err.Error())
return
}
result, err := backend.DaemonLogs(r.Context(), tail)
if err != nil {
s.writeControlBackendError(w, "daemon-logs", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
result, err := backend.TaskList(r.Context())
if err != nil {
s.writeControlBackendError(w, "task-list", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
taskID, err := adminTaskIDQuery(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error())
return
}
result, err := backend.TaskStatus(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
taskID, err := adminTaskIDQuery(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_query", err.Error())
return
}
result, err := backend.TaskLogs(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-logs", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminParseStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ParseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable")
return
}
result, err := backend.ParseStatus(r.Context())
if err != nil {
s.writeControlBackendError(w, "parse-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminParseTextUnits(w http.ResponseWriter, r *http.Request) {
s.handleAdminParseTextUnitQuery(w, r, false)
}
func (s *Server) handleAdminParseErrors(w http.ResponseWriter, r *http.Request) {
s.handleAdminParseTextUnitQuery(w, r, true)
}
func (s *Server) handleAdminParseTextUnitQuery(w http.ResponseWriter, r *http.Request, errorsOnly bool) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ParseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "parse_backend_unavailable", "Rust bat parse backend is unavailable")
return
}
query, err := parseTextUnitQueryParams(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_parse_query", err.Error())
return
}
var result json.RawMessage
if errorsOnly {
result, err = backend.ParseErrors(r.Context(), query)
} else {
result, err = backend.ParseTextUnits(r.Context(), query)
}
if err != nil {
action := "parse-text-units"
if errorsOnly {
action = "parse-errors"
}
s.writeControlBackendError(w, action, err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTaskCancel(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(TaskBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "task_backend_unavailable", "Rust bat task backend is unavailable")
return
}
var params adminTaskRequest
if !decodeAdminTaskJSON(w, r, &params) {
return
}
taskID := strings.TrimSpace(params.TaskID)
if taskID == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_task_params", "task cancel requires task_id")
return
}
result, err := backend.TaskCancel(r.Context(), taskID)
if err != nil {
s.writeControlBackendError(w, "task-cancel", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "task-cancel",
RPCMethod: "task.cancel",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -480,6 +747,46 @@ func translationTaskListParams(r *http.Request) (backendrpc.TranslationTaskListP
return params, nil return params, nil
} }
func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) {
query := r.URL.Query()
params := backendrpc.TextUnitQueryParams{
Destination: strings.TrimSpace(query.Get("destination")),
PathPattern: strings.TrimSpace(query.Get("path_pattern")),
ArchiveEntry: strings.TrimSpace(query.Get("archive_entry")),
FieldPath: strings.TrimSpace(query.Get("field_path")),
Format: strings.TrimSpace(query.Get("format")),
}
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
offset, err := strconv.ParseInt(raw, 10, 32)
if err != nil || offset < 0 {
return backendrpc.TextUnitQueryParams{}, errors.New("offset must be a non-negative integer")
}
params.Offset = int(offset)
}
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
limit, err := strconv.ParseInt(raw, 10, 32)
if err != nil || limit < 1 || limit > 1000 {
return backendrpc.TextUnitQueryParams{}, errors.New("limit must be in 1..=1000")
}
params.Limit = int(limit)
}
if raw := strings.TrimSpace(query.Get("path_id")); raw != "" {
pathID, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return backendrpc.TextUnitQueryParams{}, errors.New("path_id must be a signed integer")
}
params.PathID = &pathID
}
if raw := strings.TrimSpace(query.Get("class_id")); raw != "" {
classID, err := strconv.Atoi(raw)
if err != nil {
return backendrpc.TextUnitQueryParams{}, errors.New("class_id must be a signed integer")
}
params.ClassID = &classID
}
return params, nil
}
func firstTrimmedQuery(query url.Values, keys ...string) string { func firstTrimmedQuery(query url.Values, keys ...string) string {
for _, key := range keys { for _, key := range keys {
values := query[key] values := query[key]
@@ -493,6 +800,26 @@ func firstTrimmedQuery(query url.Values, keys ...string) string {
return "" return ""
} }
func adminLogTail(r *http.Request) (int, error) {
raw := strings.TrimSpace(r.URL.Query().Get("tail"))
if raw == "" {
return adminDefaultLogTail, nil
}
tail, err := strconv.Atoi(raw)
if err != nil || tail < 1 || tail > adminMaxLogTail {
return 0, errors.New("tail must be in 1..=2000")
}
return tail, nil
}
func adminTaskIDQuery(r *http.Request) (string, error) {
taskID := strings.TrimSpace(r.URL.Query().Get("task_id"))
if taskID == "" {
return "", errors.New("task_id is required")
}
return taskID, nil
}
func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(ScheduleBackend) backend, ok := s.backend.(ScheduleBackend)
if !ok || backend == nil { if !ok || backend == nil {
@@ -567,6 +894,34 @@ func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target a
return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request") return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation request")
} }
func decodeAdminTaskJSON(w http.ResponseWriter, r *http.Request, target any) bool {
return decodeAdminJSON(w, r, target, "invalid_task_params", "task request")
}
func validateTranslationTaskUpdateParams(params backendrpc.TranslationTaskUpdateParams) error {
if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" {
return errors.New("translation task update requires task_id and status")
}
if len(params.TranslationResults) == 0 {
return nil
}
if strings.TrimSpace(params.Status) != "completed" {
return errors.New("translation_results can only be submitted with completed status")
}
seen := make(map[string]struct{}, len(params.TranslationResults))
for _, result := range params.TranslationResults {
unitID := strings.TrimSpace(result.UnitID)
if unitID == "" {
return errors.New("translation_results unit_id is required")
}
if _, ok := seen[unitID]; ok {
return errors.New("translation_results unit_id must be unique")
}
seen[unitID] = struct{}{}
}
return nil
}
func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error { func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunParams) error {
if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) { if params.Concurrency != nil && (*params.Concurrency < 1 || *params.Concurrency > 256) {
return errors.New("translation worker concurrency must be in 1..=256") return errors.New("translation worker concurrency must be in 1..=256")
+307 -2
View File
@@ -560,6 +560,11 @@ type fakeBackend struct {
catalog json.RawMessage catalog json.RawMessage
resource *backendrpc.ResourceState resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage manifest *backendrpc.ResourceManifestPage
daemonLogs *backendrpc.LogsReport
taskList *backendrpc.TaskList
taskStatus *backendrpc.TaskRecord
taskLogs *backendrpc.TaskLogs
taskCancel *backendrpc.TaskCancelResult
} }
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
@@ -585,10 +590,49 @@ func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return f.manifest, nil return f.manifest, nil
} }
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
if f.daemonLogs != nil {
return f.daemonLogs, nil
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (f *fakeBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
if f.taskList != nil {
return f.taskList, nil
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (f *fakeBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
if f.taskStatus != nil {
out := *f.taskStatus
out.ID = taskID
return &out, nil
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (f *fakeBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
if f.taskLogs != nil {
out := *f.taskLogs
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
if f.taskCancel != nil {
out := *f.taskCancel
out.TaskID = taskID
return &out, nil
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
type controlBackend struct { type controlBackend struct {
*fakeBackend *fakeBackend
calls []string calls []string
parseTextUnitQueries []backendrpc.TextUnitQueryParams
parseErrorQueries []backendrpc.TextUnitQueryParams
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
translationTaskListParams []backendrpc.TranslationTaskListParams translationTaskListParams []backendrpc.TranslationTaskListParams
localizedPublishParams []backendrpc.LocalizedPublishParams localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams localizedRollbackParams []backendrpc.LocalizedRollbackParams
@@ -629,8 +673,70 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
} }
func (b *controlBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
b.calls = append(b.calls, "daemon.doctor")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonDoctor(ctx)
}
return &backendrpc.DoctorReport{Command: "doctor", Status: "ok", Message: "healthy", Healthy: true}, nil
}
func (b *controlBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
b.calls = append(b.calls, "daemon.logs")
if b.fakeBackend != nil {
return b.fakeBackend.DaemonLogs(ctx, tail)
}
return &backendrpc.LogsReport{Command: "logs", Status: "ok", Message: "ok", LogPath: "/tmp/bat-daemon.log", Exists: true, Empty: false, Bytes: 1, TotalLines: 1, ReturnedLines: 1, Content: "line-1\n"}, nil
}
func (b *controlBackend) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
b.calls = append(b.calls, "task.list")
if b.fakeBackend != nil {
return b.fakeBackend.TaskList(ctx)
}
return &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{}}, nil
}
func (b *controlBackend) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
b.calls = append(b.calls, "task.status")
if b.fakeBackend != nil {
return b.fakeBackend.TaskStatus(ctx, taskID)
}
return &backendrpc.TaskRecord{ID: taskID, Kind: "resource.sync", Status: "running"}, nil
}
func (b *controlBackend) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
b.calls = append(b.calls, "task.logs")
if b.fakeBackend != nil {
return b.fakeBackend.TaskLogs(ctx, taskID)
}
return &backendrpc.TaskLogs{TaskID: taskID, Lines: []string{"task-log-line-1"}}, nil
}
func (b *controlBackend) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
b.calls = append(b.calls, "task.cancel")
if b.fakeBackend != nil {
return b.fakeBackend.TaskCancel(ctx, taskID)
}
return &backendrpc.TaskCancelResult{TaskID: taskID, CancelRequested: true, Note: "cancel requested"}, nil
}
func (b *controlBackend) ParseStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.status")
return json.RawMessage(`{"available":true,"status":"parsed"}`), nil
}
func (b *controlBackend) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.text_units")
b.parseTextUnitQueries = append(b.parseTextUnitQueries, query)
return json.RawMessage(`{"available":true,"entries":[{"id":"direct:a#unit:0","destination":"Bundle/a.bundle","source_text":"source","field_path":"Scenario.Message"}]}`), nil
}
func (b *controlBackend) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
b.calls = append(b.calls, "parse.errors")
b.parseErrorQueries = append(b.parseErrorQueries, query)
return json.RawMessage(`{"available":true,"entries":[]}`), nil
}
func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) { func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
b.calls = append(b.calls, "translation.task.update") b.calls = append(b.calls, "translation.task.update")
b.translationTaskUpdates = append(b.translationTaskUpdates, params)
return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil
} }
@@ -1392,6 +1498,13 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") { if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") {
t.Fatalf("openapi missing launcher bootstrap") t.Fatalf("openapi missing launcher bootstrap")
} }
if !strings.Contains(rr.Body.String(), "/admin/dashboard/") ||
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "task-cancel") {
t.Fatalf("openapi missing dashboard/task admin routes")
}
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil)) s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil))
@@ -1410,11 +1523,187 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
} }
links := strings.Join(admin.Links, "\n") links := strings.Join(admin.Links, "\n")
if !strings.Contains(links, "/admin/translation/tasks") || if !strings.Contains(links, "/admin/translation/tasks") ||
!strings.Contains(links, "/admin/translation/handoff") { !strings.Contains(links, "/admin/translation/handoff") ||
!strings.Contains(links, "/admin/dashboard/") ||
!strings.Contains(links, "/admin/parse/text-units") ||
!strings.Contains(links, "/admin/tasks/logs") {
t.Fatalf("admin links=%v", admin.Links) t.Fatalf("admin links=%v", admin.Links)
} }
} }
func TestAdminDashboardServesStaticAssetsWithoutAdminToken(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("dashboard status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("dashboard Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
if !strings.Contains(rr.Header().Get("Content-Security-Policy"), "connect-src") {
t.Fatalf("dashboard CSP=%q", rr.Header().Get("Content-Security-Policy"))
}
if !strings.Contains(rr.Body.String(), "bat-api Dashboard") {
t.Fatalf("dashboard body missing title")
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/dashboard/app.js", nil))
if rr.Code != http.StatusOK {
t.Fatalf("app.js status=%d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "translation-worker-run") {
t.Fatalf("app.js missing dashboard control wiring")
}
}
func TestAdminTaskAndDiagnosticsEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
message := "task running"
backend := &controlBackend{fakeBackend: &fakeBackend{
doctor: &backendrpc.DoctorReport{
Command: "doctor",
Status: "ok",
Message: "healthy",
Healthy: true,
Checks: []backendrpc.DoctorCheck{{Name: "socket", OK: true, Message: "ok"}},
},
taskList: &backendrpc.TaskList{Tasks: []backendrpc.TaskRecord{{
ID: "task-sync-1",
Kind: "resource.sync",
Status: "running",
Message: &message,
}}},
}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
method string
path string
wantCall string
}{
{name: "diagnostics", method: http.MethodGet, path: "/admin/diagnostics", wantCall: "daemon.doctor"},
{name: "daemon logs", method: http.MethodGet, path: "/admin/logs?tail=20", wantCall: "daemon.logs"},
{name: "task list", method: http.MethodGet, path: "/admin/tasks", wantCall: "task.list"},
{name: "task status", method: http.MethodGet, path: "/admin/tasks/status?task_id=task-sync-1", wantCall: "task.status"},
{name: "task logs", method: http.MethodGet, path: "/admin/tasks/logs?task_id=task-sync-1", wantCall: "task.logs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(tc.method, tc.path, nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
request := httptest.NewRequest(http.MethodGet, "/admin/logs?tail=0", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid logs status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks/status", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid task status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/tasks", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated tasks status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminParseEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "parse-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
path string
wantCall string
}{
{name: "parse status", path: "/admin/parse/status", wantCall: "parse.status"},
{name: "parse text units", path: "/admin/parse/text-units?offset=1&limit=25&destination=Bundle%2Fa.bundle&path_id=42&class_id=114&field_path=Scenario.Message&format=plain", wantCall: "parse.text_units"},
{name: "parse errors", path: "/admin/parse/errors?destination=Bundle%2Fa.bundle&limit=10", wantCall: "parse.errors"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tc.path, nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.wantCall {
t.Fatalf("calls=%v", backend.calls)
}
})
}
if len(backend.parseTextUnitQueries) != 1 {
t.Fatalf("parse text unit queries=%#v", backend.parseTextUnitQueries)
}
query := backend.parseTextUnitQueries[0]
if query.Offset != 1 ||
query.Limit != 25 ||
query.Destination != "Bundle/a.bundle" ||
query.PathID == nil || *query.PathID != 42 ||
query.ClassID == nil || *query.ClassID != 114 ||
query.FieldPath != "Scenario.Message" ||
query.Format != "plain" {
t.Fatalf("query=%#v", query)
}
if len(backend.parseErrorQueries) != 1 || backend.parseErrorQueries[0].Destination != "Bundle/a.bundle" {
t.Fatalf("parse error queries=%#v", backend.parseErrorQueries)
}
request := httptest.NewRequest(http.MethodGet, "/admin/parse/text-units?limit=0", nil)
request.Header.Set("Authorization", "Bearer parse-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid parse query status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/parse/status", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated parse status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminControlForwardsAllowlistedActions(t *testing.T) { func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
cfg.AuthToken = "control-token" cfg.AuthToken = "control-token"
@@ -1436,7 +1725,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"}, {name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, {name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"}, {name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, {name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, {name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
@@ -1464,6 +1754,13 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
}) })
} }
if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
request.Header.Set("Authorization", "Bearer control-token") request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@@ -1472,6 +1769,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String())
} }
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":"textunit/v-current/Scenario","status":"failed","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation result status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`)) request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-worker-run", strings.NewReader(`{"concurrency":0}`))
request.Header.Set("Authorization", "Bearer control-token") request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
+1 -1
View File
@@ -122,7 +122,7 @@ func (c *Config) Normalize() error {
if c.AuthQueryParam == "" { if c.AuthQueryParam == "" {
c.AuthQueryParam = "bat_token" c.AuthQueryParam = "bat_token"
} }
c.AuthExemptPaths = normalizePathList(c.AuthExemptPaths) c.AuthExemptPaths = normalizePathList(append(c.AuthExemptPaths, dashboardAuthExemptPaths()...))
if c.RateLimitRPS < 0 { if c.RateLimitRPS < 0 {
return fmt.Errorf("rate limit rps must be >= 0") return fmt.Errorf("rate limit rps must be >= 0")
} }
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"net/http"
"strings"
dashboard "bat-api/web"
)
const adminDashboardPath = "/admin/dashboard"
var adminDashboardFileServer = http.FileServer(http.FS(dashboard.Assets))
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
w.Header().Set("Cache-Control", "no-store")
if r.URL.Path == adminDashboardPath {
http.Redirect(w, r, adminDashboardPath+"/", http.StatusMovedPermanently)
return
}
if !strings.HasPrefix(r.URL.Path, adminDashboardPath+"/") {
http.NotFound(w, r)
return
}
http.StripPrefix(adminDashboardPath+"/", adminDashboardFileServer).ServeHTTP(w, r)
}
func dashboardAuthExemptPaths() []string {
return []string{adminDashboardPath, adminDashboardPath + "/"}
}
func isAdminDashboardPath(path string) bool {
return path == adminDashboardPath || strings.HasPrefix(path, adminDashboardPath+"/")
}
+5 -1
View File
@@ -51,7 +51,11 @@ func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") if isAdminDashboardPath(r.URL.Path) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' http: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
} else {
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
+212 -2
View File
@@ -109,12 +109,210 @@ paths:
responses: responses:
"200": "200":
description: OpenAPI YAML. description: OpenAPI YAML.
/admin/dashboard/:
get:
summary: Embedded bat-api dashboard
security: []
responses:
"200":
description: Static dashboard HTML.
/admin/: /admin/:
get: get:
summary: Admin control entry summary: Admin control entry
responses: responses:
"200": "200":
description: Admin links and allowlisted control actions. description: Admin links and allowlisted control actions.
/admin/diagnostics:
get:
summary: Read Rust daemon doctor diagnostics
responses:
"200":
description: Current daemon.doctor report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat diagnostics backend is unavailable.
/admin/logs:
get:
summary: Read Rust daemon log tail
parameters:
- name: tail
in: query
schema:
type: integer
minimum: 1
maximum: 2000
responses:
"200":
description: Current daemon.logs report.
"400":
description: Invalid log query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat log backend is unavailable.
/admin/tasks:
get:
summary: List Rust-owned async daemon tasks
responses:
"200":
description: Current task.list report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/status:
get:
summary: Read one Rust-owned async daemon task
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.status report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/tasks/logs:
get:
summary: Read one Rust-owned async daemon task log
parameters:
- name: task_id
in: query
required: true
schema:
type: string
responses:
"200":
description: Current task.logs report.
"400":
description: Missing or invalid task_id.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat task backend is unavailable.
/admin/parse/status:
get:
summary: Read Rust-owned parse/TextUnit index status
responses:
"200":
description: Current parse.status report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/text-units:
get:
summary: Query Rust-owned TextUnit index entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.text_units report.
"400":
description: Invalid TextUnit query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/parse/errors:
get:
summary: Query Rust-owned TextUnit extraction diagnostics
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
- name: destination
in: query
schema:
type: string
- name: path_pattern
in: query
schema:
type: string
- name: archive_entry
in: query
schema:
type: string
- name: path_id
in: query
schema:
type: integer
format: int64
- name: class_id
in: query
schema:
type: integer
- name: field_path
in: query
schema:
type: string
- name: format
in: query
schema:
type: string
responses:
"200":
description: Current parse.errors report.
"400":
description: Invalid parse error query.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat parse backend is unavailable.
/admin/schedules: /admin/schedules:
get: get:
summary: List Rust-owned resource workflow schedules summary: List Rust-owned resource workflow schedules
@@ -232,7 +430,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -285,7 +483,19 @@ paths:
type: string type: string
provider: provider:
type: string type: string
enum: [mock, crowdin] translation_results:
type: array
items:
type: object
required: [unit_id, source_text, translated_text]
additionalProperties: false
properties:
unit_id:
type: string
source_text:
type: string
translated_text:
type: string
fixture_path: fixture_path:
type: string type: string
concurrency: concurrency:
+38
View File
@@ -39,6 +39,29 @@ type ControlBackend interface {
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
} }
// DaemonLogsBackend exposes the Rust daemon log tail for an authenticated
// dashboard. It remains read-only and never opens log files from Go.
type DaemonLogsBackend interface {
DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error)
}
// TaskBackend exposes Rust-owned async task state to the dashboard. Go only
// forwards read/cancel requests and does not create generic tasks.
type TaskBackend interface {
TaskList(ctx context.Context) (*backendrpc.TaskList, error)
TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error)
TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error)
TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error)
}
// ParseBackend exposes existing Rust TextUnit index queries to the dashboard.
// It is read-only and does not expand parser coverage.
type ParseBackend interface {
ParseStatus(ctx context.Context) (json.RawMessage, error)
ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
}
// ScheduleBackend exposes the Rust-owned schedule store to an authenticated // ScheduleBackend exposes the Rust-owned schedule store to an authenticated
// dashboard. The JSON result remains Rust's report shape so the API does not // dashboard. The JSON result remains Rust's report shape so the API does not
// duplicate schedule state or invent a second schema. // duplicate schedule state or invent a second schema.
@@ -97,6 +120,9 @@ func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) { func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
return r.Client.DaemonRefresh(ctx, force) return r.Client.DaemonRefresh(ctx, force)
} }
func (r RPCClient) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
return r.Client.DaemonLogs(ctx, tail)
}
func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.ResourceSync(ctx, force) return r.Client.ResourceSync(ctx, force)
} }
@@ -109,6 +135,18 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.CatalogRefresh(ctx, force) return r.Client.CatalogRefresh(ctx, force)
} }
func (r RPCClient) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
return r.Client.TaskList(ctx)
}
func (r RPCClient) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
return r.Client.TaskStatus(ctx, taskID)
}
func (r RPCClient) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
return r.Client.TaskLogs(ctx, taskID)
}
func (r RPCClient) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
return r.Client.TaskCancel(ctx, taskID)
}
func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) { func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
return r.Client.ScheduleListFiltered(ctx, params) return r.Client.ScheduleListFiltered(ctx, params)
} }
+19
View File
@@ -61,6 +61,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard)
mux.HandleFunc("/admin/dashboard/", s.handleAdminDashboard)
mux.HandleFunc("/admin/diagnostics", s.handleAdminDiagnostics)
mux.HandleFunc("/admin/logs", s.handleAdminLogs)
mux.HandleFunc("/admin/tasks", s.handleAdminTasks)
mux.HandleFunc("/admin/tasks/status", s.handleAdminTaskStatus)
mux.HandleFunc("/admin/tasks/logs", s.handleAdminTaskLogs)
mux.HandleFunc("/admin/parse/status", s.handleAdminParseStatus)
mux.HandleFunc("/admin/parse/text-units", s.handleAdminParseTextUnits)
mux.HandleFunc("/admin/parse/errors", s.handleAdminParseErrors)
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks) mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
@@ -128,7 +138,16 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/" + ClientPatchHost + "/...", "/" + ClientPatchHost + "/...",
"/" + ServerInfoHost + "/...", "/" + ServerInfoHost + "/...",
"/openapi.yaml", "/openapi.yaml",
"/admin/dashboard/",
"/admin/", "/admin/",
"/admin/diagnostics",
"/admin/logs",
"/admin/tasks",
"/admin/tasks/status",
"/admin/tasks/logs",
"/admin/parse/status",
"/admin/parse/text-units",
"/admin/parse/errors",
"/admin/schedules", "/admin/schedules",
"/admin/translation/tasks", "/admin/translation/tasks",
"/admin/translation/handoff", "/admin/translation/handoff",
+16 -5
View File
@@ -264,13 +264,24 @@ type ScheduleRunParams struct {
MaxRuns *uint64 `json:"max_runs,omitempty"` MaxRuns *uint64 `json:"max_runs,omitempty"`
} }
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
// TextUnit result accepted by Rust translation.task.update.
type TranslationTaskUnitResultParam struct {
UnitID string `json:"unit_id"`
SourceText string `json:"source_text"`
TranslatedText string `json:"translated_text"`
}
// TranslationTaskUpdateParams is used by translation.task.update to persist // TranslationTaskUpdateParams is used by translation.task.update to persist
// provider worker state for one task in the current official release. // provider worker state and optional manual-review text for one task in the
// current official release.
type TranslationTaskUpdateParams struct { type TranslationTaskUpdateParams struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
Status string `json:"status"` Status string `json:"status"`
FailureReason string `json:"failure_reason,omitempty"` FailureReason string `json:"failure_reason,omitempty"`
ProviderRunID string `json:"provider_run_id,omitempty"` ProviderRunID string `json:"provider_run_id,omitempty"`
Provider string `json:"provider,omitempty"`
TranslationResults []TranslationTaskUnitResultParam `json:"translation_results,omitempty"`
} }
// TranslationTaskListParams filters the Rust-owned translation task queue. // TranslationTaskListParams filters the Rust-owned translation task queue.
+16 -6
View File
@@ -434,9 +434,14 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
t.Fatalf("decode params: %v", err) t.Fatalf("decode params: %v", err)
} }
if params.TaskID != "textunit/v-current/Scenario" || if params.TaskID != "textunit/v-current/Scenario" ||
params.Status != "failed" || params.Status != "completed" ||
params.FailureReason != "provider rejected payload" || params.FailureReason != "" ||
params.ProviderRunID != "provider-run-1" { params.ProviderRunID != "provider-run-1" ||
params.Provider != "manual" ||
len(params.TranslationResults) != 1 ||
params.TranslationResults[0].UnitID != "direct:a#unit:0" ||
params.TranslationResults[0].SourceText != "source" ||
params.TranslationResults[0].TranslatedText != "译文" {
t.Fatalf("params = %#v", params) t.Fatalf("params = %#v", params)
} }
return testResponse{ return testResponse{
@@ -444,16 +449,21 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
OK: true, OK: true,
Status: "ok", Status: "ok",
RequestID: "req-test-translation-update", RequestID: "req-test-translation-update",
Data: map[string]any{"task_status": "failed"}, Data: map[string]any{"task_status": "completed"},
}, },
} }
}) })
raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{ raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{
TaskID: "textunit/v-current/Scenario", TaskID: "textunit/v-current/Scenario",
Status: "failed", Status: "completed",
FailureReason: "provider rejected payload",
ProviderRunID: "provider-run-1", ProviderRunID: "provider-run-1",
Provider: "manual",
TranslationResults: []TranslationTaskUnitResultParam{{
UnitID: "direct:a#unit:0",
SourceText: "source",
TranslatedText: "译文",
}},
}) })
if err != nil { if err != nil {
t.Fatalf("TranslationTaskUpdate error: %v", err) t.Fatalf("TranslationTaskUpdate error: %v", err)
-2
View File
@@ -55,8 +55,6 @@ placeholder_readmes=(
"pkg/cas/README.md" "pkg/cas/README.md"
"pkg/translator/README.md" "pkg/translator/README.md"
"pkg/types/README.md" "pkg/types/README.md"
"web/README.md"
"web/admin/README.md"
"web/shared/README.md" "web/shared/README.md"
) )
+19 -8
View File
@@ -1,17 +1,28 @@
# bat-api Admin Panel # bat-api Dashboard
本目录预留给后续 `bat-api` 管理面板。当前后端已提供只读入口 当前稳定交付的 dashboard 静态资产位于 `web/` 根目录,并由 Go embed 挂载到
`GET /admin/`,返回 health、ready、bootstrap、release、resources 和 OpenAPI `GET /admin/dashboard/`。本目录只保留后续完整管理后台的说明入口。`GET /admin/`
链接。 仍返回 health、ready、bootstrap、release、resources、OpenAPI、dashboard 和可用
控制链接。
需要配置 `BAT_API_AUTH_TOKEN` 的接口: 需要配置 `BAT_API_AUTH_TOKEN` 的接口:
- `GET /admin/diagnostics`:转发 `daemon.doctor`,读取 daemon 诊断。
- `GET /admin/logs`:转发 `daemon.logs`,按 `tail` 读取 daemon 日志尾部。
- `GET /admin/tasks``GET /admin/tasks/status``GET /admin/tasks/logs`
转发 `task.*` 查询任务列表、单项状态和任务日志。
- `GET /admin/schedules`:转发 Rust 持有的 schedule 查询,支持 `id``group` - `GET /admin/schedules`:转发 Rust 持有的 schedule 查询,支持 `id``group`
`enabled` 过滤。 `enabled` 过滤。
- `POST /admin/control/schedule-{add,update,remove,run}`:转发 - `POST /admin/control/schedule-{add,update,remove,run}`:转发
`schedule.*` 计划控制。 `schedule.*` 计划控制。
- `GET /admin/parse/status``GET /admin/parse/text-units`
`GET /admin/parse/errors`:转发 `parse.*` 只读查询当前 release 的解析状态、
TextUnit 明细和解析错误。
- `POST /admin/control/task-cancel`:转发 `task.cancel`,请求字段为 `task_id`
- `POST /admin/control/translation-task-update`:转发 - `POST /admin/control/translation-task-update`:转发
`translation.task.update`,供外部 provider 流程回写任务状态 `translation.task.update`,供外部 provider 流程或人工校对流程回写任务状态
`status=completed` 时可带 `provider``provider_run_id`
`translation_results`
- `POST /admin/control/translation-worker-run`:转发 - `POST /admin/control/translation-worker-run`:转发
`translation.worker.run`,触发 Rust provider worker。 `translation.worker.run`,触发 Rust provider worker。
- `POST /admin/control/translation-proofread`:转发 - `POST /admin/control/translation-proofread`:转发
@@ -27,6 +38,6 @@
- `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前 - `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前
release 的完整翻译交接视图。 release 的完整翻译交接视图。
正式前端必须复用资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 内嵌 dashboard 自身允许免 token 读取静态资产;所有写操作和 Rust 状态查询仍复用
`Cache-Control: no-store` 策略。静态前端资产仍为 not implemented,尚未作为 资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 `Cache-Control: no-store`
本目录的稳定交付物。当前组件边界见 `docs/reports/GO_STATUS.md` 策略。当前组件边界见 `docs/reports/GO_STATUS.md`
+861
View File
@@ -0,0 +1,861 @@
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const app = {
baseUrl: defaultBaseUrl(),
token: localStorage.getItem('bat-api-token') || '',
rememberToken: localStorage.getItem('bat-api-remember-token') === 'true',
refreshTimer: null,
resources: { offset: 0, limit: 50, total: 0, items: [], filter: '' },
schedules: [],
tasks: [],
selectedDaemonTaskId: '',
translation: { tasks: [], selectedTask: null, unitRows: [] },
};
function defaultBaseUrl() {
const saved = localStorage.getItem('bat-api-base-url');
if (saved) return saved;
if (window.location.protocol === 'http:' || window.location.protocol === 'https:') {
return window.location.origin;
}
return 'http://localhost:8080';
}
function html(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char]);
}
function attr(value) {
return html(value).replace(/`/g, '&#96;');
}
function text(id, value) {
const node = $(id);
if (node) node.textContent = value ?? '-';
}
function setHTML(id, value) {
const node = $(id);
if (node) node.innerHTML = value;
}
function apiBase() {
return (app.baseUrl || window.location.origin).replace(/\/+$/, '');
}
async function requestJSON(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(apiBase() + path, { ...options, headers });
const body = await response.text();
let parsed = {};
if (body) {
try {
parsed = JSON.parse(body);
} catch (error) {
throw new Error(`${path} 返回了非 JSON 响应`);
}
}
if (!response.ok) {
const detail = parsed.error?.message || parsed.message || response.statusText;
throw new Error(`${path} HTTP ${response.status}: ${detail}`);
}
return parsed;
}
function postControl(action, payload = {}) {
return requestJSON(`/admin/control/${action}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
function requireToken(scope) {
if (app.token) return true;
toast(`${scope} 需要 admin token`, 'warn');
return false;
}
function fmtBytes(value) {
if (value === null || value === undefined) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value);
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx += 1;
}
const digits = idx === 0 || size >= 10 ? 0 : 1;
return `${size.toFixed(digits)} ${units[idx]}`;
}
function fmtTime(seconds) {
if (!seconds) return '-';
const date = new Date(Number(seconds) * 1000);
return Number.isNaN(date.getTime()) ? String(seconds) : date.toLocaleString();
}
function shortHash(value) {
if (!value) return '-';
const str = String(value);
return str.length > 18 ? `${str.slice(0, 10)}...${str.slice(-6)}` : str;
}
function statusPill(status) {
const value = String(status || 'unknown');
const tone = /(ok|ready|completed|published|healthy|success|translated)/i.test(value)
? 'ok'
: /(fail|error|missing|unavailable|rejected)/i.test(value)
? 'bad'
: /(run|sync|queued|review|proofreading|progress)/i.test(value)
? 'warn'
: 'neutral';
return `<span class="pill pill-${tone}">${html(value)}</span>`;
}
function emptyRow(columns, message) {
return `<tr><td colspan="${columns}" class="empty">${html(message)}</td></tr>`;
}
function toast(message, tone = 'ok') {
const item = document.createElement('div');
item.className = `toast toast-${tone}`;
item.textContent = message;
$('toastRegion').appendChild(item);
window.setTimeout(() => item.remove(), 4200);
}
function setConnection(status, detail) {
const dot = $('connectionDot');
dot.className = `state-dot state-${status}`;
text('connectionTitle', status === 'ok' ? '已连接' : status === 'warn' ? '需要 token' : status === 'loading' ? '刷新中' : '未连接');
text('connectionSubtitle', detail || apiBase());
}
function setAdminPlaceholders() {
text('metricLocalized', 'locked');
text('metricLocalizedNote', '需要 admin token');
text('taskSummary', '需要 admin token');
setHTML('dashboardTaskList', '<div class="empty-block">需要 admin token</div>');
text('scheduleSummary', '需要 admin token');
setHTML('scheduleList', '<div class="empty-block">需要 admin token</div>');
text('translationTaskSummary', '需要 admin token');
setHTML('translationTaskRows', emptyRow(4, '需要 admin token'));
text('daemonTaskSummary', '需要 admin token');
setHTML('daemonTaskRows', emptyRow(5, '需要 admin token'));
text('doctorSummary', '需要 admin token');
setHTML('doctorChecks', '<div class="empty-block">需要 admin token</div>');
text('logSummary', '需要 admin token');
text('daemonLogBox', '');
}
async function refreshAll() {
setConnection('loading', apiBase());
await Promise.allSettled([loadHealthAndRelease(), loadResources(true)]);
if (!app.token) {
setConnection('warn', '未配置 admin token');
setAdminPlaceholders();
return;
}
await Promise.allSettled([
loadSchedules(),
loadDaemonTasks(),
loadDoctor(),
loadDaemonLogs(),
loadTranslationStatus(),
loadTranslationTasks(),
loadHandoff(),
]);
}
async function loadHealthAndRelease() {
try {
const health = await requestJSON('/healthz');
text('metricHealth', health.ready ? 'ready' : 'not ready');
text('metricHealthNote', health.source || 'healthz');
text('metricRpc', health.rpc_available ? 'connected' : 'offline');
text('metricRpcNote', health.socket || '-');
text('metricResources', `${health.present_count ?? 0}/${health.entry_count ?? 0}`);
text('metricResourcesNote', `${health.missing_count ?? 0} missing`);
setConnection('ok', apiBase());
} catch (error) {
setConnection('bad', error.message);
toast(error.message, 'bad');
}
try {
const release = await requestJSON('/v1/release');
const snap = release.snapshot || {};
text('releaseSummary', `${snap.app_version || '-'} / ${snap.bundle_version || '-'} / ${release.source || '-'}`);
renderKV('releaseDetails', {
version_id: snap.version_id,
status_code: snap.status_code || release.status_code,
distribution_status_code: snap.distribution_status_code,
resource_root: release.resource_root,
addressables_root: snap.addressables_root,
manifest_version: release.manifest_version,
entry_count: release.entry_count,
present_count: release.present_count,
missing_count: release.missing_count,
rpc_available: release.rpc_available,
doctor_healthy: release.doctor_healthy,
});
} catch (error) {
text('releaseSummary', error.message);
setHTML('releaseDetails', '');
}
}
function renderKV(id, values) {
const rows = Object.entries(values)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `<dt>${html(key)}</dt><dd>${html(String(value))}</dd>`)
.join('');
setHTML(id, rows || '<dd class="empty">无数据</dd>');
}
async function loadResources(sampleOnly = false) {
const limit = sampleOnly ? 8 : app.resources.limit;
const offset = sampleOnly ? 0 : app.resources.offset;
try {
const data = await requestJSON(`/v1/resources?offset=${offset}&limit=${limit}`);
const items = data.items || [];
if (sampleOnly) {
setHTML('dashboardResourceRows', renderResourceRows(items));
text('resourceSampleSummary', `${items.length}/${data.total ?? items.length}`);
return;
}
app.resources.items = items;
app.resources.total = data.total ?? items.length;
text('resourceListSummary', `${app.resources.total} entries`);
renderResourceList();
} catch (error) {
const target = sampleOnly ? 'dashboardResourceRows' : 'resourceRows';
setHTML(target, emptyRow(4, error.message));
if (!sampleOnly) text('resourceListSummary', error.message);
}
}
function renderResourceRows(items) {
if (!items.length) return emptyRow(4, '没有资源记录');
return items.map((item) => `
<tr>
<td><code>${html(item.relative_path || item.destination || '-')}</code></td>
<td>${html(fmtBytes(item.bytes))}</td>
<td>${statusPill(item.present && item.size_match ? 'present' : item.present ? 'size_mismatch' : 'missing')}</td>
<td><code>${html(shortHash(item.blake3))}</code></td>
</tr>
`).join('');
}
function renderResourceList() {
const needle = app.resources.filter.toLowerCase();
const items = needle
? app.resources.items.filter((item) => String(item.relative_path || item.destination || '').toLowerCase().includes(needle))
: app.resources.items;
setHTML('resourceRows', renderResourceRows(items));
const start = app.resources.total === 0 ? 0 : app.resources.offset + 1;
const end = Math.min(app.resources.offset + app.resources.items.length, app.resources.total);
text('resourcePagerText', `${start}-${end} / ${app.resources.total}`);
$('resourcePrevBtn').disabled = app.resources.offset === 0;
$('resourceNextBtn').disabled = app.resources.offset + app.resources.limit >= app.resources.total;
}
async function loadSchedules() {
if (!app.token) return setAdminPlaceholders();
const query = new URLSearchParams();
const id = $('scheduleIdFilter').value.trim();
const group = $('scheduleGroupFilter').value.trim();
const enabled = $('scheduleEnabledFilter').value;
if (id) query.set('id', id);
if (group) query.set('group', group);
if (enabled) query.set('enabled', enabled);
try {
const data = await requestJSON(`/admin/schedules${query.size ? `?${query}` : ''}`);
app.schedules = normalizeList(data, ['schedules', 'items', 'entries']);
text('scheduleSummary', `${app.schedules.length} schedules`);
setHTML('scheduleList', renderScheduleItems(app.schedules));
} catch (error) {
text('scheduleSummary', error.message);
setHTML('scheduleList', `<div class="empty-block">${html(error.message)}</div>`);
}
}
function normalizeList(data, keys) {
if (Array.isArray(data)) return data;
for (const key of keys) {
if (Array.isArray(data?.[key])) return data[key];
}
return [];
}
function renderScheduleItems(items) {
if (!items.length) return '<div class="empty-block">没有计划</div>';
return items.map((item) => {
const extras = [
item.every_seconds ? `every ${item.every_seconds}s` : '',
item.next_run_unix_seconds ? `next ${fmtTime(item.next_run_unix_seconds)}` : '',
item.remaining_count ? `left ${item.remaining_count}` : '',
].filter(Boolean).join(' / ');
return `
<article class="list-item">
<div>
<strong>${html(item.id || '-')}</strong>
<div class="muted">${html(item.group || '-')} ${extras ? `/ ${html(extras)}` : ''}</div>
</div>
<div class="row-actions">
${statusPill(item.enabled ? 'enabled' : 'disabled')}
<button class="btn btn-small" type="button" data-schedule-run="${attr(item.id || '')}">运行</button>
<button class="btn btn-small btn-danger" type="button" data-schedule-remove="${attr(item.id || '')}">删除</button>
</div>
</article>
`;
}).join('');
}
function schedulePayload() {
const id = $('scheduleIdInput').value.trim();
if (!id) throw new Error('schedule id 不能为空');
const payload = { id, enabled: $('scheduleEnabledInput').checked };
const group = $('scheduleGroupInput').value.trim();
const action = $('scheduleActionInput').value.trim();
const args = $('scheduleArgsInput').value.split(/\n|,/).map((arg) => arg.trim()).filter(Boolean);
if (group) payload.group = group;
if (action) payload.action = action;
if (args.length) payload.args = args;
for (const [field, idName] of [
['delay_seconds', 'scheduleDelayInput'],
['every_seconds', 'scheduleEveryInput'],
['count', 'scheduleCountInput'],
['next_run_unix_seconds', 'scheduleNextRunInput'],
]) {
const raw = $(idName).value.trim();
if (raw) payload[field] = Number(raw);
}
return payload;
}
async function mutateSchedule(action, payload) {
if (!requireToken('调度')) return;
try {
const result = await postControl(action, payload);
toast(`${action} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`);
await loadSchedules();
} catch (error) {
toast(error.message, 'bad');
}
}
async function loadDaemonTasks() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/tasks');
app.tasks = data.tasks || [];
text('daemonTaskSummary', `${app.tasks.length} tasks`);
text('taskSummary', `${app.tasks.length} tasks`);
setHTML('daemonTaskRows', renderDaemonTaskRows(app.tasks));
setHTML('dashboardTaskList', renderDashboardTaskList(app.tasks.slice(0, 5)));
} catch (error) {
text('daemonTaskSummary', error.message);
text('taskSummary', error.message);
setHTML('daemonTaskRows', emptyRow(5, error.message));
setHTML('dashboardTaskList', `<div class="empty-block">${html(error.message)}</div>`);
}
}
function renderDaemonTaskRows(tasks) {
if (!tasks.length) return emptyRow(5, '没有任务记录');
return tasks.map((task) => `
<tr class="clickable" data-task-id="${attr(task.id)}">
<td><code>${html(task.id)}</code></td>
<td>${html(task.kind || '-')}</td>
<td>${statusPill(task.status)}</td>
<td>${html(task.stage || '-')}</td>
<td>${html(fmtTime(task.updated_at))}</td>
</tr>
`).join('');
}
function renderDashboardTaskList(tasks) {
if (!tasks.length) return '<div class="empty-block">没有任务记录</div>';
return tasks.map((task) => `
<article class="list-item compact-item">
<div>
<strong>${html(task.kind || task.id)}</strong>
<div class="muted">${html(task.id)} / ${html(task.stage || '-')}</div>
</div>
${statusPill(task.status)}
</article>
`).join('');
}
async function selectDaemonTask(taskId) {
app.selectedDaemonTaskId = taskId;
text('daemonTaskDetailSummary', taskId);
await Promise.allSettled([loadDaemonTaskStatus(taskId), loadDaemonTaskLogs(taskId)]);
}
async function loadDaemonTaskStatus(taskId) {
try {
const task = await requestJSON(`/admin/tasks/status?task_id=${encodeURIComponent(taskId)}`);
renderKV('daemonTaskDetails', {
id: task.id,
kind: task.kind,
status: task.status,
stage: task.stage,
message: task.message,
created_at: fmtTime(task.created_at),
updated_at: fmtTime(task.updated_at),
started_at: fmtTime(task.started_at),
finished_at: fmtTime(task.finished_at),
error: task.error?.message,
});
} catch (error) {
setHTML('daemonTaskDetails', `<dd class="empty">${html(error.message)}</dd>`);
}
}
async function loadDaemonTaskLogs(taskId) {
try {
const logs = await requestJSON(`/admin/tasks/logs?task_id=${encodeURIComponent(taskId)}`);
text('daemonTaskLogBox', (logs.lines || []).join('\n'));
} catch (error) {
text('daemonTaskLogBox', error.message);
}
}
async function loadDoctor() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/diagnostics');
text('doctorSummary', data.healthy ? 'healthy' : (data.status || 'unhealthy'));
const checks = data.checks || [];
setHTML('doctorChecks', checks.length ? checks.map((check) => `
<article class="list-item compact-item">
<div>
<strong>${html(check.name || '-')}</strong>
<div class="muted">${html(check.message || '-')}</div>
</div>
${statusPill(check.ok ? 'ok' : 'failed')}
</article>
`).join('') : '<div class="empty-block">没有诊断项</div>');
} catch (error) {
text('doctorSummary', error.message);
setHTML('doctorChecks', `<div class="empty-block">${html(error.message)}</div>`);
}
}
async function loadDaemonLogs() {
if (!app.token) return setAdminPlaceholders();
const tail = Math.min(2000, Math.max(1, Number($('logTailInput').value || 200)));
try {
const data = await requestJSON(`/admin/logs?tail=${tail}`);
text('logSummary', `${data.returned_lines ?? 0}/${data.total_lines ?? 0} lines`);
text('daemonLogBox', data.content || '');
} catch (error) {
text('logSummary', error.message);
text('daemonLogBox', error.message);
}
}
async function loadTranslationStatus() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/translation/status');
const code = data.status_code || data.localized_status_code || data.localized_release_status || data.status || 'unknown';
text('metricLocalized', code);
text('metricLocalizedNote', data.current_localized_release_id || data.localized_release_id || data.message || '-');
text('localizedSummary', `${code} / ${data.localized_release_id || data.current_localized_release_id || '-'}`);
} catch (error) {
text('metricLocalized', 'error');
text('metricLocalizedNote', error.message);
text('localizedSummary', error.message);
}
}
async function loadHandoff() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/translation/handoff');
const handoff = data.handoff || data;
text('handoffSummary', handoff.job?.status || data.status || (data.available === false ? 'unavailable' : 'available'));
renderKV('handoffDetails', {
available: data.available,
current_version_id: data.current_version_id,
job_id: handoff.job?.job_id,
job_status: handoff.job?.status,
unit_count: handoff.job?.unit_count,
provider_runs: handoff.provider_runs?.length,
updated_unix_seconds: fmtTime(handoff.job?.updated_unix_seconds),
});
} catch (error) {
text('handoffSummary', error.message);
setHTML('handoffDetails', '');
}
}
async function loadTranslationTasks() {
if (!app.token) return setAdminPlaceholders();
const query = new URLSearchParams({ offset: '0', limit: '50' });
const workerStatus = $('translationStatusFilter').value;
const destination = $('translationDestinationFilter').value.trim();
const taskId = $('translationTaskFilter').value.trim();
if (workerStatus) query.set('worker_status', workerStatus);
if (destination) query.set('destination', destination);
if (taskId) query.set('task_id', taskId);
try {
const data = await requestJSON(`/admin/translation/tasks?${query}`);
app.translation.tasks = data.entries || [];
text('translationTaskSummary', `${data.total_entries ?? app.translation.tasks.length} tasks`);
setHTML('translationTaskRows', renderTranslationTaskRows(app.translation.tasks));
} catch (error) {
text('translationTaskSummary', error.message);
setHTML('translationTaskRows', emptyRow(4, error.message));
}
}
function taskCore(entry) {
return entry?.task || entry || {};
}
function taskID(entry) {
return taskCore(entry).task_id || entry?.task_id || entry?.unit_id || '';
}
function renderTranslationTaskRows(tasks) {
if (!tasks.length) return emptyRow(4, '没有翻译任务');
return tasks.map((entry) => {
const task = taskCore(entry);
const resultCount = (entry.translation_results || task.translation_results || []).length;
return `
<tr class="clickable" data-translation-task-id="${attr(taskID(entry))}">
<td><code>${html(taskID(entry))}</code></td>
<td>${html(task.destination || '-')}<div class="muted">${html(task.archive_entry || '')}</div></td>
<td>${statusPill(entry.task_status || entry.worker_status || task.status)}</td>
<td>${html(String(resultCount))}</td>
</tr>
`;
}).join('');
}
async function selectTranslationTask(entry) {
app.translation.selectedTask = entry;
const task = taskCore(entry);
text('translationDetailSummary', taskID(entry));
$('manualProviderRunInput').value = entry.provider_run_id || '';
await loadTextUnitsForTask(entry);
}
async function loadTextUnitsForTask(entry) {
const task = taskCore(entry);
const existingResults = new Map((entry.translation_results || []).map((result) => [result.unit_id, result]));
let units = [];
if (task.destination) {
const query = new URLSearchParams({ destination: task.destination, limit: '1000' });
if (task.archive_entry) query.set('archive_entry', task.archive_entry);
try {
const data = await requestJSON(`/admin/parse/text-units?${query}`);
units = data.entries || [];
} catch (error) {
toast(error.message, 'bad');
}
}
if (!units.length && existingResults.size) {
units = Array.from(existingResults.values()).map((result) => ({
id: result.unit_id,
source_text: result.source_text,
destination: task.destination,
archive_entry: task.archive_entry,
}));
}
app.translation.unitRows = units.map((unit) => {
const id = unit.id || unit.unit_id;
const existing = existingResults.get(id) || {};
return {
id,
sourceText: unit.source_text || existing.source_text || '',
translatedText: existing.translated_text || '',
destination: unit.destination || task.destination || '',
archiveEntry: unit.archive_entry || task.archive_entry || '',
serializedFile: unit.serialized_file || '',
pathID: unit.path_id,
classID: unit.class_id,
fieldPath: unit.field_path || '',
assetName: unit.asset_name || '',
format: unit.format || '',
};
});
renderUnitEditor();
}
function renderUnitEditor() {
const rows = app.translation.unitRows;
if (!app.translation.selectedTask) {
setHTML('unitEditor', '<div class="empty-block">选择一个翻译任务</div>');
return;
}
if (!rows.length) {
setHTML('unitEditor', '<div class="empty-block">当前任务没有可显示 TextUnit 明细</div>');
return;
}
setHTML('unitEditor', rows.map((row, index) => `
<article class="unit-card">
<div class="unit-head">
<div>
<strong>${html(row.id)}</strong>
<div class="muted">${html(row.destination)}${row.archiveEntry ? ` / ${html(row.archiveEntry)}` : ''}</div>
</div>
${statusPill(row.translatedText ? 'reviewing' : 'empty')}
</div>
<div class="unit-meta">
<span>field: ${html(row.fieldPath || '-')}</span>
<span>asset: ${html(row.assetName || '-')}</span>
<span>path_id: ${html(row.pathID ?? '-')}</span>
<span>class_id: ${html(row.classID ?? '-')}</span>
<span>format: ${html(row.format || '-')}</span>
</div>
<label class="source-label">Source
<textarea readonly rows="3">${html(row.sourceText)}</textarea>
</label>
<label>Current translation
<textarea class="unit-translation" data-unit-index="${index}" rows="4">${html(row.translatedText)}</textarea>
</label>
</article>
`).join(''));
}
async function saveManualResults() {
if (!requireToken('人工校对')) return;
const entry = app.translation.selectedTask;
if (!entry) return toast('请选择翻译任务', 'warn');
const results = $$('.unit-translation').map((editor) => {
const row = app.translation.unitRows[Number(editor.dataset.unitIndex)];
return {
unit_id: row.id,
source_text: row.sourceText,
translated_text: editor.value,
};
}).filter((result) => result.translated_text.trim() !== '');
if (!results.length) return toast('没有可保存的译文', 'warn');
const payload = {
task_id: taskID(entry),
status: 'completed',
provider: 'manual',
translation_results: results,
};
const providerRunID = $('manualProviderRunInput').value.trim();
if (providerRunID) payload.provider_run_id = providerRunID;
try {
await postControl('translation-task-update', payload);
toast(`已保存 ${results.length} 条校对结果`);
await Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]);
} catch (error) {
toast(error.message, 'bad');
}
}
async function runTranslationWorker() {
if (!requireToken('Provider worker')) return;
const payload = {
provider: $('workerProviderInput').value,
concurrency: Number($('workerConcurrencyInput').value || 8),
max_attempts: Number($('workerMaxAttemptsInput').value || 3),
lease_seconds: Number($('workerLeaseInput').value || 300),
retry_backoff_seconds: Number($('workerBackoffInput').value || 5),
};
const maxTasks = $('workerMaxTasksInput').value.trim();
const workerId = $('workerIdInput').value.trim();
const fixture = $('workerFixtureInput').value.trim();
if (maxTasks) payload.max_tasks = Number(maxTasks);
if (workerId) payload.worker_id = workerId;
if (fixture) payload.fixture_path = fixture;
try {
const result = await postControl('translation-worker-run', payload);
toast(`translation-worker-run accepted: ${result.result?.task_id || 'queued'}`);
await loadDaemonTasks();
} catch (error) {
toast(error.message, 'bad');
}
}
async function publishLocalized() {
if (!requireToken('汉化发布')) return;
const payload = { from_worker: true, force: $('localizedForceInput').checked };
const releaseID = $('localizedReleaseInput').value.trim();
if (releaseID) payload.localized_release_id = releaseID;
try {
await postControl('localized-publish', payload);
toast('localized-publish accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
}
async function rollbackLocalized() {
if (!requireToken('汉化回滚')) return;
const payload = {};
const releaseID = $('localizedReleaseInput').value.trim();
if (releaseID) payload.localized_release_id = releaseID;
if (!window.confirm('确认回滚当前汉化 release?')) return;
try {
await postControl('localized-rollback', payload);
toast('localized-rollback accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
}
function switchView(view) {
$$('.nav-item').forEach((item) => item.classList.toggle('is-active', item.dataset.view === view));
$$('.view').forEach((section) => section.classList.toggle('is-active', section.id === `view-${view}`));
const active = $(`view-${view}`);
text('viewTitle', active?.dataset.title || view);
text('viewSubtitle', active?.dataset.subtitle || '');
if (view === 'resources') loadResources();
if (view === 'schedules') loadSchedules();
if (view === 'translation') Promise.allSettled([loadTranslationTasks(), loadTranslationStatus(), loadHandoff()]);
if (view === 'tasks') loadDaemonTasks();
if (view === 'logs') Promise.allSettled([loadDoctor(), loadDaemonLogs()]);
}
function bindEvents() {
$$('.nav-item').forEach((item) => item.addEventListener('click', () => switchView(item.dataset.view)));
$$('[data-jump]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.jump)));
$('refreshAllBtn').addEventListener('click', refreshAll);
$('autoRefreshInput').addEventListener('change', configureAutoRefresh);
$('resourceLimitInput').addEventListener('change', () => {
app.resources.limit = Number($('resourceLimitInput').value || 50);
app.resources.offset = 0;
loadResources();
});
$('resourceFilterInput').addEventListener('input', () => {
app.resources.filter = $('resourceFilterInput').value.trim();
renderResourceList();
});
$('resourcePrevBtn').addEventListener('click', () => {
app.resources.offset = Math.max(0, app.resources.offset - app.resources.limit);
loadResources();
});
$('resourceNextBtn').addEventListener('click', () => {
app.resources.offset += app.resources.limit;
loadResources();
});
$$('[data-control]').forEach((button) => button.addEventListener('click', async () => {
if (!requireToken(button.dataset.control)) return;
try {
const result = await postControl(button.dataset.control, button.dataset.control === 'sync' ? { force: false } : {});
toast(`${button.dataset.control} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`);
await refreshAll();
} catch (error) {
toast(error.message, 'bad');
}
}));
$('scheduleRefreshBtn').addEventListener('click', loadSchedules);
['scheduleIdFilter', 'scheduleGroupFilter', 'scheduleEnabledFilter'].forEach((id) => $(id).addEventListener('input', loadSchedules));
$('scheduleAddBtn').addEventListener('click', () => {
try { mutateSchedule('schedule-add', schedulePayload()); } catch (error) { toast(error.message, 'warn'); }
});
$('scheduleUpdateBtn').addEventListener('click', () => {
try { mutateSchedule('schedule-update', schedulePayload()); } catch (error) { toast(error.message, 'warn'); }
});
$('scheduleList').addEventListener('click', (event) => {
const runID = event.target.closest('[data-schedule-run]')?.dataset.scheduleRun;
const removeID = event.target.closest('[data-schedule-remove]')?.dataset.scheduleRemove;
if (runID) mutateSchedule('schedule-run', { id: runID, force: true });
if (removeID && window.confirm(`删除计划 ${removeID}?`)) mutateSchedule('schedule-remove', { id: removeID });
});
$('daemonTaskRefreshBtn').addEventListener('click', loadDaemonTasks);
$('daemonTaskRows').addEventListener('click', (event) => {
const row = event.target.closest('[data-task-id]');
if (row) selectDaemonTask(row.dataset.taskId);
});
$('daemonTaskCancelBtn').addEventListener('click', async () => {
if (!app.selectedDaemonTaskId || !requireToken('任务取消')) return;
try {
await postControl('task-cancel', { task_id: app.selectedDaemonTaskId });
toast('task.cancel accepted');
await selectDaemonTask(app.selectedDaemonTaskId);
} catch (error) {
toast(error.message, 'bad');
}
});
$('doctorRefreshBtn').addEventListener('click', () => Promise.allSettled([loadDoctor(), loadDaemonLogs()]));
$('logTailInput').addEventListener('change', loadDaemonLogs);
$('translationRefreshBtn').addEventListener('click', () => Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]));
['translationStatusFilter', 'translationDestinationFilter', 'translationTaskFilter'].forEach((id) => $(id).addEventListener('input', loadTranslationTasks));
$('translationTaskRows').addEventListener('click', (event) => {
const row = event.target.closest('[data-translation-task-id]');
if (!row) return;
const entry = app.translation.tasks.find((item) => taskID(item) === row.dataset.translationTaskId);
if (entry) selectTranslationTask(entry);
});
$('workerRunBtn').addEventListener('click', runTranslationWorker);
$('manualSaveBtn').addEventListener('click', saveManualResults);
$('proofreadBtn').addEventListener('click', async () => {
if (!requireToken('人工校对状态')) return;
try {
await postControl('translation-proofread', {});
toast('translation.proofread accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
});
$('localizedPublishBtn').addEventListener('click', publishLocalized);
$('localizedRollbackBtn').addEventListener('click', rollbackLocalized);
$('saveSettingsBtn').addEventListener('click', () => {
app.baseUrl = $('baseUrlInput').value.trim() || defaultBaseUrl();
app.token = $('tokenInput').value.trim();
app.rememberToken = $('rememberTokenInput').checked;
localStorage.setItem('bat-api-base-url', app.baseUrl);
localStorage.setItem('bat-api-remember-token', String(app.rememberToken));
if (app.rememberToken && app.token) localStorage.setItem('bat-api-token', app.token);
if (!app.rememberToken) localStorage.removeItem('bat-api-token');
refreshAll();
});
$('forgetSettingsBtn').addEventListener('click', () => {
localStorage.removeItem('bat-api-base-url');
localStorage.removeItem('bat-api-token');
localStorage.removeItem('bat-api-remember-token');
app.baseUrl = defaultBaseUrl();
app.token = '';
app.rememberToken = false;
syncSettingsForm();
refreshAll();
});
}
function syncSettingsForm() {
$('baseUrlInput').value = app.baseUrl;
$('tokenInput').value = app.token;
$('rememberTokenInput').checked = app.rememberToken;
}
function configureAutoRefresh() {
if (app.refreshTimer) window.clearInterval(app.refreshTimer);
app.refreshTimer = null;
if ($('autoRefreshInput').checked) {
app.refreshTimer = window.setInterval(refreshAll, 15000);
}
}
syncSettingsForm();
bindEvents();
configureAutoRefresh();
refreshAll();
+8
View File
@@ -0,0 +1,8 @@
package web
import "embed"
// Assets 是 bat-api dashboard 的无构建静态资源。
//
//go:embed index.html app.js styles.css
var Assets embed.FS
+421
View File
@@ -0,0 +1,421 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bat-api Dashboard - BlueArchiveToolkit</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">BA</div>
<div>
<div class="brand-title">bat-api Dashboard</div>
<div class="brand-subtitle">BlueArchiveToolkit</div>
</div>
</div>
<nav class="nav" aria-label="dashboard navigation">
<button class="nav-item is-active" type="button" data-view="dashboard">总览</button>
<button class="nav-item" type="button" data-view="resources">资源</button>
<button class="nav-item" type="button" data-view="schedules">调度</button>
<button class="nav-item" type="button" data-view="translation">翻译</button>
<button class="nav-item" type="button" data-view="tasks">任务</button>
<button class="nav-item" type="button" data-view="logs">日志</button>
<button class="nav-item" type="button" data-view="settings">设置</button>
</nav>
<div class="connection-box">
<span class="state-dot" id="connectionDot"></span>
<div>
<div class="connection-title" id="connectionTitle">未连接</div>
<div class="connection-subtitle" id="connectionSubtitle">等待刷新</div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h1 id="viewTitle">总览</h1>
<p id="viewSubtitle">读取 bat-api 与 Rust daemon 的当前状态。</p>
</div>
<div class="topbar-actions">
<label class="inline-check">
<input id="autoRefreshInput" type="checkbox" checked>
自动刷新
</label>
<button class="btn btn-primary" id="refreshAllBtn" type="button">刷新</button>
</div>
</header>
<section class="view is-active" id="view-dashboard" data-title="总览" data-subtitle="读取 bat-api 与 Rust daemon 的当前状态。">
<div class="status-grid">
<div class="metric">
<div class="metric-label">bat-api</div>
<div class="metric-value" id="metricHealth">unknown</div>
<div class="metric-note" id="metricHealthNote">-</div>
</div>
<div class="metric">
<div class="metric-label">Rust RPC</div>
<div class="metric-value" id="metricRpc">unknown</div>
<div class="metric-note" id="metricRpcNote">-</div>
</div>
<div class="metric">
<div class="metric-label">资源完整度</div>
<div class="metric-value" id="metricResources">-</div>
<div class="metric-note" id="metricResourcesNote">-</div>
</div>
<div class="metric">
<div class="metric-label">汉化状态</div>
<div class="metric-value" id="metricLocalized">unknown</div>
<div class="metric-note" id="metricLocalizedNote">需要 admin token</div>
</div>
</div>
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>当前 release</h2>
<p id="releaseSummary">尚未读取</p>
</div>
</div>
<dl class="kv-list" id="releaseDetails"></dl>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>最近任务</h2>
<p id="taskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" type="button" data-jump="tasks">打开任务</button>
</div>
<div class="list" id="dashboardTaskList"></div>
</section>
</div>
<section class="panel">
<div class="panel-head">
<div>
<h2>资源样本</h2>
<p id="resourceSampleSummary">读取 /v1/resources</p>
</div>
<button class="btn btn-small" type="button" data-jump="resources">查看资源</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>路径</th>
<th>大小</th>
<th>状态</th>
<th>BLAKE3</th>
</tr>
</thead>
<tbody id="dashboardResourceRows"></tbody>
</table>
</div>
</section>
</section>
<section class="view" id="view-resources" data-title="资源" data-subtitle="查看当前发布 release 的资源清单,并触发同步、校验或修复。">
<section class="panel">
<div class="panel-head split">
<div>
<h2>资源清单</h2>
<p id="resourceListSummary">-</p>
</div>
<div class="row-actions">
<button class="btn" type="button" data-control="sync">同步</button>
<button class="btn" type="button" data-control="verify">校验</button>
<button class="btn btn-danger" type="button" data-control="repair">修复</button>
</div>
</div>
<div class="filter-row">
<input id="resourceFilterInput" type="search" placeholder="过滤已加载路径">
<select id="resourceLimitInput">
<option value="20">20</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>路径</th>
<th>大小</th>
<th>状态</th>
<th>BLAKE3</th>
</tr>
</thead>
<tbody id="resourceRows"></tbody>
</table>
</div>
<div class="pager">
<span id="resourcePagerText">-</span>
<div>
<button class="btn btn-small" id="resourcePrevBtn" type="button">上一页</button>
<button class="btn btn-small" id="resourceNextBtn" type="button">下一页</button>
</div>
</div>
</section>
</section>
<section class="view" id="view-schedules" data-title="调度" data-subtitle="管理 Rust 持久化 schedule,覆盖资源拉取、解析、翻译和发布任务。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>计划列表</h2>
<p id="scheduleSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="scheduleRefreshBtn" type="button">刷新</button>
</div>
<div class="filter-row">
<input id="scheduleIdFilter" type="search" placeholder="id">
<input id="scheduleGroupFilter" type="search" placeholder="group: res / parse / i18n">
<select id="scheduleEnabledFilter">
<option value="">全部</option>
<option value="true">启用</option>
<option value="false">停用</option>
</select>
</div>
<div class="list" id="scheduleList"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>新增或更新</h2>
<p>schedule.add / schedule.update</p>
</div>
</div>
<form class="form" id="scheduleForm">
<label>ID <input id="scheduleIdInput" autocomplete="off"></label>
<label>Group <input id="scheduleGroupInput" placeholder="res"></label>
<label>Command <input id="scheduleActionInput" placeholder="res sync"></label>
<label>Args <textarea id="scheduleArgsInput" rows="3" placeholder="每行一个参数"></textarea></label>
<div class="form-grid">
<label>Delay seconds <input id="scheduleDelayInput" type="number" min="0"></label>
<label>Every seconds <input id="scheduleEveryInput" type="number" min="1"></label>
<label>Count <input id="scheduleCountInput" type="number" min="1"></label>
<label>Next run unix <input id="scheduleNextRunInput" type="number" min="0"></label>
</div>
<label class="inline-check"><input id="scheduleEnabledInput" type="checkbox" checked>启用</label>
<div class="row-actions">
<button class="btn btn-primary" id="scheduleAddBtn" type="button">新增</button>
<button class="btn" id="scheduleUpdateBtn" type="button">更新</button>
</div>
</form>
</section>
</div>
</section>
<section class="view" id="view-translation" data-title="翻译" data-subtitle="查看翻译任务、触发 provider worker、编辑人工校对结果并发布汉化资源。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head split">
<div>
<h2>翻译任务</h2>
<p id="translationTaskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="translationRefreshBtn" type="button">刷新</button>
</div>
<div class="filter-row">
<select id="translationStatusFilter">
<option value="">全部状态</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="failed">failed</option>
<option value="completed">completed</option>
<option value="skipped">skipped</option>
</select>
<input id="translationDestinationFilter" type="search" placeholder="destination">
<input id="translationTaskFilter" type="search" placeholder="task_id">
</div>
<div class="table-wrap compact">
<table>
<thead>
<tr>
<th>任务</th>
<th>资源</th>
<th>状态</th>
<th>结果</th>
</tr>
</thead>
<tbody id="translationTaskRows"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Provider worker</h2>
<p>translation.worker.run</p>
</div>
</div>
<form class="form">
<div class="form-grid">
<label>Provider
<select id="workerProviderInput">
<option value="mock">mock</option>
<option value="crowdin">crowdin</option>
</select>
</label>
<label>Concurrency <input id="workerConcurrencyInput" type="number" min="1" max="256" value="8"></label>
<label>Max attempts <input id="workerMaxAttemptsInput" type="number" min="1" value="3"></label>
<label>Lease seconds <input id="workerLeaseInput" type="number" min="1" value="300"></label>
<label>Backoff seconds <input id="workerBackoffInput" type="number" min="0" value="5"></label>
<label>Max tasks <input id="workerMaxTasksInput" type="number" min="1"></label>
</div>
<label>Worker ID <input id="workerIdInput" placeholder="dashboard-worker"></label>
<label>Fixture path <input id="workerFixtureInput" placeholder="/tmp/mock-provider.json"></label>
<button class="btn btn-primary" id="workerRunBtn" type="button">启动 worker</button>
</form>
</section>
</div>
<section class="panel">
<div class="panel-head split">
<div>
<h2>人工校对</h2>
<p id="translationDetailSummary">选择一个翻译任务</p>
</div>
<div class="row-actions">
<button class="btn" id="proofreadBtn" type="button">标记人工校对中</button>
<button class="btn btn-primary" id="manualSaveBtn" type="button">保存校对结果</button>
</div>
</div>
<div class="form manual-meta">
<label>Provider run ID <input id="manualProviderRunInput" placeholder="留空时由 Rust 生成"></label>
</div>
<div class="unit-editor" id="unitEditor"></div>
</section>
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>汉化发布</h2>
<p id="localizedSummary">需要 admin token</p>
</div>
</div>
<div class="form">
<label>Localized release ID <input id="localizedReleaseInput" placeholder="可选"></label>
<label class="inline-check"><input id="localizedForceInput" type="checkbox">强制发布</label>
<div class="row-actions">
<button class="btn btn-primary" id="localizedPublishBtn" type="button">从 worker 结果发布</button>
<button class="btn btn-danger" id="localizedRollbackBtn" type="button">回滚</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>交接视图</h2>
<p id="handoffSummary">translation.handoff</p>
</div>
</div>
<dl class="kv-list" id="handoffDetails"></dl>
</section>
</div>
</section>
<section class="view" id="view-tasks" data-title="任务" data-subtitle="轮询 Rust daemon 持久化任务,查看状态、日志并发起取消。">
<section class="panel">
<div class="panel-head split">
<div>
<h2>任务列表</h2>
<p id="daemonTaskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="daemonTaskRefreshBtn" type="button">刷新</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Task ID</th>
<th>Kind</th>
<th>Status</th>
<th>Stage</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="daemonTaskRows"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head split">
<div>
<h2>任务详情</h2>
<p id="daemonTaskDetailSummary">选择一个任务</p>
</div>
<button class="btn btn-danger" id="daemonTaskCancelBtn" type="button">取消任务</button>
</div>
<dl class="kv-list" id="daemonTaskDetails"></dl>
<pre class="log-box" id="daemonTaskLogBox"></pre>
</section>
</section>
<section class="view" id="view-logs" data-title="日志" data-subtitle="读取 Rust daemon 诊断和最近日志。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head split">
<div>
<h2>诊断</h2>
<p id="doctorSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="doctorRefreshBtn" type="button">刷新</button>
</div>
<div class="list" id="doctorChecks"></div>
</section>
<section class="panel">
<div class="panel-head split">
<div>
<h2>Daemon log</h2>
<p id="logSummary">需要 admin token</p>
</div>
<label class="inline-label">Tail <input id="logTailInput" type="number" min="1" max="2000" value="200"></label>
</div>
<pre class="log-box" id="daemonLogBox"></pre>
</section>
</div>
</section>
<section class="view" id="view-settings" data-title="设置" data-subtitle="配置 dashboard 调用 bat-api 的地址和 admin token。">
<section class="panel settings-panel">
<div class="panel-head">
<div>
<h2>连接</h2>
<p>默认使用当前页面同源 bat-api。</p>
</div>
</div>
<form class="form">
<label>Base URL <input id="baseUrlInput" autocomplete="off"></label>
<label>Admin token <input id="tokenInput" type="password" autocomplete="off"></label>
<label class="inline-check"><input id="rememberTokenInput" type="checkbox">在本浏览器保存 token</label>
<div class="row-actions">
<button class="btn btn-primary" id="saveSettingsBtn" type="button">保存并刷新</button>
<button class="btn" id="forgetSettingsBtn" type="button">清除</button>
</div>
</form>
</section>
</section>
</main>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+670
View File
@@ -0,0 +1,670 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--surface: #ffffff;
--surface-muted: #f0f3f8;
--border: #d9e0ea;
--border-strong: #bcc7d5;
--text: #172033;
--muted: #657286;
--blue: #2563eb;
--blue-dark: #1d4ed8;
--green: #15803d;
--amber: #b45309;
--red: #b91c1c;
--shadow: 0 10px 30px rgba(23, 32, 51, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 0;
}
code,
pre {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.shell {
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 16px;
background: #111827;
color: #eef2ff;
display: flex;
flex-direction: column;
gap: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 8px;
background: #2563eb;
color: #ffffff;
display: grid;
place-items: center;
font-weight: 800;
font-size: 13px;
}
.brand-title {
font-weight: 700;
font-size: 15px;
}
.brand-subtitle,
.connection-subtitle,
.muted {
color: var(--muted);
}
.brand-subtitle,
.connection-subtitle {
color: #94a3b8;
font-size: 12px;
}
.nav {
display: grid;
gap: 5px;
}
.nav-item {
width: 100%;
padding: 9px 10px;
border-radius: 6px;
background: transparent;
color: #cbd5e1;
text-align: left;
cursor: pointer;
}
.nav-item:hover,
.nav-item.is-active {
background: rgba(255, 255, 255, 0.09);
color: #ffffff;
}
.connection-box {
margin-top: auto;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.connection-title {
font-weight: 700;
}
.state-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 50%;
background: #94a3b8;
flex: 0 0 auto;
}
.state-ok {
background: #22c55e;
}
.state-warn,
.state-loading {
background: #f59e0b;
}
.state-bad {
background: #ef4444;
}
.main {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 22px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
font-weight: 750;
}
h2 {
font-size: 15px;
font-weight: 730;
}
.topbar p,
.panel-head p,
.metric-note,
.muted {
color: var(--muted);
font-size: 12px;
}
.topbar-actions,
.row-actions,
.filter-row,
.pager {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.view {
display: none;
}
.view.is-active {
display: grid;
gap: 16px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.metric {
padding: 14px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
}
.metric-value {
margin-top: 6px;
font-size: 22px;
font-weight: 760;
overflow-wrap: anywhere;
}
.panel {
overflow: hidden;
}
.panel-head {
min-height: 58px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-head.split {
align-items: flex-start;
}
.grid {
display: grid;
gap: 16px;
}
.two-col {
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr);
}
.btn {
min-height: 34px;
padding: 7px 12px;
border-radius: 6px;
border: 1px solid var(--border-strong);
background: #ffffff;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.btn:hover {
border-color: var(--blue);
color: var(--blue-dark);
}
.btn-primary {
border-color: var(--blue);
background: var(--blue);
color: #ffffff;
}
.btn-primary:hover {
background: var(--blue-dark);
color: #ffffff;
}
.btn-danger {
border-color: #fecaca;
color: var(--red);
}
.btn-small {
min-height: 28px;
padding: 5px 9px;
font-size: 12px;
}
.inline-check,
.inline-label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.inline-label input {
width: 80px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
padding: 8px 10px;
outline: none;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
textarea {
resize: vertical;
}
.filter-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.filter-row input,
.filter-row select {
max-width: 260px;
}
.form {
display: grid;
gap: 11px;
padding: 14px 16px;
}
.form label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.manual-meta {
border-bottom: 1px solid var(--border);
}
.manual-meta label {
max-width: 420px;
}
.table-wrap {
width: 100%;
overflow: auto;
}
.table-wrap.compact {
max-height: 560px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
}
td code {
overflow-wrap: anywhere;
}
tr.clickable {
cursor: pointer;
}
tr.clickable:hover {
background: var(--surface-muted);
}
.empty,
.empty-block {
color: var(--muted);
text-align: center;
}
.empty-block {
padding: 18px;
}
.pager {
justify-content: space-between;
padding: 12px 16px;
}
.list {
display: grid;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.list-item:last-child {
border-bottom: 0;
}
.compact-item {
padding-block: 10px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-muted);
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.pill-ok {
border-color: #bbf7d0;
background: #f0fdf4;
color: var(--green);
}
.pill-warn {
border-color: #fde68a;
background: #fffbeb;
color: var(--amber);
}
.pill-bad {
border-color: #fecaca;
background: #fef2f2;
color: var(--red);
}
.pill-neutral {
border-color: var(--border);
background: var(--surface-muted);
color: var(--muted);
}
.kv-list {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 0;
padding: 6px 16px 14px;
}
.kv-list dt,
.kv-list dd {
margin: 0;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.kv-list dt {
color: var(--muted);
font-size: 12px;
}
.kv-list dd {
overflow-wrap: anywhere;
}
.log-box {
min-height: 280px;
margin: 0;
padding: 14px;
background: #0f172a;
color: #dbeafe;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.unit-editor {
display: grid;
gap: 12px;
padding: 14px 16px 16px;
}
.unit-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
background: #fbfcfe;
display: grid;
gap: 10px;
}
.unit-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.unit-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.unit-meta span {
padding: 3px 7px;
border-radius: 6px;
background: var(--surface-muted);
color: var(--muted);
font-size: 12px;
}
.source-label textarea {
background: #f8fafc;
color: #334155;
}
.settings-panel {
max-width: 720px;
}
.toast-region {
position: fixed;
right: 18px;
bottom: 18px;
display: grid;
gap: 8px;
z-index: 30;
}
.toast {
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
border-radius: 8px;
box-shadow: var(--shadow);
background: #ecfdf5;
color: var(--green);
border: 1px solid #bbf7d0;
}
.toast-warn {
background: #fffbeb;
color: var(--amber);
border-color: #fde68a;
}
.toast-bad {
background: #fef2f2;
color: var(--red);
border-color: #fecaca;
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
}
.nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.two-col,
.status-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.main {
padding: 16px;
}
.topbar {
flex-direction: column;
}
.topbar-actions,
.row-actions,
.filter-row {
width: 100%;
}
.btn,
.filter-row input,
.filter-row select {
max-width: none;
}
.status-grid,
.two-col,
.form-grid,
.nav {
grid-template-columns: 1fr;
}
.kv-list {
grid-template-columns: 1fr;
}
.kv-list dt {
border-bottom: 0;
padding-bottom: 2px;
}
.kv-list dd {
padding-top: 0;
}
}