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
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '' };
|
||||
}
|
||||
Reference in New Issue
Block a user