From c901f6954fd2a011002d303e877b95bb51bc395c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:44:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=A8=A1=E5=9E=8B=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E9=A1=B5=E6=96=B0=E5=A2=9E=203D=20=E4=B8=A4=E6=AE=B5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8C=BA=E5=9D=97=E4=B8=8E=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E9=94=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 3D 定价区块:按端点分「文生 3D / 图生 3D」,各渲染模型版本 × {无贴图, 带贴图} 底价与加价项输入,键全部来自接口返回 - 抽出 adminEditorGenerationPricing 纯模块:数值解析、提交前 ≥1 校验、加价项中文标签、保存请求组装 - 保存整段回传 models 与 3D 两段,并带 expectedUpdatedAtMicros;409 提示重新读取且提供入口 - 后端未配置 3D 段时提示并禁用保存,不在页面新建或停用整段 - API 类型与客户端补两段、定价版本与保存请求类型;补组件与纯模块用例 --- apps/admin-web/src/api/adminApiClient.ts | 3 +- apps/admin-web/src/api/adminApiTypes.ts | 34 ++++ ...nEditorGenerationModel3dPricingSection.tsx | 136 +++++++++++++ .../AdminEditorGenerationPricingPage.test.tsx | 185 ++++++++++++++++-- .../AdminEditorGenerationPricingPage.tsx | 78 +++++++- .../adminEditorGenerationPricing.test.ts | 88 +++++++++ .../src/pages/adminEditorGenerationPricing.ts | 116 +++++++++++ apps/admin-web/src/styles/admin.css | 29 +++ 8 files changed, 639 insertions(+), 30 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx create mode 100644 apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts create mode 100644 apps/admin-web/src/pages/adminEditorGenerationPricing.ts diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index d85e5a8c8..1ba25ab4a 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -52,6 +52,7 @@ import type { AdminUpdateAccountResponse, AdminUpdateAgcTemplateRequest, AdminUploadedEditorShowcaseCampaignImage, + AdminUpsertEditorGenerationPricingRequest, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, @@ -486,7 +487,7 @@ export function getAdminEditorGenerationPricing(token: string) { export function upsertAdminEditorGenerationPricing( token: string, - payload: EditorGenerationPricingConfigPayload, + payload: AdminUpsertEditorGenerationPricingRequest, ) { return request( '/admin/api/editor-generation-pricing', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 44b833586..9564c4b65 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -428,8 +428,42 @@ export interface EditorGenerationModelPricingPayload { prices?: Record; } +/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */ +export interface EditorGenerationModel3dVersionPricePayload { + noTexture: number; + texture: number; +} + +/** + * 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。 + * + * 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回, + * 不在前端硬编码清单,也不能新增、删除或停用。 + */ +export interface EditorGenerationModel3dEndpointPricingPayload { + versionPrices: Record; + addOnPrices: Record; +} + +/** 3D 生成定价段:两个端点并列,各持自己的设置。 */ +export interface EditorGenerationModel3dPricingPayload { + textToModelPricing: EditorGenerationModel3dEndpointPricingPayload; + imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload; +} + export interface EditorGenerationPricingConfigPayload { models: Record; + /** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */ + model3d?: EditorGenerationModel3dPricingPayload | null; + /** 当前定价版本:保存时原样回传做乐观锁,冲突时后端返回 409。 */ + updatedAtMicros: number; +} + +/** 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,外加读取到的定价版本。 */ +export interface AdminUpsertEditorGenerationPricingRequest { + models: Record; + model3d: EditorGenerationModel3dPricingPayload | null; + expectedUpdatedAtMicros: number; } export interface AdminEditorAssetListQuery { diff --git a/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx b/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx new file mode 100644 index 000000000..569890a1e --- /dev/null +++ b/apps/admin-web/src/pages/AdminEditorGenerationModel3dPricingSection.tsx @@ -0,0 +1,136 @@ +import type { EditorGenerationModel3dPricingPayload } from '../api/adminApiTypes'; +import { + EDITOR_MODEL3D_ENDPOINT_SECTIONS, + editorModel3dAddOnLabel, + parsePositiveInteger, + updateEditorModel3dEndpointPricing, +} from './adminEditorGenerationPricing'; + +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[2], + ) { + onChange(updateEditorModel3dEndpointPricing(model3d, section, updater)); + } + + return ( +
+
+ 3D 生成定价 + 按次 +
+

+ 模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D + 生成不可提交。 +

+
+ {EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => ( +
+
+ {label} +
+
+ {Object.entries(model3d[key].versionPrices).map( + ([version, price]) => ( +
+ + {version} + + + +
+ ), + )} +
+
+ {Object.entries(model3d[key].addOnPrices).map( + ([addOn, price]) => ( + + ), + )} +
+
+ ))} +
+
+ ); +} diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx index fc5718dc0..06d286eda 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -5,20 +5,23 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, vi } from 'vitest'; import { + AdminApiError, getAdminEditorGenerationPricing, upsertAdminEditorGenerationPricing, } from '../api/adminApiClient'; import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes'; import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage'; -vi.mock('../api/adminApiClient', () => ({ - formatAdminApiError: vi.fn((error: unknown) => - error instanceof Error ? error.message : '请求失败', - ), - getAdminEditorGenerationPricing: vi.fn(), - isAdminApiError: vi.fn(() => false), - upsertAdminEditorGenerationPricing: vi.fn(), -})); +vi.mock('../api/adminApiClient', async () => { + const actual = await vi.importActual( + '../api/adminApiClient', + ); + return { + ...actual, + getAdminEditorGenerationPricing: vi.fn(), + upsertAdminEditorGenerationPricing: vi.fn(), + }; +}); const pricing: EditorGenerationPricingConfigPayload = { models: { @@ -41,36 +44,80 @@ 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, + }, + }, + }, + updatedAtMicros: 1_700_000_000_000_000, }; +function savedPricing( + overrides: Partial = {}, +) { + return { + ...pricing, + updatedAtMicros: pricing.updatedAtMicros + 1, + ...overrides, + }; +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing); - vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({ - ...pricing, - models: { - ...pricing.models, - 'gpt-image-2': { - unit: 'perGeneration', - prices: { '1K': 20, '2K': 58 }, + vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue( + savedPricing({ + models: { + ...pricing.models, + 'gpt-image-2': { + unit: 'perGeneration', + prices: { '1K': 20, '2K': 58 }, + }, }, - }, - }); + }), + ); }); -test('模型定价后台按模型展示单位并保存尺寸定价', async () => { - const user = userEvent.setup(); +async function renderPage() { render( , ); + 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); - const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K'); - fireEvent.change(gptImage2kInput, { target: { value: '58' } }); + fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), { + target: { value: '58' }, + }); await user.click(screen.getByRole('button', { name: '保存定价' })); await user.click(screen.getByRole('button', { name: '确认' })); @@ -90,3 +137,99 @@ 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({ + expectedUpdatedAtMicros: pricing.updatedAtMicros, + 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('保存遇到 409 时提示重新读取且不覆盖本地改动', async () => { + const user = userEvent.setup(); + vi.mocked(upsertAdminEditorGenerationPricing).mockRejectedValue( + new AdminApiError({ message: '定价冲突', status: 409 }), + ); + await renderPage(); + + await user.click(screen.getByRole('button', { name: '保存定价' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('定价已被其他人更新,请重新读取后再编辑保存'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '重新读取' })).toBeTruthy(); +}); + +test('后端未配置 3D 段时给出提示并禁止保存', async () => { + vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({ + ...pricing, + model3d: null, + }); + render( + , + ); + + expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx index e8326ed8b..2733688cd 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx @@ -3,14 +3,22 @@ import { FormEvent, useEffect, useState } from 'react'; import { getAdminEditorGenerationPricing, + isAdminApiError, 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, + parsePositiveInteger, +} from './adminEditorGenerationPricing'; import { handlePageError } from './pageUtils'; interface AdminEditorGenerationPricingPageProps { @@ -20,6 +28,8 @@ interface AdminEditorGenerationPricingPageProps { const emptyPricing: EditorGenerationPricingConfigPayload = { models: {}, + model3d: null, + updatedAtMicros: 0, }; const unitLabels: Record = { @@ -27,6 +37,9 @@ const unitLabels: Record = { perSecond: '按秒', }; +const PRICING_VERSION_CONFLICT_MESSAGE = + '定价已被其他人更新,请重新读取后再编辑保存'; + export function AdminEditorGenerationPricingPage({ token, onUnauthorized, @@ -36,6 +49,7 @@ export function AdminEditorGenerationPricingPage({ const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [isPricingVersionStale, setIsPricingVersionStale] = useState(false); const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); useEffect(() => { @@ -46,6 +60,7 @@ export function AdminEditorGenerationPricingPage({ async function refreshPricing() { setIsLoading(true); setErrorMessage(''); + setIsPricingVersionStale(false); try { setPricing(await getAdminEditorGenerationPricing(token)); } catch (error: unknown) { @@ -57,10 +72,11 @@ export function AdminEditorGenerationPricingPage({ async function handleSave(event: FormEvent) { event.preventDefault(); - if (isSaving) { + if (isSaving || !canSave) { return; } setErrorMessage(''); + setIsPricingVersionStale(false); const confirmed = await confirmWrite({ action: '保存模型定价', target: '图片画布生成', @@ -71,8 +87,18 @@ export function AdminEditorGenerationPricingPage({ setIsSaving(true); try { - setPricing(await upsertAdminEditorGenerationPricing(token, pricing)); + setPricing( + await upsertAdminEditorGenerationPricing( + token, + buildAdminEditorGenerationPricingRequest(pricing), + ), + ); } catch (error: unknown) { + if (isAdminApiError(error) && error.status === 409) { + setErrorMessage(PRICING_VERSION_CONFLICT_MESSAGE); + setIsPricingVersionStale(true); + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { setIsSaving(false); @@ -120,6 +146,15 @@ 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 (
@@ -141,6 +176,31 @@ export function AdminEditorGenerationPricingPage({ {errorMessage ? (
{errorMessage} + {isPricingVersionStale ? ( + + ) : null} +
+ ) : null} + + {isModel3dMissing ? ( +
+ 当前部署未配置 3D 定价段(缺失即 3D + 生成不可提交):需先由受控默认配置提供两段价格, + 后台不在页面里新建或停用整段。 +
+ ) : null} + + {invalidPriceLabels.length > 0 ? ( +
+ {`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
) : null} @@ -156,9 +216,16 @@ export function AdminEditorGenerationPricingPage({ )}
+ {pricing.model3d ? ( + + ) : null} +
); } - -function parsePositiveInteger(value: string) { - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts new file mode 100644 index 000000000..b4fc133a6 --- /dev/null +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from 'vitest'; + +import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes'; +import { + buildAdminEditorGenerationPricingRequest, + collectInvalidEditorGenerationPricingLabels, + editorModel3dAddOnLabel, + parsePositiveInteger, +} from './adminEditorGenerationPricing'; + +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 }, + }, + }, + updatedAtMicros: 42, + }; +} + +test('数值解析把空串、非数字与 0 收敛成 0', () => { + expect(parsePositiveInteger('12')).toBe(12); + expect(parsePositiveInteger('')).toBe(0); + expect(parsePositiveInteger('abc')).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, + expectedUpdatedAtMicros: 42, + }); + expect( + buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }), + ).toEqual({ + models: pricing.models, + model3d: null, + expectedUpdatedAtMicros: 42, + }); +}); diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts new file mode 100644 index 000000000..df234fc43 --- /dev/null +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts @@ -0,0 +1,116 @@ +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 = { + hdTexture: '高清贴图', + ultraTexture: '超高清贴图', + hdGeometry: '高清几何', + quadMesh: '四边形网格', + smartLowPoly: '智能低面', + generateParts: '分件生成', +}; + +export function editorModel3dAddOnLabel(addOn: string) { + return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn; +} + +/** 输入框文本转整数;空串、非数字、0 与负数一律收敛成 0,交给提交前校验拦住。 */ +export function parsePositiveInteger(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +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)) { + if (modelPricing.prices) { + for (const [tier, price] of Object.entries(modelPricing.prices)) { + 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]; + 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, + expectedUpdatedAtMicros: pricing.updatedAtMicros, + }; +} + +/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */ +export function updateEditorModel3dEndpointPricing( + model3d: EditorGenerationModel3dPricingPayload, + section: keyof EditorGenerationModel3dPricingPayload, + updater: ( + current: EditorGenerationModel3dEndpointPricingPayload, + ) => EditorGenerationModel3dEndpointPricingPayload, +): EditorGenerationModel3dPricingPayload { + return { ...model3d, [section]: updater(model3d[section]) }; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 2198adbdf..a4f0c5c76 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -2972,6 +2972,34 @@ 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; @@ -3022,6 +3050,7 @@ 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,