后台模型定价页新增 3D 两段编辑区块与乐观锁提示
- 新增 3D 定价区块:按端点分「文生 3D / 图生 3D」,各渲染模型版本 × {无贴图, 带贴图} 底价与加价项输入,键全部来自接口返回
- 抽出 adminEditorGenerationPricing 纯模块:数值解析、提交前 ≥1 校验、加价项中文标签、保存请求组装
- 保存整段回传 models 与 3D 两段,并带 expectedUpdatedAtMicros;409 提示重新读取且提供入口
- 后端未配置 3D 段时提示并禁用保存,不在页面新建或停用整段
- API 类型与客户端补两段、定价版本与保存请求类型;补组件与纯模块用例
This commit is contained in:
@@ -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<EditorGenerationPricingConfigPayload>(
|
||||
'/admin/api/editor-generation-pricing',
|
||||
|
||||
@@ -428,8 +428,42 @@ 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;
|
||||
/** 当前定价版本:保存时原样回传做乐观锁,冲突时后端返回 409。 */
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
/** 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,外加读取到的定价版本。 */
|
||||
export interface AdminUpsertEditorGenerationPricingRequest {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
model3d: EditorGenerationModel3dPricingPayload | null;
|
||||
expectedUpdatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
|
||||
@@ -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<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>
|
||||
<label className="admin-field">
|
||||
<span>无贴图</span>
|
||||
<input
|
||||
aria-label={`${label} ${version} 无贴图`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price.noTexture}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
versionPrices: {
|
||||
...current.versionPrices,
|
||||
[version]: {
|
||||
...price,
|
||||
noTexture: parsePositiveInteger(
|
||||
event.target.value,
|
||||
),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>带贴图</span>
|
||||
<input
|
||||
aria-label={`${label} ${version} 带贴图`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price.texture}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
versionPrices: {
|
||||
...current.versionPrices,
|
||||
[version]: {
|
||||
...price,
|
||||
texture: 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>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import('../api/adminApiClient')>(
|
||||
'../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<EditorGenerationPricingConfigPayload> = {},
|
||||
) {
|
||||
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(
|
||||
<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);
|
||||
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(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -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<EditorGenerationPricingUnitPayload, string> = {
|
||||
@@ -27,6 +37,9 @@ const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -141,6 +176,31 @@ export function AdminEditorGenerationPricingPage({
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
{isPricingVersionStale ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshPricing}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>重新读取</span>
|
||||
</button>
|
||||
) : null}
|
||||
</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}
|
||||
|
||||
@@ -156,9 +216,16 @@ export function AdminEditorGenerationPricingPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pricing.model3d ? (
|
||||
<AdminEditorGenerationModel3dPricingSection
|
||||
model3d={pricing.model3d}
|
||||
onChange={updateModel3dPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || !canSave}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
@@ -223,8 +290,3 @@ function renderModelPricingCard({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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]) };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user