mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
+9
-9
@@ -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 支持本地 fixture;up-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 支持本地 fixture;up-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 MVP(issue #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` 独立跟踪。
|
||||||
- Web(G-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 fixture,Go 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 fixture,Go 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 #1(P0,主体已实现):`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,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
1. Issue #1(P0,主体已实现):`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,不走 FFI(FFI 降级说明见 `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
@@ -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
@@ -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 10:Web 管理后台
|
### Milestone 10:Web 管理后台
|
||||||
|
|
||||||
**目标**:为翻译协作和资源管理提供可用后台。
|
**目标**:在已落地的 `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 解析/重打包和官方资源长期运行报告。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN 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 MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN 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
|
|||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Rust:CAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。
|
- Rust:CAS、官方资源同步核心、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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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`、
|
||||||
|
|||||||
@@ -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 或应急只读诊断。
|
||||||
|
|
||||||
|
|||||||
@@ -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 状态、发布与显式回滚 |
|
||||||
|
|
||||||
|
|||||||
@@ -259,19 +259,24 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
|||||||
|
|
||||||
排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。
|
排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。
|
||||||
|
|
||||||
### G-010:Web 管理后台尚未实现
|
### G-010:完整 Web 管理后台仍未实现
|
||||||
|
|
||||||
现象:
|
现象:
|
||||||
|
|
||||||
- `web/` 只有目录结构。
|
- `web/` 已提供无构建内嵌 dashboard MVP,作为 `bat-api` 静态资产服务于
|
||||||
|
`/admin/dashboard/`。
|
||||||
|
- 当前页面可调用已鉴权的资源、调度、任务、日志、parse、翻译和 localized 发布/回滚
|
||||||
|
接口,但不提供独立登录、角色权限、术语管理、批量审核工作流或构建型前端工程。
|
||||||
|
|
||||||
影响:
|
影响:
|
||||||
|
|
||||||
- 翻译审核、术语管理、Dashboard 无 UI。
|
- 基础资源/调度/翻译控制可以在 dashboard 上完成。
|
||||||
|
- 协作式翻译审核、术语管理和权限隔离仍缺少完整 UI。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
- 登录、权限、翻译审核、术语管理基础流程可用。
|
- 登录、权限、翻译审核、术语管理基础流程可用。
|
||||||
|
- Dashboard E2E、静态资产构建/发布策略和错误态交互纳入常规门禁。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -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 helper;typed 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` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
|
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed 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` 走同一 contract;fake 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 固定 ok;manifest/sync 走 FFI |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/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 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t_dir);
|
write_textunit_task_queue_fixture(¤t_dir);
|
||||||
|
write_textunit_index_fixture(¤t_dir);
|
||||||
write_translation_task_repository_fixture(¤t_dir);
|
write_translation_task_repository_fixture(¤t_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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(¤t.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(
|
||||||
|
¤t_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(),
|
||||||
|
|||||||
@@ -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
@@ -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, ¶ms) {
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
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, ¶ms) {
|
||||||
|
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
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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+"/")
|
||||||
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
})[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attr(value) {
|
||||||
|
return html(value).replace(/`/g, '`');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user