Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547fb0dbce | |||
| 06ffa7b6ac | |||
| e4634928c6 | |||
| faa510e999 | |||
| 0b19df467e | |||
| 96b44d8660 | |||
| 33308a93e8 | |||
| f6dad1950f | |||
| a4b103a1b0 | |||
| 05d455d4ef | |||
| d8b37e0da9 | |||
| 21c3bd7a2b | |||
| 756297d2df | |||
| e02811588c | |||
| e1780b21b0 | |||
| 6ed1fd26ed | |||
| 4912aa4df0 |
@@ -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-*` 旧路径习惯。
|
||||
|
||||
@@ -57,6 +57,8 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
-84
@@ -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
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
@@ -13,6 +13,10 @@
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
"bundled-resources:check": "node scripts/check-package-layout.mjs",
|
||||
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
|
||||
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
@@ -24,7 +28,7 @@
|
||||
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cubone/react-file-manager": "^1.35.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { prepareBundledResources } from './prepare-bundled-resources.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -431,10 +432,30 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
|
||||
export function stageBundledResources(
|
||||
target,
|
||||
{ prepare = prepareBundledResources } = {},
|
||||
) {
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(defaultEditorFeatures(target)),
|
||||
profile: 'release',
|
||||
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
console.log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
{
|
||||
spawn = spawnSync,
|
||||
stageRuntime = stageNodeRuntime,
|
||||
stageBundled = stageBundledResources,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -444,7 +465,10 @@ export function runTauriBuild(
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
if (!args.includes('--no-bundle')) {
|
||||
stageRuntime(target);
|
||||
stageBundled(target);
|
||||
}
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
|
||||
@@ -363,6 +363,8 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
@@ -456,6 +458,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -705,6 +709,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -818,6 +823,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
stageBundled(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
@@ -829,11 +838,14 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
assert.deepEqual(events, ['stage', 'bundled', 'build']);
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('no-bundle must not stage bundled resources');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
@@ -848,6 +860,9 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('invalid runtime must prevent bundled staging');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
@@ -960,6 +975,8 @@ for (const channel of ['release', 'beta-2']) {
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
+636
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
prepareBundledResources,
|
||||
supportedHostTarget,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
@@ -67,6 +72,29 @@ function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
||||
return withDefaultCargoFeatures(argv, features);
|
||||
}
|
||||
|
||||
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
|
||||
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
|
||||
function prepareBundledResourcesBeforeTauri(
|
||||
features = readDevCargoFeatures(),
|
||||
{ prepare = prepareBundledResources, log = console.log } = {},
|
||||
) {
|
||||
const target = supportedHostTarget();
|
||||
if (!target) {
|
||||
log(
|
||||
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(features),
|
||||
log: (line) => log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
|
||||
cwd: appRoot,
|
||||
@@ -75,6 +103,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
|
||||
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
|
||||
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
|
||||
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
|
||||
function buildTauriDevProcessEnv(endpoint, env = process.env) {
|
||||
return buildLocalRustProcessEnv({
|
||||
...withAgcDevEndpointEnv(endpoint, env),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTauriDev(
|
||||
argv = process.argv.slice(2),
|
||||
{
|
||||
@@ -84,6 +123,7 @@ async function runTauriDev(
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
prepareResources = prepareBundledResourcesBeforeTauri,
|
||||
} = {},
|
||||
) {
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
@@ -130,15 +170,14 @@ async function runTauriDev(
|
||||
shutdownRequested.then(() => false),
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const devFeatures = readDevCargoFeatures();
|
||||
prepareResources(devFeatures);
|
||||
const tauriArguments = buildTauriArguments(
|
||||
withDevCargoFeatures(argv),
|
||||
withDevCargoFeatures(argv, devFeatures),
|
||||
endpoint.url,
|
||||
);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
},
|
||||
env: buildTauriDevProcessEnv(endpoint),
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
@@ -225,7 +264,9 @@ function isDirectModuleExecution() {
|
||||
|
||||
export {
|
||||
buildTauriArguments,
|
||||
buildTauriDevProcessEnv,
|
||||
isDirectModuleExecution,
|
||||
prepareBundledResourcesBeforeTauri,
|
||||
runTauriDev,
|
||||
spawnTauriCli,
|
||||
withDevCargoFeatures,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user