Compare commits

..

6 Commits

Author SHA1 Message Date
lhk229 fc439612b0 Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m12s
Project CI / Backend tests (pull_request) Successful in 3m57s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m2s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m46s
2026-09-25 01:25:44 +08:00
lhk229 6ed1fd26ed 修复 Linux 沙箱命令退出回收的偶发竞态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m57s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m14s
Project CI / Repository checks (push) Successful in 2m2s
Project CI / AI game creator shell web tests (push) Successful in 1m30s
统一正常退出、取消和超时的有界进程组退出确认,保留归属校验
提前记录启动身份,修补终态协议错误的清理及输出任务回收
补充确定性竞态回归并同步运行时规范和排障记忆
保持现有 CI 并行、分片和重试策略不变
2026-09-24 16:45:07 +00:00
lhk229 2370dc509b 修复账号场景生成的幂等与结果返回协议
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑
场景配方保留 AGC 来源标记,恢复可下载的队列完成结果
补充非法幂等键、场景结果序列化和幂等命名空间回归测试
同步 OpenAPI、技术方案、实施计划及排障记录
2026-09-24 16:18:40 +00:00
lhk229 4912aa4df0 删除创作首页登录送泥点营销文案
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Frontend tests (pull_request) Successful in 2m0s
Project CI / Native shell tests (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m17s
删除 CreationLandingView hero 区“登录即送 100 泥点,可以免费制作 50 个素材”文案
删除 CreationLandingView.test.tsx 中对应的文案断言
清理 index.css 中已无调用方的 creation-landing__hero-benefit 样式
更正 decision-log 中已过期的“注册赠送固定为 100 泥点”决策记录,注明金额由线上钱包配置决定
2026-09-24 16:08:42 +00:00
lhk229 bb69e09b8d Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m6s
Project CI / Repository checks (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m32s
2026-09-24 23:47:34 +08:00
lhk229 571cb00fe4 新增 external v1 游戏场景生成路由并迁移 AGC 美术包背景阶段
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
- api-server 新增 POST /api/external/v1/editor/scenes/generations,复用 editor:image-generate scope、幂等键和站内场景生图队列
- editor_project.rs 抽取站内与外部共用的场景生图 payload 构造函数,站内 handler 改为调用共享函数
- 同步更新 external v1 OpenAPI 契约与 MCP 派生排除标记,登记 docs/README.md 索引
- AGC 客户端美术包背景阶段改走新场景路由,stylePreset 固定 custom + customStyle 保留现有风格文案
- external_generation_state 快照与 recovery_scan 账本白名单支持新场景路由
- direct_runtime 背景资源识别同时兼容新场景路由与旧通用路由,新增旧路由回放测试
- 更新 genarrative-external-editor-api skill 参考文档
- 新增主规范、里程碑规范与实施计划三份 SDD 文档
2026-09-24 15:40:30 +00:00
277 changed files with 1901 additions and 23569 deletions
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Capability | POST path | Required body fields | Common optional body fields |
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
Poll all nine through:
Poll all ten through:
```text
GET /api/external/v1/generations/{operationId}
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
Use OpenAPI as the final authority; these common values are a routing aid:
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
## Art Spec and Image Request
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
-9
View File
@@ -145,15 +145,6 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
ELEVENLABS_API_KEY=""
ELEVENLABS_REQUEST_TIMEOUT_MS="180000"
# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。
# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all)
# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。
TRIPO_BASE_URL="https://openapi.tripo3d.com/v3"
TRIPO_API_KEY=""
# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。
TRIPO_REQUEST_TIMEOUT_MS="60000"
TRIPO_RETRIES="2"
# 阿里云 OSS 配置。
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
-84
View File
@@ -67,90 +67,6 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛
## Language
### 3D Model Generation
**3D 模型生成操作**:
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
**Provider task**:
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
**临时 provider URL**:
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
_Avoid_: 永久 OSS URL、资源 ID
**模型 artifact**:
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
**Tripo API facade**:
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
**Tripo operation**:
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
**模型产物持久化**:
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
**3D provider 配置门禁**:
Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。
_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现
**3D 生成底价**:
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
**3D 生成加价项**:
叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。
_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价
**定价配置归一化**:
读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。
_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价
**模型生成落点**:
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
**模型输入资源引用**:
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
**3D 模型生成工具**:
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
**3D 生成定价**:
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布
**定价版本(乐观锁)**:
后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。
_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」
### 3D 模型资源预览
**3D 模型资源**:
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
**模型预览图**:
3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
**模型本体**:
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
**3D 查看器预览**:
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
### Puzzle Clear
**拼消消**:
+1 -2
View File
@@ -60,7 +60,6 @@ import type {
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorGenerationPricingRequest,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
@@ -520,7 +519,7 @@ export function getAdminEditorGenerationPricing(token: string) {
export function upsertAdminEditorGenerationPricing(
token: string,
payload: AdminUpsertEditorGenerationPricingRequest,
payload: EditorGenerationPricingConfigPayload,
) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
-35
View File
@@ -431,43 +431,8 @@ export interface EditorGenerationModelPricingPayload {
prices?: Record<string, number>;
}
/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */
export interface EditorGenerationModel3dVersionPricePayload {
noTexture: number;
texture: number;
}
/**
* 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。
*
* 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回,
* 不在前端硬编码清单,也不能新增、删除或停用。
*/
export interface EditorGenerationModel3dEndpointPricingPayload {
versionPrices: Record<string, EditorGenerationModel3dVersionPricePayload>;
addOnPrices: Record<string, number>;
}
/** 3D 生成定价段:两个端点并列,各持自己的设置。 */
export interface EditorGenerationModel3dPricingPayload {
textToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
}
export interface EditorGenerationPricingConfigPayload {
models: Record<string, EditorGenerationModelPricingPayload>;
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
model3d?: EditorGenerationModel3dPricingPayload | null;
}
/**
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
*
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
*/
export interface AdminUpsertEditorGenerationPricingRequest {
models: Record<string, EditorGenerationModelPricingPayload>;
model3d: EditorGenerationModel3dPricingPayload | null;
}
export interface AdminEditorAssetListQuery {
@@ -1,131 +0,0 @@
import type {
EditorGenerationModel3dPricingPayload,
EditorGenerationModel3dVersionPricePayload,
} from '../api/adminApiTypes';
import {
EDITOR_MODEL3D_ENDPOINT_SECTIONS,
editorModel3dAddOnLabel,
updateEditorModel3dEndpointPricing,
} from './adminEditorGenerationPricing';
import { parsePositiveInteger } from './pageUtils';
/**
* 一个模型版本的两个底价字段。两栏只有字段键与展示名不同,用描述表驱动渲染,
* 避免两段近乎相同的 JSX 各自演化。
*/
const VERSION_PRICE_FIELDS = [
{ key: 'noTexture', label: '无贴图' },
{ key: 'texture', label: '带贴图' },
] as const satisfies ReadonlyArray<{
key: keyof EditorGenerationModel3dVersionPricePayload;
label: string;
}>;
interface AdminEditorGenerationModel3dPricingSectionProps {
model3d: EditorGenerationModel3dPricingPayload;
onChange: (next: EditorGenerationModel3dPricingPayload) => void;
}
/**
* 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的
* 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。
*
* 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。
*/
export function AdminEditorGenerationModel3dPricingSection({
model3d,
onChange,
}: AdminEditorGenerationModel3dPricingSectionProps) {
function updateEndpoint(
section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'],
updater: Parameters<typeof updateEditorModel3dEndpointPricing>[2],
) {
onChange(updateEditorModel3dEndpointPricing(model3d, section, updater));
}
return (
<section className="admin-panel admin-pricing-model-card">
<div className="admin-pricing-model-heading">
<strong>3D 生成定价</strong>
<span className="admin-pricing-unit">按次</span>
</div>
<p className="admin-pricing-model3d-hint">
模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D
生成不可提交。
</p>
<div className="admin-pricing-grid">
{EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => (
<section className="admin-panel admin-pricing-model-card" key={key}>
<div className="admin-pricing-model-heading">
<strong>{label}</strong>
</div>
<div className="admin-pricing-model3d-list">
{Object.entries(model3d[key].versionPrices).map(
([version, price]) => (
<div className="admin-pricing-model3d-row" key={version}>
<span className="admin-pricing-model3d-row-label">
{version}
</span>
{VERSION_PRICE_FIELDS.map(
({ key: field, label: fieldLabel }) => (
<label className="admin-field" key={field}>
<span>{fieldLabel}</span>
<input
aria-label={`${label} ${version} ${fieldLabel}`}
min={1}
step={1}
type="number"
value={price[field]}
onChange={(event) =>
updateEndpoint(key, (current) => ({
...current,
versionPrices: {
...current.versionPrices,
[version]: {
...price,
[field]: parsePositiveInteger(
event.target.value,
),
},
},
}))
}
/>
</label>
),
)}
</div>
),
)}
</div>
<div className="admin-form-row">
{Object.entries(model3d[key].addOnPrices).map(
([addOn, price]) => (
<label className="admin-field" key={addOn}>
<span>{editorModel3dAddOnLabel(addOn)}</span>
<input
aria-label={`${label} ${editorModel3dAddOnLabel(addOn)}`}
min={1}
step={1}
type="number"
value={price}
onChange={(event) =>
updateEndpoint(key, (current) => ({
...current,
addOnPrices: {
...current.addOnPrices,
[addOn]: parsePositiveInteger(event.target.value),
},
}))
}
/>
</label>
),
)}
</div>
</section>
))}
</div>
</section>
);
}
@@ -11,16 +11,14 @@ import {
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
vi.mock('../api/adminApiClient', async () => {
const actual = await vi.importActual<typeof import('../api/adminApiClient')>(
'../api/adminApiClient',
);
return {
...actual,
getAdminEditorGenerationPricing: vi.fn(),
upsertAdminEditorGenerationPricing: vi.fn(),
};
});
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminEditorGenerationPricing: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminEditorGenerationPricing: vi.fn(),
}));
const pricing: EditorGenerationPricingConfigPayload = {
models: {
@@ -43,78 +41,36 @@ const pricing: EditorGenerationPricingConfigPayload = {
'audio1.0': { unit: 'perGeneration', price: 5 },
'chirp-v5': { unit: 'perGeneration', price: 12 },
},
model3d: {
textToModelPricing: {
versionPrices: {
'v3.1-20260211': { noTexture: 8, texture: 16 },
'P2-20260801': { noTexture: 80, texture: 88 },
},
addOnPrices: {
hdTexture: 8,
ultraTexture: 16,
quadMesh: 4,
generateParts: 16,
},
},
imageToModelPricing: {
versionPrices: {
'v3.1-20260211': { noTexture: 16, texture: 24 },
'P2-20260801': { noTexture: 80, texture: 88 },
},
addOnPrices: {
hdTexture: 8,
ultraTexture: 16,
quadMesh: 4,
generateParts: 16,
},
},
},
};
function savedPricing(
overrides: Partial<EditorGenerationPricingConfigPayload> = {},
) {
return {
...pricing,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue(
savedPricing({
models: {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: { '1K': 20, '2K': 58 },
},
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
...pricing,
models: {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: { '1K': 20, '2K': 58 },
},
}),
);
},
});
});
async function renderPage() {
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
render(
<AdminEditorGenerationPricingPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('3D 生成定价');
}
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
await renderPage();
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), {
target: { value: '58' },
});
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
@@ -134,82 +90,3 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
);
});
});
test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => {
const user = userEvent.setup();
await renderPage();
expect(screen.getByText('文生 3D')).toBeTruthy();
expect(screen.getByText('图生 3D')).toBeTruthy();
expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), {
target: { value: '18' },
});
fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), {
target: { value: '86' },
});
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
target: { value: '6' },
});
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
model3d: {
textToModelPricing: expect.objectContaining({
versionPrices: expect.objectContaining({
'v3.1-20260211': { noTexture: 8, texture: 18 },
}),
addOnPrices: expect.objectContaining({ quadMesh: 6 }),
}),
imageToModelPricing: expect.objectContaining({
versionPrices: expect.objectContaining({
'P2-20260801': { noTexture: 86, texture: 88 },
}),
}),
},
}),
);
});
});
test('数值小于 1 时禁用保存并给出中文提示', async () => {
const user = userEvent.setup();
await renderPage();
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
target: { value: '0' },
});
expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy();
const saveButton = screen.getByRole('button', {
name: '保存定价',
}) as HTMLButtonElement;
expect(saveButton.disabled).toBe(true);
await user.click(saveButton);
expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled();
});
test('后端未配置 3D 段时给出提示并禁止保存', async () => {
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({
...pricing,
model3d: null,
});
render(
<AdminEditorGenerationPricingPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
expect(
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
@@ -6,18 +6,12 @@ import {
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type {
EditorGenerationModel3dPricingPayload,
EditorGenerationModelPricingPayload,
EditorGenerationPricingConfigPayload,
EditorGenerationPricingUnitPayload,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection';
import {
buildAdminEditorGenerationPricingRequest,
collectInvalidEditorGenerationPricingLabels,
} from './adminEditorGenerationPricing';
import { handlePageError, parsePositiveInteger } from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminEditorGenerationPricingPageProps {
token: string;
@@ -26,7 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
const emptyPricing: EditorGenerationPricingConfigPayload = {
models: {},
model3d: null,
};
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
@@ -64,7 +57,7 @@ export function AdminEditorGenerationPricingPage({
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving || !canSave) {
if (isSaving) {
return;
}
setErrorMessage('');
@@ -78,12 +71,7 @@ export function AdminEditorGenerationPricingPage({
setIsSaving(true);
try {
setPricing(
await upsertAdminEditorGenerationPricing(
token,
buildAdminEditorGenerationPricingRequest(pricing),
),
);
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -132,15 +120,6 @@ export function AdminEditorGenerationPricingPage({
});
}
function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) {
setPricing((current) => ({ ...current, model3d: next }));
}
const invalidPriceLabels =
collectInvalidEditorGenerationPricingLabels(pricing);
const isModel3dMissing = !pricing.model3d;
const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing;
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -165,20 +144,6 @@ export function AdminEditorGenerationPricingPage({
</div>
) : null}
{isModel3dMissing ? (
<div className="admin-alert" role="status">
当前部署未配置 3D 定价段(缺失即 3D
生成不可提交):需先由受控默认配置提供两段价格,
后台不在页面里新建或停用整段。
</div>
) : null}
{invalidPriceLabels.length > 0 ? (
<div className="admin-alert" role="status">
{`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
</div>
) : null}
<form className="admin-stack" onSubmit={handleSave}>
<div className="admin-pricing-model-list admin-pricing-model-list--single">
{Object.entries(pricing.models).map(([model, modelPricing]) =>
@@ -191,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
)}
</div>
{pricing.model3d ? (
<AdminEditorGenerationModel3dPricingSection
model3d={pricing.model3d}
onChange={updateModel3dPrice}
/>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !canSave}
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
@@ -265,3 +223,8 @@ function renderModelPricingCard({
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
@@ -7,7 +7,7 @@ import {
} from '../api/adminApiClient';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger } from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminProfileWalletConfigPageProps {
token: string;
@@ -197,3 +197,8 @@ export function AdminProfileWalletConfigPage({
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
@@ -11,7 +11,7 @@ import type {
ProfileRechargeProductKind,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger } from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminRechargeProductPageProps {
token: string;
@@ -546,6 +546,11 @@ function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`;
}
function parsePositiveInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
}
function parseNonNegativeInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
@@ -12,7 +12,7 @@ import type {
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils';
import { handlePageError, splitLines } from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
@@ -422,6 +422,11 @@ export function AdminRedeemCodePage({
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}
@@ -19,7 +19,7 @@ import {
filterAdminTrackingEventKeyOptions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import { handlePageError, parsePositiveInteger } from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminTaskConfigPageProps {
token: string;
@@ -571,6 +571,11 @@ export function AdminTaskConfigPage({
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
@@ -1,87 +0,0 @@
import { expect, test } from 'vitest';
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
import {
buildAdminEditorGenerationPricingRequest,
collectInvalidEditorGenerationPricingLabels,
editorModel3dAddOnLabel,
} from './adminEditorGenerationPricing';
import { parsePositiveInteger } from './pageUtils';
function pricingFixture(): EditorGenerationPricingConfigPayload {
return {
models: {
'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 3, '2K': 5 } },
'audio1.0': { unit: 'perGeneration', price: 5 },
},
model3d: {
textToModelPricing: {
versionPrices: { 'v3.1-20260211': { noTexture: 8, texture: 16 } },
addOnPrices: { hdTexture: 8, unknownAddOn: 7 },
},
imageToModelPricing: {
versionPrices: { 'v3.1-20260211': { noTexture: 16, texture: 24 } },
addOnPrices: { hdTexture: 8 },
},
},
};
}
test('数值解析把空串、非数字、小数与 0 收敛成 0', () => {
expect(parsePositiveInteger('12')).toBe(12);
expect(parsePositiveInteger('')).toBe(0);
expect(parsePositiveInteger('abc')).toBe(0);
expect(parsePositiveInteger('12abc')).toBe(0);
expect(parsePositiveInteger('3.9')).toBe(0);
expect(parsePositiveInteger('0')).toBe(0);
expect(parsePositiveInteger('-3')).toBe(0);
});
test('未登记的 3D 加价项键回退显示原键名', () => {
expect(editorModel3dAddOnLabel('hdTexture')).toBe('高清贴图');
expect(editorModel3dAddOnLabel('unknownAddOn')).toBe('unknownAddOn');
});
test('校验覆盖模型档位与 3D 两段的全部数值', () => {
expect(collectInvalidEditorGenerationPricingLabels(pricingFixture())).toEqual(
[],
);
const invalid = pricingFixture();
const invalidImagePricing = invalid.models['gpt-image-2'];
const invalidTextEndpoint = invalid.model3d?.textToModelPricing;
const invalidImageEndpoint = invalid.model3d?.imageToModelPricing;
if (
!invalidImagePricing?.prices ||
!invalidTextEndpoint ||
!invalidImageEndpoint
) {
throw new Error('夹具必须包含图片档位与 3D 两段');
}
const imageVersionPrice = invalidImageEndpoint.versionPrices['v3.1-20260211'];
if (!imageVersionPrice) {
throw new Error('夹具必须包含图生 3D 的版本底价');
}
invalidImagePricing.prices['2K'] = 0;
invalidTextEndpoint.addOnPrices.hdTexture = 0;
imageVersionPrice.noTexture = 0;
expect(collectInvalidEditorGenerationPricingLabels(invalid)).toEqual([
'gpt-image-2 2K',
'文生 3D 高清贴图',
'图生 3D v3.1-20260211 无贴图',
]);
});
test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => {
const pricing = pricingFixture();
expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({
models: pricing.models,
model3d: pricing.model3d,
});
expect(
buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }),
).toEqual({
models: pricing.models,
model3d: null,
});
});
@@ -1,115 +0,0 @@
import type {
AdminUpsertEditorGenerationPricingRequest,
EditorGenerationModel3dEndpointPricingPayload,
EditorGenerationModel3dPricingPayload,
EditorGenerationPricingConfigPayload,
} from '../api/adminApiTypes';
/** 3D 端点在两个并列段里的字段名与后台展示名。 */
export const EDITOR_MODEL3D_ENDPOINT_SECTIONS = [
{ key: 'textToModelPricing', label: '文生 3D' },
{ key: 'imageToModelPricing', label: '图生 3D' },
] as const satisfies ReadonlyArray<{
key: keyof EditorGenerationModel3dPricingPayload;
label: string;
}>;
/**
* 3D 加价项键到中文标签。键来自接口返回,这里只负责展示;遇到未登记的键回退显示原键名,
* 不因为标签缺失就把该项漏掉。
*/
const EDITOR_MODEL3D_ADD_ON_LABELS: Record<string, string> = {
hdTexture: '高清贴图',
ultraTexture: '超高清贴图',
hdGeometry: '高清几何',
quadMesh: '四边形网格',
smartLowPoly: '智能低面',
generateParts: '分件生成',
};
export function editorModel3dAddOnLabel(addOn: string) {
return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn;
}
export function isPositiveMudPoints(value: number | null | undefined) {
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
}
/**
* 提交前校验:所有泥点数值都必须是不小于 1 的整数,返回不合法项的展示名。
*
* 后端仍会独立校验一遍;这里只是不把明显非法的 payload 发出去,避免保存后才报错。
*/
export function collectInvalidEditorGenerationPricingLabels(
pricing: EditorGenerationPricingConfigPayload,
) {
const labels: string[] = [];
for (const [model, modelPricing] of Object.entries(pricing.models)) {
const tieredPrices = modelPricing.prices;
// `prices: {}` 没有档位可校验,不能算「这一项走分层计价」——否则底价会被顺手跳过校验。
if (tieredPrices && Object.keys(tieredPrices).length > 0) {
for (const [tier, price] of Object.entries(tieredPrices)) {
if (!isPositiveMudPoints(price)) {
labels.push(`${model} ${tier}`);
}
}
continue;
}
if (!isPositiveMudPoints(modelPricing.price)) {
labels.push(`${model} 泥点`);
}
}
const model3d = pricing.model3d;
if (model3d) {
for (const { key, label } of EDITOR_MODEL3D_ENDPOINT_SECTIONS) {
// 段与子表都来自接口返回:缺段或缺子表时只跳过这一段,不能让整页校验崩掉。
const endpointPricing = model3d[key];
if (!endpointPricing) {
continue;
}
for (const [version, price] of Object.entries(
endpointPricing.versionPrices ?? {},
)) {
if (!isPositiveMudPoints(price.noTexture)) {
labels.push(`${label} ${version} 无贴图`);
}
if (!isPositiveMudPoints(price.texture)) {
labels.push(`${label} ${version} 带贴图`);
}
}
for (const [addOn, price] of Object.entries(
endpointPricing.addOnPrices ?? {},
)) {
if (!isPositiveMudPoints(price)) {
labels.push(`${label} ${editorModel3dAddOnLabel(addOn)}`);
}
}
}
}
return labels;
}
/**
* 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。
* 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。
*/
export function buildAdminEditorGenerationPricingRequest(
pricing: EditorGenerationPricingConfigPayload,
): AdminUpsertEditorGenerationPricingRequest {
return {
models: pricing.models,
model3d: pricing.model3d ?? null,
};
}
/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */
export function updateEditorModel3dEndpointPricing(
model3d: EditorGenerationModel3dPricingPayload,
section: keyof EditorGenerationModel3dPricingPayload,
updater: (
current: EditorGenerationModel3dEndpointPricingPayload,
) => EditorGenerationModel3dEndpointPricingPayload,
): EditorGenerationModel3dPricingPayload {
return { ...model3d, [section]: updater(model3d[section]) };
}
-13
View File
@@ -1,18 +1,5 @@
import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient';
/**
* 泥点 / 积分类输入框文本转正整数:空串、非数字、小数、0 与负数一律收敛成 0,
* 由各页的提交前校验拦住。
*
* 用 `Number` 而不是 `parseInt`:`parseInt` 会把 `3.9` 截成 3、`12abc` 截成 12,
* 等于把编辑中的半截输入静默当成合法值存下去;再用 `isSafeInteger` 挡掉超出安全整数
* 范围的值(那种值在别处会被静默改写)。后台各页统一用这一份,不再各写一遍。
*/
export function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
export function handlePageError(
error: unknown,
onUnauthorized: (message?: string) => void,
-29
View File
@@ -2972,34 +2972,6 @@ button:disabled {
gap: 10px;
}
.admin-pricing-model3d-list {
display: grid;
gap: 10px;
}
.admin-pricing-model3d-row {
display: grid;
grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr));
align-items: end;
gap: 10px;
}
.admin-pricing-model3d-row-label {
color: #5c3a26;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
padding-bottom: 10px;
}
.admin-pricing-model3d-hint {
color: #7b5a45;
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.admin-pricing-unit {
flex: 0 0 auto;
border-radius: 999px;
@@ -3050,7 +3022,6 @@ button:disabled {
.admin-two-column-wide,
.admin-accounts-layout,
.admin-pricing-grid,
.admin-pricing-model3d-row,
.admin-form-row,
.admin-filter-grid,
.admin-table-query-grid,
+4 -3
View File
@@ -6312,7 +6312,8 @@ dependencies = [
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros 12.0.1",
@@ -6333,9 +6334,9 @@ dependencies = [
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"indexmap 2.14.0",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -29,10 +29,7 @@ tauri-build = { version = "2.6.2", features = [] }
[dependencies]
codex-patch-parser = { path = "vendor/codex-patch-parser" }
chrono = { version = "0.4", default-features = false, features = ["std"] }
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`
# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit;
# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" }
ts-rs = "12.0.1"
typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
@@ -69,7 +66,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
.unwrap_or(false)
}
fn direct_taonier_background_asset_identity(
root: &Path,
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
) -> Option<DirectTaonierArtAssetIdentity> {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/scenes/generations",
"scene",
expected_reference_source,
)
.or_else(|| {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
expected_reference_source,
)
})
}
fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity(
root,
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
) else {
return false;
};
let Some(background) = direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
) else {
let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
return false;
};
let _ = (background, art_spec);
@@ -3325,15 +3342,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
})
.flatten();
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(art_spec),
)
.is_some()
direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
});
let art_spec = match existing_art_spec {
Some(identity) => identity,
@@ -3409,15 +3418,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
}
};
if mode.regenerates_existing()
|| direct_taonier_art_asset_identity(
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
)
.is_none()
|| direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
{
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
let outcome = match generate_direct_taonier_art_asset_at(
@@ -8294,8 +8295,8 @@ mod tests {
vec!["taonier-resource-icon-spec".to_string()],
),
GameCreationAppAssetKind::Scene => (
"/api/external/v1/editor/images/generations",
"spec",
"/api/external/v1/editor/scenes/generations",
"scene",
vec!["taonier-resource-icon-spec".to_string()],
),
_ => (
@@ -8679,6 +8680,46 @@ mod tests {
);
}
#[test]
fn legacy_route_background_registration_still_counts_as_valid_base() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记")
.expect("init project");
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
register_direct_taonier_art_asset_fixture(
root.path(),
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
GameCreationAppAssetKind::IconSpec,
);
std::fs::write(
root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH),
tiny_visible_png(),
)
.expect("background bytes");
register_local_asset_at(
root.path(),
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"image/png",
"platform-art",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("taonier-project".to_string()),
resource_id: Some("taonier-resource-game-background".to_string()),
asset_object_id: Some("taonier-object-game-background".to_string()),
task_id: Some("taonier-task-game-background".to_string()),
prompt: None,
model: Some("gpt-image-2".to_string()),
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
generation_kind: Some("spec".to_string()),
reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()],
},
)
.expect("register legacy-route background");
assert!(direct_taonier_art_base_is_valid(root.path()));
}
fn tiny_visible_png() -> Vec<u8> {
let mut bytes = Vec::new();
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
@@ -2835,14 +2835,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
&& art_spec_asset_type == Some("icon-spec")
}
GameCreationAppAssetKind::Scene => {
snapshot.endpoint == "/api/external/v1/editor/images/generations"
&& snapshot.generation_kind == "spec"
snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
&& snapshot.generation_kind == "scene"
&& platform_art_runtime_references_match_request_contract(
&snapshot.reference_resource_ids,
GameCreationAppAssetKind::Scene,
)
&& request_asset_kind.as_deref() == Some("game-background")
&& art_spec_asset_type == Some("background")
&& json_string_field(&request_body, "sceneContent")
.is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
}
GameCreationAppAssetKind::IconSpritesheet => {
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations"
@@ -3248,6 +3249,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
GameCreationAppAssetKind::Image => "image",
GameCreationAppAssetKind::Character => "character",
GameCreationAppAssetKind::PublicationMaterial => "publication-material",
GameCreationAppAssetKind::Scene => "scene",
_ => "spec",
};
let is_canonical_art_spritesheet =
@@ -3291,6 +3293,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
},
}),
)
} else if options.asset_kind == GameCreationAppAssetKind::Scene {
(
"/api/external/v1/editor/scenes/generations",
serde_json::json!({
"sceneContent": generation_prompt,
"stylePreset": "custom",
"customStyle": prompt_text!("media.scene_style"),
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetLabel": options.asset_label,
"projectId": canvas_context.project_id,
"assetFolderId": canvas_context.asset_folder_id,
"referenceImageSrcs": references.ordered.clone(),
"canvasCompletion": {
"title": options.asset_label,
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
},
}),
)
} else {
(
"/api/external/v1/editor/images/generations",
@@ -13272,14 +13293,13 @@ mod canvas_generation_tests {
let background = write_retained_stage(
root,
"run-background-canonical-and-user-references",
"/api/external/v1/editor/images/generations",
"/api/external/v1/editor/scenes/generations",
serde_json::json!({
"prompt": "保留账本参考合同",
"kind": "spec",
"assetKind": "game-background",
"sceneContent": "保留账本参考合同",
"stylePreset": "custom",
"customStyle": "原创横屏 Web 游戏场景背景",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"generationInputs": { "artSpec": { "assetType": "background" } },
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
}),
);
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
.collect::<Result<Vec<_>, _>>()?;
(generation_kind, reference_resource_ids)
}
"/api/external/v1/editor/scenes/generations" => {
if json_string_field(&request_body, "sceneContent")
.is_none_or(|value| value.trim().is_empty())
{
return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string());
}
let reference_resource_ids = request_body
.get("referenceImageSrcs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
"External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string()
})?
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
("scene".to_string(), reference_resource_ids)
}
"/api/external/v1/editor/icon-spritesheets/generations" => {
let reference_resource_id = json_string_field(&request_body, "referenceId")
.or_else(|| json_string_field(&request_body, "referenceImageSrc"))
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
endpoint,
Some(
"/api/external/v1/editor/images/generations"
| "/api/external/v1/editor/scenes/generations"
| "/api/external/v1/editor/icon-spritesheets/generations"
)
);
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
#[cfg(target_os = "linux")]
const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
}
#[cfg(not(windows))]
{
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let requested = self.request_owned_group_termination();
let _ = child.start_kill();
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
requested?;
let waited = tokio::time::timeout_at(deadline, child.wait()).await;
waited
.map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// leader 可能与取消同时退出;只要组已停止便无需补发信号。
if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
return Err(match requested {
Ok(_) => error,
Err(request_error) => format!("{request_error};{error}"),
});
}
return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
}
#[cfg(not(target_os = "linux"))]
{
requested?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
}
}
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
if !linux_project_command_group_has_live_members(*pid)? {
return Ok(());
}
// wait 已回收 leader,不能再用旧 PID 授权发信号。
// namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
return wait_linux_project_command_group_exit(
*pid,
tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
)
.await;
}
#[cfg(not(target_os = "linux"))]
self.request_owned_group_termination().map(|_| ())
}
}
}
#[cfg(target_os = "linux")]
async fn wait_linux_project_command_group_exit(
group: u32,
deadline: tokio::time::Instant,
) -> Result<(), String> {
while linux_project_command_group_has_live_members(group)? {
if tokio::time::Instant::now() >= deadline {
return Err(
"受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
);
}
tokio::time::sleep_until(
deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
)
.await;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> {
@@ -1842,6 +1882,17 @@ where
),
));
}
// 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
let tree = match ProjectCommandTree::attach(&child) {
Ok(tree) => tree,
Err(error) => {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(error, termination),
));
}
};
if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
@@ -1860,12 +1911,7 @@ where
// cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec {
Ok(TargetExecState::Established) => {
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, child, gate })
}
Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new(
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err();
let wait_result = child.wait().await;
let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
let wait_result = tokio::time::timeout_at(deadline, child.wait())
.await
.map_err(|_| "等待受控命令主进程退出超时".to_string())?
.map_err(|error| error.to_string());
if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
return Err(format!("{error};{fallback}"));
}
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
wait_linux_project_command_group_exit(process_id, deadline).await?;
Ok(format!(
"{}并完成主进程回收",
group_result.expect("group termination result checked")
@@ -2220,7 +2271,7 @@ where
let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?;
let terminal = wait_established_project_command_terminal(gate).await;
if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort();
stderr_task.abort();
@@ -2229,6 +2280,12 @@ where
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
));
}
#[cfg(target_os = "linux")]
if let Err(error) = terminal {
stdout_task.abort();
stderr_task.abort();
return Err(error);
}
(status.code(), false, None)
}
ProjectCommandWait::Exited(Err(error)) => {
@@ -2541,45 +2598,83 @@ mod tests {
return;
}
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped());
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
drop(child.stdin.take());
child.wait().await.unwrap();
let live_result = tree.after_main_exit(&mut child).await;
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
let zombie_result = tree.after_main_exit(&mut child).await;
assert_eq!(
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
descendant
);
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
assert!(error.contains("leader 身份未确认"), "{error}");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
tree.after_main_exit(&mut child).await.unwrap();
struct DescendantGuard(i32);
impl Drop for DescendantGuard {
fn drop(&mut self) {
unsafe {
libc::kill(self.0, libc::SIGKILL);
libc::waitpid(self.0, std::ptr::null_mut(), 0);
}
}
}
// 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
for terminate in [false, true] {
let mut command = tokio::process::Command::new("/bin/sh");
command
.args(["-c", "sleep 60 & echo $!; read release"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true);
command.as_std_mut().process_group(0);
let mut child = command.spawn().unwrap();
let tree = ProjectCommandTree::attach(&child).unwrap();
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
.await
.unwrap();
let descendant: i32 = line.trim().parse().unwrap();
let descendant_guard = DescendantGuard(descendant);
drop(child.stdin.take());
child.wait().await.unwrap();
let ProjectCommandTree::Group { pid, .. } = &tree;
let error = tree
.request_owned_group_termination()
.expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
assert!(error.contains("leader 身份未确认"), "{error}");
let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect_err("持续存活成员必须在预算用尽时失败");
assert!(error.contains("仍有存活成员"), "{error}");
{
let cleanup = async {
if terminate {
tree.terminate(&mut child).await.map(|_| ())
} else {
tree.after_main_exit(&mut child).await
}
};
tokio::pin!(cleanup);
// 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
tokio::select! {
biased;
result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
_ = tokio::task::yield_now() => {}
}
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
cleanup.await.expect("leader 消失后,组成员停止应完成清理");
}
tree.after_main_exit(&mut child)
.await
.expect("僵尸不应阻止完成");
wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect("无活成员时不消耗等待预算");
drop(descendant_guard);
tree.after_main_exit(&mut child).await.unwrap();
}
}
#[test]
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
.expect("run timeout test");
assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed");
#[cfg(target_os = "linux")]
assert!(timed_out.output.contains("主进程已回收"));
#[cfg(not(target_os = "linux"))]
assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox"));
@@ -1266,11 +1266,13 @@
max-width: calc(100% - 28px);
}
/* 「生成规范」的二级菜单(`bottom:100%+6px`)必须可见:换行与不裁剪由共享工具栏保证
(`@genarrative/image-canvas-react` 的 `.genarrative-image-canvas__toolbar` 已从
`overflow-x: auto` 改成 `flex-wrap: wrap`),这里只限宽,不再重复覆盖 overflow。 */
/* 共享 chrome 的 `.genarrative-image-canvas__toolbar` 是横向滚动条(`overflow-x:auto`),
会把它自己弹出的二级菜单(`bottom:100%+6px`)裁掉。这里改成换行、不裁切:
「生成规范」的二级菜单必须可见,窄屏用换行兜底而不是横向滚动。 */
.game-resource-bottom-toolbar-strip {
max-width: 100%;
overflow: visible;
flex-wrap: wrap;
}
.game-resource-bottom-toolbar-action {

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