Compare commits

..

1 Commits

Author SHA1 Message Date
k88936 b43a663fe2 修复移动端顶栏折行与头像裁剪弹窗被底部主菜单遮挡
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Successful in 4m5s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m46s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m42s
Project CI / Repository checks (pull_request) Failing after 2m3s
- 主站顶栏在所有断点保持单行:去掉顶栏与动作区的 flex-wrap,品牌和下载、泥点、账户入口不再折成两行
- 窄屏(<640px)下载入口收起为 44×44 图标按钮,保留 aria-label/title,≥640px 恢复「下载客户端」完整文案
- 窄屏(<480px)顶栏品牌只保留 IP 标识,让出空间给下载、泥点与账户入口,并收紧窄屏动作区间距
- 头像裁剪弹窗去掉 portal={false},改由 UnifiedModal 默认 portal 渲染到 document.body
- 个人中心两级弹窗壳层同步去掉 portal={false},昵称等个人中心弹窗一并回到页面级层级
- 弹窗不再受 .platform-desktop-layout 的 z-index:1 stacking context 限制,层级高于固定底部主菜单(z-index:60)
- 共享记忆补记单行顶栏口径与「内联弹窗被固定底栏压住」的排障经验
2026-09-24 21:22:15 +08:00
266 changed files with 1074 additions and 23437 deletions
-9
View File
@@ -145,15 +145,6 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
ELEVENLABS_API_KEY="" ELEVENLABS_API_KEY=""
ELEVENLABS_REQUEST_TIMEOUT_MS="180000" 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 配置。 # 阿里云 OSS 配置。
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
-84
View File
@@ -67,90 +67,6 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛
## Language ## 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 ### Puzzle Clear
**拼消消**: **拼消消**:
+1 -2
View File
@@ -60,7 +60,6 @@ import type {
AdminUpdateAccountResponse, AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest, AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage, AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorGenerationPricingRequest,
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest, AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileInviteCodeRequest,
@@ -520,7 +519,7 @@ export function getAdminEditorGenerationPricing(token: string) {
export function upsertAdminEditorGenerationPricing( export function upsertAdminEditorGenerationPricing(
token: string, token: string,
payload: AdminUpsertEditorGenerationPricingRequest, payload: EditorGenerationPricingConfigPayload,
) { ) {
return request<EditorGenerationPricingConfigPayload>( return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing', '/admin/api/editor-generation-pricing',
-35
View File
@@ -431,43 +431,8 @@ export interface EditorGenerationModelPricingPayload {
prices?: Record<string, number>; 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 { export interface EditorGenerationPricingConfigPayload {
models: Record<string, EditorGenerationModelPricingPayload>; models: Record<string, EditorGenerationModelPricingPayload>;
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
model3d?: EditorGenerationModel3dPricingPayload | null;
}
/**
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
*
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
*/
export interface AdminUpsertEditorGenerationPricingRequest {
models: Record<string, EditorGenerationModelPricingPayload>;
model3d: EditorGenerationModel3dPricingPayload | null;
} }
export interface AdminEditorAssetListQuery { 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 type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage'; import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
vi.mock('../api/adminApiClient', async () => { vi.mock('../api/adminApiClient', () => ({
const actual = await vi.importActual<typeof import('../api/adminApiClient')>( formatAdminApiError: vi.fn((error: unknown) =>
'../api/adminApiClient', error instanceof Error ? error.message : '请求失败',
); ),
return {
...actual,
getAdminEditorGenerationPricing: vi.fn(), getAdminEditorGenerationPricing: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminEditorGenerationPricing: vi.fn(), upsertAdminEditorGenerationPricing: vi.fn(),
}; }));
});
const pricing: EditorGenerationPricingConfigPayload = { const pricing: EditorGenerationPricingConfigPayload = {
models: { models: {
@@ -43,48 +41,13 @@ const pricing: EditorGenerationPricingConfigPayload = {
'audio1.0': { unit: 'perGeneration', price: 5 }, 'audio1.0': { unit: 'perGeneration', price: 5 },
'chirp-v5': { unit: 'perGeneration', price: 12 }, '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(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing); vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue( vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
savedPricing({ ...pricing,
models: { models: {
...pricing.models, ...pricing.models,
'gpt-image-2': { 'gpt-image-2': {
@@ -92,29 +55,22 @@ beforeEach(() => {
prices: { '1K': 20, '2K': 58 }, prices: { '1K': 20, '2K': 58 },
}, },
}, },
}), });
);
}); });
async function renderPage() { test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
render( render(
<AdminEditorGenerationPricingPage <AdminEditorGenerationPricingPage
token="admin-token" token="admin-token"
onUnauthorized={vi.fn()} onUnauthorized={vi.fn()}
/>, />,
); );
await screen.findByText('3D 生成定价');
}
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
await renderPage();
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0); expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0); expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), { const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
target: { value: '58' }, fireEvent.change(gptImage2kInput, { target: { value: '58' } });
});
await user.click(screen.getByRole('button', { name: '保存定价' })); await user.click(screen.getByRole('button', { name: '保存定价' }));
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, upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient'; } from '../api/adminApiClient';
import type { import type {
EditorGenerationModel3dPricingPayload,
EditorGenerationModelPricingPayload, EditorGenerationModelPricingPayload,
EditorGenerationPricingConfigPayload, EditorGenerationPricingConfigPayload,
EditorGenerationPricingUnitPayload, EditorGenerationPricingUnitPayload,
} from '../api/adminApiTypes'; } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection'; import { handlePageError } from './pageUtils';
import {
buildAdminEditorGenerationPricingRequest,
collectInvalidEditorGenerationPricingLabels,
} from './adminEditorGenerationPricing';
import { handlePageError, parsePositiveInteger } from './pageUtils';
interface AdminEditorGenerationPricingPageProps { interface AdminEditorGenerationPricingPageProps {
token: string; token: string;
@@ -26,7 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
const emptyPricing: EditorGenerationPricingConfigPayload = { const emptyPricing: EditorGenerationPricingConfigPayload = {
models: {}, models: {},
model3d: null,
}; };
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = { const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
@@ -64,7 +57,7 @@ export function AdminEditorGenerationPricingPage({
async function handleSave(event: FormEvent<HTMLFormElement>) { async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (isSaving || !canSave) { if (isSaving) {
return; return;
} }
setErrorMessage(''); setErrorMessage('');
@@ -78,12 +71,7 @@ export function AdminEditorGenerationPricingPage({
setIsSaving(true); setIsSaving(true);
try { try {
setPricing( setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
await upsertAdminEditorGenerationPricing(
token,
buildAdminEditorGenerationPricingRequest(pricing),
),
);
} catch (error: unknown) { } catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage); handlePageError(error, onUnauthorized, setErrorMessage);
} finally { } 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 ( return (
<section className="admin-page admin-page-wide"> <section className="admin-page admin-page-wide">
<div className="admin-page-heading"> <div className="admin-page-heading">
@@ -165,20 +144,6 @@ export function AdminEditorGenerationPricingPage({
</div> </div>
) : null} ) : 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}> <form className="admin-stack" onSubmit={handleSave}>
<div className="admin-pricing-model-list admin-pricing-model-list--single"> <div className="admin-pricing-model-list admin-pricing-model-list--single">
{Object.entries(pricing.models).map(([model, modelPricing]) => {Object.entries(pricing.models).map(([model, modelPricing]) =>
@@ -191,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
)} )}
</div> </div>
{pricing.model3d ? (
<AdminEditorGenerationModel3dPricingSection
model3d={pricing.model3d}
onChange={updateModel3dPrice}
/>
) : null}
<button <button
className="admin-primary-button" className="admin-primary-button"
disabled={isSaving || !canSave} disabled={isSaving}
type="submit" type="submit"
> >
<Save size={17} aria-hidden="true" /> <Save size={17} aria-hidden="true" />
@@ -265,3 +223,8 @@ function renderModelPricingCard({
</section> </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'; } from '../api/adminApiClient';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes'; import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger } from './pageUtils'; import { handlePageError } from './pageUtils';
interface AdminProfileWalletConfigPageProps { interface AdminProfileWalletConfigPageProps {
token: string; token: string;
@@ -197,3 +197,8 @@ export function AdminProfileWalletConfigPage({
</section> </section>
); );
} }
function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
@@ -11,7 +11,7 @@ import type {
ProfileRechargeProductKind, ProfileRechargeProductKind,
} from '../api/adminApiTypes'; } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger } from './pageUtils'; import { handlePageError } from './pageUtils';
interface AdminRechargeProductPageProps { interface AdminRechargeProductPageProps {
token: string; token: string;
@@ -546,6 +546,11 @@ function formatPrice(priceCents: number) {
return `¥${(priceCents / 100).toFixed(2)}`; return `¥${(priceCents / 100).toFixed(2)}`;
} }
function parsePositiveInteger(value: string) {
const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0;
}
function parseNonNegativeInteger(value: string) { function parseNonNegativeInteger(value: string) {
const parsed = parseInteger(value); const parsed = parseInteger(value);
return parsed > 0 ? parsed : 0; return parsed > 0 ? parsed : 0;
@@ -12,7 +12,7 @@ import type {
ProfileRedeemCodeMode, ProfileRedeemCodeMode,
} from '../api/adminApiTypes'; } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils'; import { handlePageError, splitLines } from './pageUtils';
interface AdminRedeemCodePageProps { interface AdminRedeemCodePageProps {
token: string; 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) { function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value; return redeemModes.find((item) => item.value === value)?.label ?? value;
} }
@@ -19,7 +19,7 @@ import {
filterAdminTrackingEventKeyOptions, filterAdminTrackingEventKeyOptions,
findAdminTrackingEventDefinition, findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions'; } from '../config/trackingEventDefinitions';
import { handlePageError, parsePositiveInteger } from './pageUtils'; import { handlePageError } from './pageUtils';
interface AdminTaskConfigPageProps { interface AdminTaskConfigPageProps {
token: string; 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) { function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0; 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'; 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( export function handlePageError(
error: unknown, error: unknown,
onUnauthorized: (message?: string) => void, onUnauthorized: (message?: string) => void,
-29
View File
@@ -2972,34 +2972,6 @@ button:disabled {
gap: 10px; 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 { .admin-pricing-unit {
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 999px; border-radius: 999px;
@@ -3050,7 +3022,6 @@ button:disabled {
.admin-two-column-wide, .admin-two-column-wide,
.admin-accounts-layout, .admin-accounts-layout,
.admin-pricing-grid, .admin-pricing-grid,
.admin-pricing-model3d-row,
.admin-form-row, .admin-form-row,
.admin-filter-grid, .admin-filter-grid,
.admin-table-query-grid, .admin-table-query-grid,
+4 -3
View File
@@ -6312,7 +6312,8 @@ dependencies = [
[[package]] [[package]]
name = "ts-rs" name = "ts-rs"
version = "12.0.1" 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 = [ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"ts-rs-macros 12.0.1", "ts-rs-macros 12.0.1",
@@ -6333,9 +6334,9 @@ dependencies = [
[[package]] [[package]]
name = "ts-rs-macros" name = "ts-rs-macros"
version = "12.0.1" 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 = [ dependencies = [
"indexmap 2.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.118", "syn 2.0.118",
@@ -29,10 +29,7 @@ tauri-build = { version = "2.6.2", features = [] }
[dependencies] [dependencies]
codex-patch-parser = { path = "vendor/codex-patch-parser" } codex-patch-parser = { path = "vendor/codex-patch-parser" }
chrono = { version = "0.4", default-features = false, features = ["std"] } chrono = { version = "0.4", default-features = false, features = ["std"] }
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)` ts-rs = "12.0.1"
# (见 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" }
typed_floats = { version = "1.0.7", features = ["serde"] } typed_floats = { version = "1.0.7", features = ["serde"] }
nalgebra = { version = "0.35.0", features = ["serde-serialize"] } nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } 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" portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1" 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 = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
@@ -1266,11 +1266,13 @@
max-width: calc(100% - 28px); max-width: calc(100% - 28px);
} }
/* 「生成规范」的二级菜单(`bottom:100%+6px`)必须可见:换行与不裁剪由共享工具栏保证 /* 共享 chrome 的 `.genarrative-image-canvas__toolbar` 是横向滚动条(`overflow-x:auto`),
(`@genarrative/image-canvas-react` 的 `.genarrative-image-canvas__toolbar` 已从 会把它自己弹出的二级菜单(`bottom:100%+6px`)裁掉。这里改成换行、不裁切:
`overflow-x: auto` 改成 `flex-wrap: wrap`),这里只限宽,不再重复覆盖 overflow。 */ 「生成规范」的二级菜单必须可见,窄屏用换行兜底而不是横向滚动。 */
.game-resource-bottom-toolbar-strip { .game-resource-bottom-toolbar-strip {
max-width: 100%; max-width: 100%;
overflow: visible;
flex-wrap: wrap;
} }
.game-resource-bottom-toolbar-action { .game-resource-bottom-toolbar-action {
@@ -10201,10 +10201,6 @@ export default function ProjectDevelopmentView({
onOpenCharacterAnimationPanel={ onOpenCharacterAnimationPanel={
openResourceCharacterAnimationPanel openResourceCharacterAnimationPanel
} }
/* 资源画布不接 3D 预览:`resolveResourceCanvasToolbarActions`
不登记 preview-model3d,按钮不会渲染,这里只为满足共享工具条
的必填签名,与上面其余未接通动作的写法一致。 */
onPreviewModel3d={() => {}}
onDownloadLayer={() => { onDownloadLayer={() => {
if ( if (
!selectedResource || !selectedResource ||
@@ -8884,20 +8884,9 @@ export function registerProjectAgentStatusTests() {
expect(dock).toMatch(/(?:^|[;\s])right:/u); expect(dock).toMatch(/(?:^|[;\s])right:/u);
expect(dock).not.toMatch(/(?:^|[;\s])left:/u); expect(dock).not.toMatch(/(?:^|[;\s])left:/u);
// 二级菜单不能被工具栏自己的横向滚动裁掉。这个机制住在共享工具栏里:托管规则曾经是 // 二级菜单不能被工具栏自己的横向滚动裁掉(共享 chrome 默认 overflow-x: auto)。
// `overflow-x: auto`(把它自己底部弹出的二级菜单裁掉,也把尾部工具滚出视野),现在 expect(
// 换成「换行 + 不裁剪」,所以这里直接钉住共享规则本身 —— 谁把滚动容器加回来, styleRuleBody(chromeStyles, '\\.game-resource-bottom-toolbar-strip'),
// 「生成规范」的二级菜单就会立刻被裁掉,这条断言先失败。 ).toMatch(/overflow:\s*visible/u);
const sharedToolbar = styleRuleBody(
readFileSync(
repoPath('packages/image-canvas-react/src/styles.css'),
'utf8',
),
'\\.genarrative-image-canvas__toolbar',
);
expect(sharedToolbar).toMatch(/flex-wrap:\s*wrap/u);
expect(sharedToolbar).not.toMatch(
/overflow(?:-[xy])?\s*:\s*(?:auto|scroll|hidden)/u,
);
}); });
} }
-9
View File
@@ -47,15 +47,6 @@ GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID=
GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET= GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET=
GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000 GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS=30000
# Tripo 3D 生成:网关与密钥没有内置默认值,会提交 / 执行 3D job 的角色
#(api / external-generation-worker / all)缺任意一项就拒绝启动。
# 下面的占位值只用于容器压测与 Jenkins 分支预览这类不跑真实 3D 请求的环境;
# 要真实跑 3D 必须换成真凭据,否则进程能起、3D 提交会在请求期 503。
TRIPO_BASE_URL=https://openapi.tripo3d.com/v3
TRIPO_API_KEY=CHANGE_ME_FOR_CONTAINER
TRIPO_REQUEST_TIMEOUT_MS=60000
TRIPO_RETRIES=2
GENARRATIVE_OTEL_ENABLED=true GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
-9
View File
@@ -101,15 +101,6 @@ ELEVENLABS_BASE_URL=https://api.elevenlabs.io
ELEVENLABS_API_KEY= ELEVENLABS_API_KEY=
ELEVENLABS_REQUEST_TIMEOUT_MS=180000 ELEVENLABS_REQUEST_TIMEOUT_MS=180000
# Tripo 3D 生成:两项都是必填,缺任意一项时 api-server / external-generation-worker
# 会在启动期直接拒绝启动(不兜底到任何默认网关)。网关没有内置默认值;
# 密钥按部署机约定放受保护文件或受保护 env,不要写进仓库,也不要把这一行留空后部署。
TRIPO_BASE_URL=https://openapi.tripo3d.com/v3
TRIPO_API_KEY=
# 可缺省(沿用内置 60000 / 2);显式给出时零值或越界值会让进程拒绝启动。
TRIPO_REQUEST_TIMEOUT_MS=60000
TRIPO_RETRIES=2
HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2 HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2
HYPER3D_API_KEY= HYPER3D_API_KEY=
HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000 HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000
-10
View File
@@ -32,9 +32,6 @@
- [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。 - [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。
- [Tripo 3D 模型 Provider 集成](<./technical/【技术方案】Tripo 3D模型Provider集成-2026-09-18.md>):实现 text-to-model、image-to-model、multiview-to-model 的 Rust provider adapter 与流式下载边界。
- [Tripo 3D 生成 API 集成](<./technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md>):把 text-to-model 与 image-to-model 接成内部 API,复用异步队列与状态查询,产物落 OSS 并登记资源,按显式泥点定价真实扣费。
- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
- [Responses API 与 Agents SDK 迁移评估](./technical/【技术评估】Responses%20API与Agents%20SDK迁移评估-2026-09-07.md):评估 AGC 核心 Runtime 是否迁移到外部 Agent SDK 及其边界。 - [Responses API 与 Agents SDK 迁移评估](./technical/【技术评估】Responses%20API与Agents%20SDK迁移评估-2026-09-07.md):评估 AGC 核心 Runtime 是否迁移到外部 Agent SDK 及其边界。
@@ -95,13 +92,6 @@
## 后端、运维与测试 ## 后端、运维与测试
- [Tripo SDK 隔离与 Provider DTO 边界 ADR](./adr/【ADR】0001-Tripo%20SDK隔离与Provider%20DTO边界-2026-09-18.md):固定 SDK 只存在于 `platform-tripo` 内部,provider task 与产品资源结果分层。
- [Tripo 生成 API 集成边界与提交时定价 ADR](./adr/【ADR】0002-Tripo生成API集成边界与提交时定价-2026-09-21.md):全新 API 不复用 Hyper3D,at-most-once submit,按提交时定价真实扣费。
- [3D 资源客户端媒体投影与格式真实性 ADR](./adr/【ADR】0003-3D资源客户端媒体投影与格式真实性-2026-09-21.md):3D 资源的客户端投影按类别分叉,模型内容类型按字节识别,格式判定只在查看器包内(声明类型 → 字节魔数 → 地址扩展名),画布不判格式。
- [3D 生成入口的价格真相与幂等身份 ADR](./adr/【ADR】0004-3D生成入口价格与幂等身份-2026-09-21.md):3D 生成入口的价格只读实时定价查询,缺段即不可提交;幂等键由前端铸造,重试必须换键。
- [3D 生成定价迁入 SpacetimeDB 与后台编辑 ADR](./adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md):3D 定价按端点拆成两段并列设置并改为 SpacetimeDB 权威、后台可编辑;公开读模型与画布 3D 入口不变。
- [Tripo 配置启动期失败关闭 ADR](./adr/【ADR】0006-Tripo配置启动期失败关闭-2026-09-24.md):会提交或会执行 3D job 的角色缺 `TRIPO_BASE_URL` / `TRIPO_API_KEY` 即拒绝启动;网关无内置默认值,两个运行旋钮可缺省但声明即校验。
- [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md)
- [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。 - [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。
- [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md)
@@ -1,9 +0,0 @@
# 【ADR】0001-Tripo SDK隔离与Provider DTO边界-2026-09-18
状态:已接受
`tripo3d-sdk` 只允许出现在 `platform-tripo` 内部。该 crate 以显式必填配置创建可复用 provider client,只公开自己的 text-to-model、image-to-model、multiview-to-model DTO、通用单次 task 查询、显式模型下载流和结构化错误;不暴露 SDK 类型、`wait_for_task` 或后台轮询。模型下载由 provider 自有 HTTP client 逐块消费,不提供完整 `Vec<u8>`;未来 OSS 持久化直接承接同一数据流,第三方 SDK 保持原样。共享产品契约与 provider task 结果分层,provider adapter 不伪造 `resourceId`/`assetId`;shared-contracts 的 Rust 类型始终通过 ts-rs 生成目录化 TypeScript binding。
选择这个边界是为了避免 API 层绑定第三方 SDK 的任务/错误模型,同时让未来的资源持久化、幂等和 provider 替换由应用层负责。请求 enum 以 Tripo API 文档为准;当前 `compress=geometry`、纹理质量 `fast/standard/detailed/extreme` 及纹理版本已经纳入目录化 Rust/TypeScript contracts。文档确认不存在于这三个 endpoint 的 `style` 字段不再保留占位类型,组合约束在 provider 副作用前执行。
完成结果按 endpoint 建模为严格类型:text-to-model 要求模型、渲染图和生成参考图 URL,image-to-model 与 multiview-to-model 要求模型和渲染图 URL;共同 task DTO 只用 enum 标识具体结果,不再提供由 `Vec` 和可选字段组成的宽松通用 output。URL 使用语义中性的 `TripoUrl`,真实响应缺少对应必填字段时以 output schema error 失败关闭。
@@ -1,15 +0,0 @@
# 【ADR】0002-Tripo 生成 API 集成边界与提交时定价-2026-09-21
状态:已接受
Tripo 3D 生成以**全新 API** 接入 api-server,不复用 `platform-hyper3d` 的路由、契约、错误映射或实现,也不做 provider 静默切换;旧 Hyper3D 能力保持原样。两个首期端点是 text-to-model 与 image-to-model,multiview 与 Splat 不接入。异步执行复用现有 `external_generation_job` 队列、worker、租约与状态查询,不新建平行队列、状态枚举或查询接口。对外只暴露 Genarrative 自己的 operation 标识,Tripo task 仅以服务端 checkpoint 形式存在,任何 API 都不返回 provider task ID、SDK 类型或带签名的临时 URL。
选择这个边界是因为第三方 SDK 的任务与错误模型会随 provider 变化,一旦穿透到 API 与前端就会把手第三方约束固化成产品契约;同时 Hyper3D 与 Tripo 的参数、状态与计费形态都不同,把它们塞进同一组路径会让“路径名”与“实际 provider”不一致,调用方无法分辨。
**at-most-once submit**:其它生成任务的崩溃恢复模型是“租约过期后重新 claim,handler 从头重跑”,这对可重跑的 provider 安全,但 Tripo 重跑会二次 submit、二次消耗额度。Tripo job 固定 `max_attempts = 1`,崩溃后租约耗尽直接终态失败、不重新 claim,因此没有续跑路径。因此 `external_generation_job` 追加 provider checkpoint 字段并新增受租约栅栏保护的写入 procedure:没有 checkpoint 才允许 submit,submit 成功后必须先落 checkpoint 再轮询,已有 checkpoint 只允许查询、下载与落库(防御性硬约束)。submit 成功但 checkpoint 落库失败的 attempt 只能终态失败,不得退回可重试队列。checkpoint 不写入 `request_payload_json`,因为该字段是请求真相并参与请求指纹与压缩逻辑。
**提交时定价与真实扣费**:Tripo 计费形态是“底价 + 可叠加 add-on”,现有“模型 → 档位 → 单价”查表表达不了,因此新增 `model3d` 定价段,全部以泥点计价,底价按 `endpoint × modelVersion × 是否有贴图` 拆分,add-on 按请求参数判定叠加;配置加载即校验全部底价键存在,运行期缺键直接拒绝提交,不复用现有 `unwrap_or(0)` 兜底。扣费依据固定为提交时定价,而不是 provider 返回的实际消耗:用户在提交前即可得到确定价格,失败退款就是 attempt 级单次冲正,不需要按实际用量退差额。provider 的实际消耗只写日志用于成本对账,不进入契约、API 响应或资源行。
**provider 成功但落库失败按失败处理并退款**:没有正式资源引用就不算交付成功。这会带来“provider 已消耗、用户已退款”的净亏损,是明确接受的成本,换取的是“用户看到成功就一定拿得到资源”的确定性。首期不提供取消,因为 SDK 没有取消能力,提供本地取消会让调用方误以为 provider 任务已停止。
代价与已知取舍:复用队列意味着要在一个被多个 job kind 共用的表上追加 provider 专用列;真实扣费意味着上线前必须先把 20 个底价键与 add-on 价格确认到位,否则接口无法开放;产物下载沿用完整字节缓冲,40MiB 级模型会占用内存,流式上传作为后续 TODO 处理。

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