AGC 发布面板接入买断制定价并抽取共享定价组件
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m0s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m47s
Project CI / Repository checks (pull_request) Failing after 56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m28s
Project CI / Frontend tests (pull_request) Successful in 4m18s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m7s
Project CI / Native shell tests (pull_request) Successful in 9m27s

packages/shared/src/components:新增 PlatformGamePricingField(.tsx/.css/.test.tsx)与 platformGamePricingModel(上限常量与解析),并从 components/index.ts 导出;组件只表达付费方式单选、买断价输入、上限提示、禁用态与错误行,校验由使用方持有。
src/components/common/PlatformGamePricingField.tsx:按既有的 common 适配约定新增网页端再导出。
src/components/game-distribution/GamePublishPage.tsx:把内联的付费方式 fieldset 与买断价输入替换为共享组件,文案、校验、禁用逻辑与既有用例保持不变。
src/components/game-distribution/gamePublishMetadata.ts:买断价上限与解析改为复用共享常量与共享函数,不再各写一份。
apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx:首次发布与更新两条路径都加入共享定价组件,默认免费;提交前本地校验并透传价格。
apps/ai-game-creator-shell/src/services/gameDistributionPublish.ts:发布命令载荷透传 priceMudPoints,并做 0..=1000000 兜底校验;发布草稿回读新增 priceMudPoints 字段。
apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:Tauri 命令新增可选 price_mud_points(旧前端不传按免费,向后兼容)并替换硬编码的 0;回读作者作品时解析 priceMudPoints,按「最近版本冻结价优先、回退游戏行当前价」预填,避免已上线付费作品被静默改成免费;发布摘要纳入价格,只改价格也会换幂等键,不会重放上一次收据。
apps/ai-game-creator-shell/tests:补充买断价透传、本地校验拦截、更新模式价格预填与免费兜底用例。
packages/shared/src/components/PlatformGamePricingField.test.tsx:覆盖免费/买断制切换、价格输入、上限提示、禁用态与错误行。
This commit is contained in:
2026-10-05 20:20:53 +08:00
parent 93de2a0d6c
commit ce737f4e8b
13 changed files with 665 additions and 75 deletions
@@ -0,0 +1,82 @@
/*
* 买断制定价字段:网页发布页与 AGC 发布面板共用同一份表现与文案。
*
* 选择器统一写成「根类 + 子类」而不是裸类:AGC 发布面板的
* `.game-distribution-publish-panel__fields label/input` 是后代选择器,裸类会被它
* 覆盖。提高一档特异性后,无论宿主在前后引入样式,组件外观都由这里唯一决定。
*/
.platform-game-pricing-field {
display: grid;
gap: 0.55rem;
min-width: 0;
margin: 0;
border: 0;
padding: 0;
font-size: 0.76rem;
color: var(--platform-text-strong, #3d1f10);
}
.platform-game-pricing-field > legend {
padding: 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
}
.platform-game-pricing-field .platform-game-pricing-field__modes {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.platform-game-pricing-field .platform-game-pricing-field__option {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.76rem;
font-weight: 500;
}
.platform-game-pricing-field .platform-game-pricing-field__option input {
width: auto;
min-width: 0;
margin: 0;
}
.platform-game-pricing-field .platform-game-pricing-field__price {
display: grid;
gap: 0.3rem;
font-weight: 500;
}
.platform-game-pricing-field .platform-game-pricing-field__price input {
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--platform-surface-border, rgba(226, 203, 184, 0.72));
border-radius: 0.7rem;
padding: 0.55rem 0.7rem;
background: var(--platform-panel-fill, #fffdfa);
color: var(--platform-text-strong, #3d1f10);
font: inherit;
font-weight: 500;
}
.platform-game-pricing-field
.platform-game-pricing-field__price
input:focus-visible {
border-color: var(--platform-accent, #c7653d);
outline: 2px solid rgba(199, 101, 61, 0.2);
outline-offset: 1px;
}
.platform-game-pricing-field .platform-game-pricing-field__hint {
color: var(--platform-text-soft, #988476);
font-size: 0.7rem;
font-weight: 500;
}
.platform-game-pricing-field .platform-game-pricing-field__error {
color: var(--platform-button-danger-text, #a6402f);
font-size: 0.7rem;
font-weight: 600;
}
@@ -0,0 +1,134 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
PlatformGamePricingField,
resolvePlatformGamePriceMudPoints,
} from './index';
function PricingHarness({
disabled = false,
error = '',
}: {
disabled?: boolean;
error?: string;
}) {
const [mode, setMode] = useState<'free' | 'paid'>('free');
const [priceInput, setPriceInput] = useState('');
return (
<PlatformGamePricingField
mode={mode}
priceInput={priceInput}
disabled={disabled}
error={error}
onModeChange={setMode}
onPriceInputChange={setPriceInput}
/>
);
}
test('默认免费只渲染付费方式,不渲染买断价输入', () => {
render(<PricingHarness />);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'checked',
true,
);
expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty(
'checked',
false,
);
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('切到买断制后显示价格输入、上限提示,并回传输入值', () => {
render(<PricingHarness />);
fireEvent.click(screen.getByRole('radio', { name: '买断制' }));
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
expect(priceInput.value).toBe('');
expect(screen.getByText(/最多 1000000 泥点/u)).toBeTruthy();
fireEvent.change(priceInput, { target: { value: '240' } });
expect(priceInput.value).toBe('240');
fireEvent.click(screen.getByRole('radio', { name: '免费' }));
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
});
test('禁用态锁住单选与价格输入', () => {
render(<PricingHarness disabled />);
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
'disabled',
true,
);
expect(screen.getByRole('radio', { name: '买断制' })).toHaveProperty(
'disabled',
true,
);
});
test('使用方传入的校验错误在字段内以 alert 呈现', () => {
render(<PricingHarness error="买断价必须是整数泥点" />);
expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点');
});
test('价格解析与提交口径一致:免费为 0,买断制要求 1..=1000000 整数', () => {
expect(resolvePlatformGamePriceMudPoints('free', 'abc')).toEqual({
priceMudPoints: 0,
error: '',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1000000')).toEqual({
priceMudPoints: PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
error: '',
});
expect(resolvePlatformGamePriceMudPoints('paid', '')).toEqual({
priceMudPoints: 0,
error: '买断价必须是整数泥点',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1.5')).toEqual({
priceMudPoints: 0,
error: '买断价必须是整数泥点',
});
expect(resolvePlatformGamePriceMudPoints('paid', '1000001')).toEqual({
priceMudPoints: 0,
error: '买断价必须是 1 到 1000000 之间的整数泥点',
});
});
test('onModeChange 与 onPriceInputChange 只回传使用方状态', () => {
const onModeChange = vi.fn();
const onPriceInputChange = vi.fn();
const { rerender } = render(
<PlatformGamePricingField
mode="free"
priceInput=""
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: '买断制' }));
expect(onModeChange).toHaveBeenCalledWith('paid');
expect(screen.queryByLabelText(/买断价/u)).toBeNull();
rerender(
<PlatformGamePricingField
mode="paid"
priceInput="0"
onModeChange={onModeChange}
onPriceInputChange={onPriceInputChange}
/>,
);
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '12' },
});
expect(onPriceInputChange).toHaveBeenCalledWith('12');
});
@@ -0,0 +1,85 @@
import './PlatformGamePricingField.css';
import {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
type PlatformGamePricingMode,
} from './platformGamePricingModel';
export type PlatformGamePricingFieldProps = {
/** 付费方式;`free` 等价于 0 泥点。 */
mode: PlatformGamePricingMode;
/** 买断价原始输入(受控字符串),校验由使用方持有。 */
priceInput: string;
disabled?: boolean;
/** 使用方算出的校验结果;为空串或省略时不渲染错误行。 */
error?: string;
onModeChange: (mode: PlatformGamePricingMode) => void;
onPriceInputChange: (value: string) => void;
/** 同页存在多组定价字段时的单选组名;省略时用默认值。 */
radioName?: string;
};
/**
* 平台游戏买断制定价字段。
*
* 网页发布页与 AGC 发布面板共用同一份表现和文案;组件只表达输入 UI,价格校验、提交
* 与业务状态由使用方持有。上限取自共享常量,两端不各写一份。
*/
export function PlatformGamePricingField({
mode,
priceInput,
disabled = false,
error = '',
onModeChange,
onPriceInputChange,
radioName = 'platform-game-pricing-mode',
}: PlatformGamePricingFieldProps) {
return (
<fieldset className="platform-game-pricing-field" disabled={disabled}>
<legend>付费方式</legend>
<div className="platform-game-pricing-field__modes">
<label className="platform-game-pricing-field__option">
<input
type="radio"
name={radioName}
checked={mode === 'free'}
disabled={disabled}
onChange={() => onModeChange('free')}
/>
免费
</label>
<label className="platform-game-pricing-field__option">
<input
type="radio"
name={radioName}
checked={mode === 'paid'}
disabled={disabled}
onChange={() => onModeChange('paid')}
/>
买断制
</label>
</div>
{mode === 'paid' ? (
<label className="platform-game-pricing-field__price">
买断价(泥点)
<input
type="number"
inputMode="numeric"
value={priceInput}
disabled={disabled}
onChange={(event) => onPriceInputChange(event.target.value)}
/>
<span className="platform-game-pricing-field__hint">
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
{PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 泥点
</span>
</label>
) : null}
{error ? (
<span className="platform-game-pricing-field__error" role="alert">
{error}
</span>
) : null}
</fieldset>
);
}
+7
View File
@@ -44,6 +44,13 @@ export type {
PlatformFilterToolbarTabItem,
} from './PlatformFilterToolbar';
export { PlatformFilterToolbar } from './PlatformFilterToolbar';
export type { PlatformGamePricingFieldProps } from './PlatformGamePricingField';
export { PlatformGamePricingField } from './PlatformGamePricingField';
export type { PlatformGamePricingMode } from './platformGamePricingModel';
export {
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
resolvePlatformGamePriceMudPoints,
} from './platformGamePricingModel';
export type {
PlatformIconBadgeProps,
PlatformIconBadgeShape,
@@ -0,0 +1,33 @@
/** 买断价上限,与服务端 `MAX_GAME_PRICE_MUD_POINTS` 保持一致。 */
export const PLATFORM_GAME_MAX_PRICE_MUD_POINTS = 1_000_000;
export type PlatformGamePricingMode = 'free' | 'paid';
/**
* 解析付费方式与买断价输入。
*
* 口径与服务端一致:免费等价于 `0`;买断制必须是 `1..=1_000_000` 的整数泥点。
* 返回的 `error` 为空字符串表示可用,否则 `priceMudPoints` 无意义。
*/
export function resolvePlatformGamePriceMudPoints(
mode: PlatformGamePricingMode,
rawValue: string,
): { priceMudPoints: number; error: string } {
if (mode === 'free') return { priceMudPoints: 0, error: '' };
const value = rawValue.trim();
if (!/^\d+$/u.test(value)) {
return { priceMudPoints: 0, error: '买断价必须是整数泥点' };
}
const priceMudPoints = Number(value);
if (
!Number.isSafeInteger(priceMudPoints) ||
priceMudPoints < 1 ||
priceMudPoints > PLATFORM_GAME_MAX_PRICE_MUD_POINTS
) {
return {
priceMudPoints: 0,
error: `买断价必须是 1 到 ${PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 之间的整数泥点`,
};
}
return { priceMudPoints, error: '' };
}