diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index a3d892458..73dac7168 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-cli -description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification. +description: SpacetimeDB 2.7 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification. --- # SpacetimeDB CLI @@ -68,6 +68,31 @@ spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xa spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101 ``` +## Standalone MCP Endpoint (2.7) + +SpacetimeDB 2.7 standalone exposes an authenticated JSON-RPC MCP endpoint at +`POST /v1/database/{name_or_identity}/mcp`. It advertises `ping`, `get_schema`, +`sql`, and `call`. The SQL and reducer tools execute with the bearer token's +identity, so keep routine smoke checks read-only. + +```bash +curl -fsS \ + -H "Authorization: Bearer ${SPACETIME_TOKEN}" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"genarrative-smoke","version":"1.0.0"}}}' \ + http://127.0.0.1:3101/v1/database/my-db/mcp + +curl -fsS \ + -H "Authorization: Bearer ${SPACETIME_TOKEN}" \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ping","arguments":{"message":"genarrative"}}}' \ + http://127.0.0.1:3101/v1/database/my-db/mcp +``` + +For repository upgrade validation, also call `tools/list` and the read-only +`get_schema` tool against an isolated local database. Do not use `sql` or `call` +for writes unless that mutation is explicitly in scope. + ## Server & Auth ```bash @@ -102,7 +127,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping | Flag | Description | |------|-------------| | `--server`, `-s` | Target server nickname, host, or URL | -| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values | +| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6+ use scoped values | | `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` | | `--module-path`, `-p` | Module project path | | `--bin-path`, `-b` | Publish/generate from compiled wasm | @@ -146,6 +171,8 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)" ## Notes -- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates. -- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution. +- Procedure calls remain stable in 2.7; module HTTP handlers/webhooks and RLS capabilities still require their documented gates. +- 2.5 fixed `publish --delete-data` config fallback; 2.6 kept that behavior and improved CLI binary distribution; 2.7 adds `spacetime sql --format json` and database `lock` / `unlock`. +- The official 2.7.0 Linux release archives and container image currently use the `v2.7.0-hotfix3` asset tag while binaries report `2.7.0`; keep the asset tag distinct from the runtime version check. +- Do not assume `spacetime version install 2.7.0` selected hotfix3: stale updater metadata can install bare-tag commit `a08663c7...`. For the current release, verify CLI commit `d220349a...` and use the official hotfix3 archive or repository provision flow when it differs. - Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults. diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md index c17575687..e671603dd 100644 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ b/.codex/skills/spacetimedb-concepts/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-concepts -description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features. +description: Understand SpacetimeDB 2.7 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features. --- # SpacetimeDB Core Concepts @@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl 1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints. 2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables. -3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`. +3. **Procedures are stable in 2.7**: they can use explicit transactions and outgoing HTTP via `ctx.http`. 4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument. 5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering. 6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`. @@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client- ## Procedures -Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`). +Procedures are stable in 2.7. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`). Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure. -Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6. +Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.7. ## Views -Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction. +Views expose computed read-only data. SpacetimeDB 2.7 supports primary keys on procedural views in Rust, TypeScript, C#, and C++. Clients can receive update events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction. ## Event Tables Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`. -2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables. +Since 2.6, event tables support broader layout-altering automigrations, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables. Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required. -Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables. +Official 2.4.1 through 2.7 release notes document primary-key-backed update callbacks for procedural views, not event tables. ## Subscriptions @@ -78,7 +78,18 @@ Best practices: - Avoid overlapping queries that duplicate row delivery. - Use indexes for subscribed filters. -## 2.2.0 to 2.6.1 Delta +## Standalone MCP + +SpacetimeDB 2.7 standalone exposes `POST /v1/database/{name_or_identity}/mcp` +using MCP JSON-RPC protocol `2025-06-18`. Its tools are `ping`, `get_schema`, +`sql`, and `call`; SQL and reducer calls run with the authenticated caller's +identity. In Genarrative this is an operator/developer integration surface, not +a replacement for `api-server` BFF routes, `spacetime-client` facades, or public +read models. Upgrade smoke should use an isolated local database and restrict +itself to `initialize`, `tools/list`, `ping`, and `get_schema` unless writes are +explicitly intended. + +## 2.2.0 to 2.7.0 Delta Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: @@ -89,6 +100,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: - **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments. - **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands. - **2.6.1**: procedure contexts again receive the caller `Identity` and `ConnectionId`; generated TypeScript `Option` fields use optional keys; `spacetime init --template` lists available templates when no template argument is supplied. +- **2.7.0**: existing tables can add unique or primary-key constraints when current data satisfies them; standalone exposes an authenticated database MCP endpoint; Rust adds context-capability and table-accessor traits; `spacetime sql --format json` and database locking are available; view cleanup, backing-table migration, connection metrics, and memory metrics improve. Official current release assets use the `v2.7.0-hotfix3` tag while binaries report `2.7.0`. ## Debugging Checklist diff --git a/.codex/skills/spacetimedb-rust/SKILL.md b/.codex/skills/spacetimedb-rust/SKILL.md index 5750ada65..ef0a239d6 100644 --- a/.codex/skills/spacetimedb-rust/SKILL.md +++ b/.codex/skills/spacetimedb-rust/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-rust -description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic. +description: Develop SpacetimeDB 2.7 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic. --- # SpacetimeDB Rust Module Development @@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`. -In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables. +Since 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables. Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required. -Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables. +Official 2.4.1 through 2.7 release notes tie primary-key-backed update callbacks to procedural views, not event tables. ## Views @@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer ## Procedures -Procedures remain stable in 2.6 and no longer require the `unstable` feature. +Procedures remain stable in 2.7 and no longer require the `unstable` feature. ```rust use spacetimedb::{procedure, ProcedureContext}; diff --git a/.env.example b/.env.example index 4b5ed9164..58b202e3d 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,19 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600" ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520" ALIYUN_OSS_SUCCESS_ACTION_STATUS="200" +# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的 +# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。 +# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。 +# 只有需要脱离父 API 单独验证 worker 时才运行 `npm run dev:bgfilter-worker`;不要让 `all` 角色兼任它。 +GENARRATIVE_BGFILTER_WORKER_HOST="127.0.0.1" +GENARRATIVE_BGFILTER_WORKER_PORT="8083" +GENARRATIVE_BGFILTER_WORKER_BASE_URL="http://127.0.0.1:8083" +GENARRATIVE_BGFILTER_INTERNAL_TOKEN="CHANGE_ME_FOR_LOCAL" +GENARRATIVE_BGFILTER_WORKER_CONCURRENCY="16" +GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS="5000" +GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS="2048" +GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS="2000" + # SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR="" GENARRATIVE_DATABASE_BACKUP_WORK_DIR="" diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1536699b8..3b4cf7c4d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -386,7 +386,6 @@ module.exports = { 'src/data/**', 'src/prompts/**', 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*', - 'apps/admin-web/src/pages/AdminGrayReleaseConfigPage*', 'apps/admin-web/src/pages/AdminWorkVisibilityPage*', 'src/services/recommendedRuntimeGuestLaunch.test.ts', 'src/data/sceneEncounterPreviews.ts', diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index b728bda03..29c8a4bcf 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -106,6 +106,18 @@ jobs: - name: Run frontend and script tests run: npm run test + - name: Run BgFilter worker smoke harness tests + run: npm run bgfilter-worker:smoke-test + + - name: Validate production health patrol behavior + run: npm run check:production-health-patrol + + - name: Validate production API release behavior + run: npm run check:production-api-release + + - name: Validate production API deploy behavior + run: npm run check:production-api-deploy + backend-tests: name: Backend tests runs-on: genarrative-ci diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index 18c2ebe05..2562ed0f6 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -1,8 +1,8 @@ --- name: genarrative-dev-stack-port-routing short_description: 修改 Genarrative 本地 dev 启动端口、代理目标、端口冲突处理时使用。 -description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 -version: 1.0.0 +description: 在 Genarrative 中修改 npm run dev / dev:spacetime / dev:api-server / dev:bgfilter-worker / dev:web / dev:admin-web 的本地启动端口、端口可用性探测、端口漂移、SpacetimeDB publish server、Rust 进程环境变量、Vite 代理目标和后台 admin-web 启动串联时使用。 +version: 1.1.0 author: Hermes Agent license: MIT metadata: @@ -13,7 +13,7 @@ metadata: # Genarrative 本地 dev 启动端口与代理目标串联流程 -用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与四个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。 +用于维护 Genarrative 本地开发栈启动脚本,重点覆盖 `npm run dev` 与五个 `dev:*` 单模块命令的端口检查、端口漂移和后续流程目标传递。 ## 适用场景 @@ -31,40 +31,44 @@ metadata: 2. Rust `api-server`:`8082`,健康检查为 `http://127.0.0.1:/healthz`。 3. SpacetimeDB standalone:`3101`,健康检查为 `http://127.0.0.1:/v1/ping`。 4. 后台 Vite:`3102`,后台地址为 `http://127.0.0.1:/admin/`。 +5. 独立 BgFilter worker:`8083`,就绪检查为 `http://127.0.0.1:/readyz`。 端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。 -Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有端口探测、漂移和复用逻辑,不读系统级注册表。 +Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`、`bgfilterWorker = start + 4`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有统一端口探测和漂移逻辑,不读系统级注册表。 ## 实现入口 - `package.json` - - `dev`:执行 `node scripts/dev.mjs`,启动完整四模块。 - - `dev:spacetime` / `dev:api-server` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs `。 + - `dev`:执行 `node scripts/dev.mjs`,启动完整五服务。 + - `dev:spacetime` / `dev:api-server` / `dev:bgfilter-worker` / `dev:web` / `dev:admin-web`:执行 `node scripts/dev.mjs `;`dev:api-server` 会安全带起其依赖的 BgFilter worker。 - `scripts/dev-stack-port-utils.mjs` - `isPortAvailable(...)`:探测端口是否可监听。 - `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。 - - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。 + - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite、BgFilter worker 端口,并避免本次解析结果互相冲突。 - Linux 注册表分配:`reserveLinuxDevPortRange(...)` / `releaseLinuxDevPortRange(...)`,仅在 Linux 上启用系统级端口段登记与用户段复用,自动分配从 `10000-10099` 起。 - - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。 + - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102 bgfilterWorker:127.0.0.1:8083`。 - `scripts/dev.mjs` - 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER`、`RUST_SERVER_TARGET`。 - - 完整栈按 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 顺序启动。 - - Linux 下会先申请系统级端口段并把它映射成四个 dev 端口;自动分配从 `10000-10099` 起,Windows 则直接沿用原有参数解析与端口漂移逻辑。 + - 完整栈按 SpacetimeDB、publish、BgFilter worker readiness、api-server readiness、主站 Vite、后台 Vite 顺序启动。 + - Linux 下会先申请系统级端口段并把它映射成五个 dev 端口;自动分配从 `10000-10099` 起,Windows 则把第五个服务纳入原有统一参数解析与端口漂移逻辑。 + - 完整栈和 `dev:api-server` 把两个 Rust 进程作为同一重启单元,先全部停止,再先启动 BgFilter worker、后启动 api-server;不要为同一份 Rust 源码创建两个并发 `cargo` watcher。 - 单模块命令复用同一套参数和 env 解析。 ## 必须保持的传递链路 -`npm run dev` 和四个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置: +`npm run dev` 和五个 `dev:*` 单模块命令中端口解析后,必须同步到以下位置: 1. SpacetimeDB 启动:`spacetime start --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"`。 2. SpacetimeDB 发布:`spacetime publish ... --server "${SPACETIME_SERVER}"`。 3. Rust api-server:`GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`。 4. api-server 健康检查:`wait_for_api_server "${RUST_SERVER_TARGET}/healthz" ...`。 -5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 -6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 -7. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/spacetime` 必须显示最终实际地址。 -8. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 +5. BgFilter worker:`GENARRATIVE_PROCESS_ROLE=bgfilter-worker`、解析后的 `HOST / PORT`、与父 API 相同的 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` / `GENARRATIVE_BGFILTER_INTERNAL_TOKEN`,以及显式有效的 `N / Q`。 +6. BgFilter worker readiness:父 API 启动前检查解析后地址的 `/readyz`。 +7. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 +8. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 +9. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/bgfilter-worker/spacetime` 必须显示最终实际地址。 +10. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。 @@ -74,7 +78,7 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` - `scripts/dev-stack-port-utils.mjs` - `scripts/dev.mjs` - `scripts/dev-utils.mjs` - - `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md` + - `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - `docs/project-memory/shared-memory/pitfalls.md` 2. 优先改公共端口工具,不要把端口探测逻辑复制到多个脚本。 3. 修改 `scripts/dev.mjs` 时确认变量顺序:先解析参数和端口,再构造 `SPACETIME_SERVER` / `RUST_SERVER_TARGET`,最后启动对应 service。 @@ -91,21 +95,21 @@ Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` node --check scripts/dev.mjs npm run test -- scripts/dev-stack-port-utils.test.ts npm run check:encoding -node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 +node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 api:127.0.0.1:0 web:0.0.0.0:0 adminWeb:127.0.0.1:0 bgfilterWorker:127.0.0.1:0 ``` 端口冲突回归测试建议: 1. 用测试或临时 Node server 占用某个优先端口。 2. 调用 `findAvailablePort`,断言结果大于被占用端口。 -3. 调用 `resolveDevStackPorts`,断言四个结果互不相同。 +3. 调用 `resolveDevStackPorts`,断言五个结果互不相同。 4. 如果实际启动完整栈,观察控制台: - `[dev:ports] ... 不可用,改用 ...` - `[dev] api-server: http://...:` - `[dev] spacetime: http://...:` - 主站和后台 Vite 启动端口与日志一致。 -完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 `/healthz`、`/v1/ping` 和页面端口;不要等待 `npm run dev` 自然退出。 +完整启动属于长驻进程。需要 smoke 时用 background 方式启动,并另开命令检查 api-server `/healthz`、BgFilter worker `/readyz`、SpacetimeDB `/v1/ping` 和两个页面端口;不要等待 `npm run dev` 自然退出。检查地址必须取 `.app/dev-stack.json` 或启动日志中的实际端口,不能假定 worker 一定停在 `8083`。 ## 常见坑 @@ -122,7 +126,8 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap - [ ] Linux 注册表分配、同用户复用固定段并继续漂移、自动分配从 `10000-10099` 起、Windows bypass 都有测试覆盖。 - [ ] `scripts/dev.mjs` 通过 `node --check`。 - [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 +- [ ] BgFilter worker 在 api-server 前 ready,父子共享实际 base URL / Token,Rust watch 只触发一次组合重启。 - [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 -- [ ] 文档同步更新 `docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`。 +- [ ] 文档同步更新 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - [ ] 长期踩坑同步更新 `docs/project-memory/shared-memory/pitfalls.md`。 - [ ] 修改中文文件后运行 `npm run check:encoding`。 diff --git a/README.md b/README.md index 71aa2ac89..d5ee527a1 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ npm run dev 补充说明: -- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。 +- `npm run dev` 会启动 SpacetimeDB standalone、独立 `bgfilter-worker`、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调;内部 worker ready 后才启动 API。 - 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。 -- 四个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:web`、`npm run dev:admin-web`。 -- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,`api-server` 改动后会重启 Rust 进程。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。 +- 五个模块可独立启动:`npm run dev:spacetime`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:web`、`npm run dev:admin-web`;其中 `dev:api-server` 会安全带起同 runner 的 BgFilter worker 依赖。 +- 如需自动刷新后端模块,使用 `npm run dev -- --watch`;其中 `spacetime-module` 改动后只会重新发布模块,不会重启 standalone,Rust 源码改动会把 `api-server` 与 `bgfilter-worker` 作为一个组合单元重启。主站和后台前端源码变化交给 Vite 自身 HMR,不由外层 watcher 重启。非 watch 模式下可在 `npm run dev` 终端输入 `rs api-server`、`rs bgfilter-worker`、`rs web`、`rs admin-web`、`rs spacetime` 或 `rs all`,其中 `rs spacetime` 也是只重新发布模块。 构建生产包: diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 85f029e37..97ec8bbb6 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -3,10 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createAdminAccount, executeAdminRechargeRefund, + getAdminFeatureGateConfig, getAdminUserDetail, listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, updateAdminAccount, + uploadAdminEditorShowcaseCampaignImage, + upsertAdminFeatureGateConfig, } from './adminApiClient'; afterEach(() => { @@ -16,7 +19,7 @@ afterEach(() => { test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( - new Response(JSON.stringify({account: {accountId: 'member-1'}}), { + new Response(JSON.stringify({ account: { accountId: 'member-1' } }), { status: 200, }), ), @@ -40,7 +43,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => expect(fetchMock.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({Authorization: 'Bearer owner-token'}), + headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }), }), ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1'); @@ -56,6 +59,132 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => ); }); +test('灰度配置读写只使用通用 feature-gates 管理接口', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ gates: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await getAdminFeatureGateConfig('gray-token'); + await upsertAdminFeatureGateConfig('gray-token', { + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + body: JSON.stringify({ + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }), + }), + ); +}); + +test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => { + const closeBitmap = vi.fn(); + vi.stubGlobal( + 'createImageBitmap', + vi + .fn() + .mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }), + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + upload: { + bucket: 'genarrative-release', + host: 'https://genarrative-release.oss.example.com', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + formFields: { key: 'campaign-key', policy: 'signed-policy' }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + .mockResolvedValueOnce(new Response('', { status: 200 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const file = new File(['image-bytes'], 'card.png', { type: 'image/png' }); + + const uploaded = await uploadAdminEditorShowcaseCampaignImage( + 'admin-token', + file, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-ticket', + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://genarrative-release.oss.example.com', + ); + expect(fetchMock.mock.calls[2]?.[0]).toBe( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + ); + expect(fetchMock.mock.calls[2]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }), + body: JSON.stringify({ + bucket: 'genarrative-release', + objectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + contentType: 'image/png', + contentLength: file.size, + }), + }), + ); + expect(uploaded).toEqual({ + imageSrc: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + imageObjectKey: + 'generated-character-drafts/editor/showcase-campaign/current/card.png', + imageWidth: 1024, + imageHeight: 1536, + legacyPublicPath: + '/generated-character-drafts/editor/showcase-campaign/current/card.png', + }); + expect(closeBitmap).toHaveBeenCalledOnce(); +}); + test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ entries: [] }), { @@ -110,13 +239,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); test('退款执行使用独立 execute 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await executeAdminRechargeRefund('token-1', { @@ -143,13 +270,11 @@ test('退款执行使用独立 execute 管理员路由', async () => { }); test('退款人工复核使用独立 resolve 管理员路由', async () => { - const fetchMock = vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { - status: 200, - }), - ); + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { + status: 200, + }), + ); vi.stubGlobal('fetch', fetchMock); await resolveAdminRechargeRefundManualReview('token-1', { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 17e3ba244..e3697bb90 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,5 +1,6 @@ import type { AdminAccountListResponse, + AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminCreateAccountRequest, AdminCreateAccountResponse, AdminCreateEditorShowcaseCampaignImageUploadTicketRequest, @@ -22,6 +23,7 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -40,6 +42,7 @@ import type { AdminUpdateAccountResponse, AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, + AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, @@ -266,6 +269,23 @@ export function listAdminTrackingEventKeys(token: string) { ); } +export function getAdminFeatureGateConfig(token: string) { + return request('/admin/api/feature-gates', { + token, + }); +} + +export function upsertAdminFeatureGateConfig( + token: string, + payload: AdminUpsertFeatureGateConfigRequest, +) { + return request('/admin/api/feature-gates', { + method: 'PUT', + token, + body: payload, + }); +} + export function getAdminEditorGenerationPricing(token: string) { return request( '/admin/api/editor-generation-pricing', @@ -388,6 +408,19 @@ export async function uploadAdminEditorShowcaseCampaignImage( ); await postAdminDirectUploadFile(response.upload, file); const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, ''); + await request( + '/admin/api/editor-showcase/campaign/image-upload-confirm', + { + method: 'POST', + token, + body: { + bucket: response.upload.bucket, + objectKey, + contentType, + contentLength: file.size, + } satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest, + }, + ); return { imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath, imageObjectKey: objectKey, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 65c416d50..3900ab01d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -286,6 +286,26 @@ export interface AdminTrackingEventListQuery { exportAll?: boolean; } +export interface AdminFeatureGateConfigPayload { + gateKey: string; + enabled: boolean; + rolloutPercent: number; + allowUserIds: string[]; + allowUserTags: string[]; + denyUserIds: string[]; + description: string; + updatedAt: string; +} + +export interface AdminFeatureGateConfigResponse { + gates: AdminFeatureGateConfigPayload[]; +} + +export type AdminUpsertFeatureGateConfigRequest = Omit< + AdminFeatureGateConfigPayload, + 'updatedAt' +>; + /** 图片画布生成模型泥点定价配置。 */ export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond'; @@ -372,6 +392,7 @@ export interface AdminEditorShowcaseAssetPayload { taskId?: string | null; assetKind?: string | null; generationInputs?: Record | null; + thumbnailSrc?: string | null; generationCostMudPoints: number; refundMudPoints: number; reviewStatus: 'pending' | 'approved' | 'rejected' | string; @@ -459,6 +480,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse { upload: AdminDirectUploadTicketPayload; } +export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest { + bucket: string; + objectKey: string; + contentType: string; + contentLength: number; +} + export interface AdminUploadedEditorShowcaseCampaignImage { imageSrc: string; imageObjectKey: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 1ee731fc7..de33c8761 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -24,6 +24,7 @@ import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; +import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; @@ -227,6 +228,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'gray-release' ? ( + + ) : null} {activeRouteId === 'redeem' ? ( { ); }); +test('后台灰度发布路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'gray-release', + label: '灰度发布', + hash: '#gray-release', + }); + expect(resolveAdminRoute('#gray-release')).toBe('gray-release'); + expect(routeHash('gray-release')).toBe('#gray-release'); +}); + test('后台不再暴露旧创作模板管理路由', () => { expect(resolveAdminRoute('#creation-entry')).toBe('dashboard'); expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard'); - expect(resolveAdminRoute('#gray-release')).toBe('dashboard'); expect(resolveAdminRoute('#work-visibility')).toBe('dashboard'); }); + test('后台素材查询路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ id: 'editor-assets', @@ -93,6 +103,17 @@ test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => { ); }); +test('member 可单独获得灰度发布 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['gray-release'], + }); + expect(routes.map((route) => route.id)).toEqual(['gray-release']); + expect(resolveAccessibleAdminRoute('#gray-release', routes)).toBe( + 'gray-release', + ); +}); + test('零权限 member 不回落到 Dashboard', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index faf97b083..b3bad3b50 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,10 +1,11 @@ -/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */ +/** 后台单页应用可导航的路由标识。 */ export type AdminRouteId = | 'dashboard' | 'overview' | 'tables' | 'debug' | 'tracking' + | 'gray-release' | 'redeem' | 'invite' | 'profile-wallet' @@ -32,6 +33,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'tables', label: '表查询', hash: '#tables' }, { id: 'debug', label: 'API 调试', hash: '#debug' }, { id: 'tracking', label: '埋点数据', hash: '#tracking' }, + { id: 'gray-release', label: '灰度发布', hash: '#gray-release' }, { id: 'redeem', label: '兑换码', hash: '#redeem' }, { id: 'invite', label: '邀请码', hash: '#invite' }, { id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' }, diff --git a/apps/admin-web/src/components/AdminEditorAssetMedia.tsx b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx new file mode 100644 index 000000000..9e93be572 --- /dev/null +++ b/apps/admin-web/src/components/AdminEditorAssetMedia.tsx @@ -0,0 +1,436 @@ +import { X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; +import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient'; + +const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; +const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; +const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; +const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; +let adminAssetReadDispatchTail = Promise.resolve(); + +export interface AdminPreviewableEditorAsset { + assetId: string; + label: string; + imageSrc: string; + objectKey?: string | null; + assetKind?: string | null; + thumbnailSrc?: string | null; +} + +export function AdminEditorAssetThumbnail({ + entry, + token, + altPrefix = '素材', +}: { + entry: AdminPreviewableEditorAsset; + token: string; + altPrefix?: string; +}) { + const thumbnailSource = resolveAdminAssetThumbnailSource(entry); + const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); + const imageSrc = useAdminResolvedAssetUrl( + token, + thumbnailSource.src, + thumbnailSource.objectKey, + shouldLoad, + ); + const alt = `${altPrefix}:${entry.label || entry.assetId}`; + + return imageSrc ? ( + {alt} + ) : ( +
+ ); +} + +export function AdminEditorAssetPreviewDialog({ + entry, + token, + onClose, +}: { + entry: AdminPreviewableEditorAsset; + token: string; + onClose: () => void; +}) { + return ( +
+
+
+
+

{entry.label || entry.assetId}

+ {entry.assetId} +
+ +
+ +
+
+ ); +} + +function AdminEditorAssetPreviewMedia({ + entry, + token, +}: { + entry: AdminPreviewableEditorAsset; + token: string; +}) { + const mediaKind = resolveAdminAssetMediaKind(entry); + const isAudio = mediaKind === 'audio'; + const isVideo = mediaKind === 'video'; + const mediaSrc = useAdminResolvedAssetUrl( + token, + entry.imageSrc, + entry.objectKey, + ); + const posterSrc = useAdminResolvedAssetUrl( + token, + isVideo ? (entry.thumbnailSrc ?? '') : '', + null, + ); + const label = entry.label || entry.assetId; + + if (isAudio) { + return ( +
+ {`音频封面:${label}`} + {mediaSrc ? ( +