Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs # apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx # apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # package.json # packages/shared/src/contracts/gameDistribution.ts # scripts/check-game-distribution-dto-parity.mjs # server-rs/crates/api-server/src/modules/game_distribution.rs # server-rs/crates/module-game-distribution/src/errors.rs # server-rs/crates/module-game-distribution/src/lib.rs # server-rs/crates/shared-contracts/src/game_distribution.rs # server-rs/crates/spacetime-client/src/active.rs # server-rs/crates/spacetime-client/src/active/mapper.rs # server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs # server-rs/crates/spacetime-client/src/game_distribution.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs # server-rs/crates/spacetime-module/src/game_distribution.rs # server-rs/crates/spacetime-module/src/migration.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/game-distribution/GamePlayPage.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx # src/services/gameDistributionClient.test.ts # src/services/gameDistributionClient.ts
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 买断制定价字段:网页发布页与 AGC 发布面板共用同一份表现与文案。
|
||||
*
|
||||
* 这里只补平台原语没覆盖的部分,不另造一套控件观感:
|
||||
* - 外壳:`PlatformSubpanel`(`platform` 面 + `xs` 圆角 ≈ 网页 `.game-publish-fieldset` 的
|
||||
* 0.7rem 描边卡片,AGC 侧同族容器是 `.game-distribution-publish-panel__publication`)。
|
||||
* - 付费方式:原生 radio(屏外裁切保留语义 / 键盘 / `.checked`)+ 共享 chip 外观
|
||||
* (`getPlatformCategoryChipClassName`:未选=浅底描边,选中=实心品牌填充反白)。
|
||||
* - 买断价:`PlatformTextField`,本文件只补右对齐等宽数字、单位留位、错误态与层次。
|
||||
*
|
||||
* 选择器统一写成「根类 + 子类」而不是裸类:AGC 发布面板的
|
||||
* `.game-distribution-publish-panel__fields label/input` 是后代选择器(0,1,1),会盖掉
|
||||
* 原语里的 Tailwind 工具类(0,1,0)。提高一档特异性后,组件外观在两端都由这里唯一决定。
|
||||
*/
|
||||
.platform-game-pricing-field {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__option {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 原生 radio 只做语义与键盘载体:`display: none` / `visibility: hidden` 会把可聚焦性和
|
||||
读屏可见性一起拿掉,所以用「屏外裁切」隐藏,焦点仍落在它身上。 */
|
||||
.platform-game-pricing-field .platform-game-pricing-field__option-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
/* chip 自带高度 36px(窄屏还会降到 33px),这里统一抬到 44px 触控下限;
|
||||
「免费 / 买断制」字数不同,按内容宽度会一宽一窄,给个共同下限让两枚等宽。 */
|
||||
.platform-game-pricing-field .platform-game-pricing-field__option-chip {
|
||||
min-width: 5.5rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__option-input:focus-visible
|
||||
+ .platform-game-pricing-field__option-chip {
|
||||
outline: 2px solid var(--platform-input-focus-ring, #b6623f);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__option-input:disabled
|
||||
+ .platform-game-pricing-field__option-chip {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-label {
|
||||
display: block;
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 边框 / 圆角 / 底色 / 焦点环都来自 `PlatformTextField`(AGC 宿主还会用面板自己的输入框
|
||||
token 覆盖,与同面板其它输入框一致),这里只补三件事:数字右对齐且等宽、给单位留位、错误态。 */
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-input {
|
||||
padding-right: 2.9rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 错误态。网页宿主对子面板内的输入框做了统一 remap(`src/index.css` 的
|
||||
`.platform-theme :where(.platform-subpanel …) :where(input…)`,带 `!important`),
|
||||
非法态在那边由宿主同族的 `[aria-invalid='true']` 分支给出;这里保留**正常权重**的同色声明,
|
||||
AGC 侧没有那套 remap 时就靠这条生效。不改 `box-shadow`:那是宿主的输入高光与焦点环,
|
||||
压掉会让非法字段失去键盘焦点提示。 */
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input[aria-invalid='true'] {
|
||||
border-color: var(--platform-button-danger-text, #a6402f);
|
||||
background: var(--platform-button-danger-fill, rgba(255, 237, 229, 0.94));
|
||||
}
|
||||
|
||||
/* 原生 number 的上下箭头会画在「泥点」单位左边(Chrome 一直画,Firefox hover 才画),
|
||||
与单位挤在一起;这里隐藏箭头,保留 `type="number"` + `inputMode="numeric"`(移动端仍是数字键盘)。 */
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input::-webkit-outer-spin-button,
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-input {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__price-unit {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
color: var(--platform-text-soft, #988476);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field
|
||||
.platform-game-pricing-field__price-input[aria-invalid='true']
|
||||
+ .platform-game-pricing-field__price-unit {
|
||||
color: var(--platform-button-danger-text, #a6402f);
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__hint {
|
||||
color: var(--platform-text-soft, #988476);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__error {
|
||||
color: var(--platform-button-danger-text, #a6402f);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* 窄屏(≈390px):两枚胶囊等分整行,不靠换行兜底,也不出现横向溢出。 */
|
||||
@media (max-width: 480px) {
|
||||
.platform-game-pricing-field .platform-game-pricing-field__modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.platform-game-pricing-field .platform-game-pricing-field__option-chip {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* @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 = '',
|
||||
initialMode = 'free',
|
||||
initialPriceInput = '',
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
initialMode?: 'free' | 'paid';
|
||||
initialPriceInput?: string;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'free' | 'paid'>(initialMode);
|
||||
const [priceInput, setPriceInput] = useState(initialPriceInput);
|
||||
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('禁用态锁住单选与买断价输入', () => {
|
||||
// 免费模式下价格输入根本不渲染,只断言两个 radio 等于没测到输入框;
|
||||
// 必须用「买断制 + 禁用」才能覆盖价格输入被锁住的路径。
|
||||
// 外壳已由 fieldset 换成 div,禁用不再级联:这里逐个断言三个控件都被锁住。
|
||||
render(
|
||||
<PricingHarness disabled initialMode="paid" initialPriceInput="120" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: '免费' })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
const paidRadio = screen.getByRole('radio', { name: '买断制' });
|
||||
expect(paidRadio).toHaveProperty('disabled', true);
|
||||
expect(paidRadio).toHaveProperty('checked', true);
|
||||
const priceInput = screen.getByLabelText(/买断价/u) as HTMLInputElement;
|
||||
expect(priceInput.disabled).toBe(true);
|
||||
expect(priceInput.value).toBe('120');
|
||||
});
|
||||
|
||||
test('使用方传入的校验错误在字段内以 alert 呈现', () => {
|
||||
render(<PricingHarness error="买断价必须是整数泥点" />);
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('买断价必须是整数泥点');
|
||||
});
|
||||
|
||||
test('买断价校验错误与上限提示一起通过 aria-describedby 关联到价格输入', () => {
|
||||
render(
|
||||
<PricingHarness
|
||||
initialMode="paid"
|
||||
initialPriceInput="0"
|
||||
error="买断价必须是整数泥点"
|
||||
/>,
|
||||
);
|
||||
|
||||
const priceInput = screen.getByLabelText(/买断价/u);
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert.textContent).toBe('买断价必须是整数泥点');
|
||||
expect(alert.id).toBeTruthy();
|
||||
// 描述里必须同时有错误行与上限提示:只挂错误行会让「最多 1000000 泥点」读不到。
|
||||
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
expect(describedByIds).toContain(alert.id);
|
||||
const describedText = describedByIds
|
||||
.map((id) => document.getElementById(id)?.textContent ?? '')
|
||||
.join(' ');
|
||||
expect(describedText).toContain('买断价必须是整数泥点');
|
||||
expect(describedText).toContain('1000000');
|
||||
expect(priceInput.getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
test('没有校验错误时价格输入仍通过 aria-describedby 播报上限提示', () => {
|
||||
render(<PricingHarness initialMode="paid" initialPriceInput="240" />);
|
||||
|
||||
const priceInput = screen.getByLabelText(/买断价/u);
|
||||
const describedByIds = (priceInput.getAttribute('aria-describedby') ?? '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean);
|
||||
expect(describedByIds).toHaveLength(1);
|
||||
expect(
|
||||
document.getElementById(describedByIds[0] ?? '')?.textContent,
|
||||
).toContain('1000000');
|
||||
expect(priceInput.getAttribute('aria-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
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,155 @@
|
||||
import './PlatformGamePricingField.css';
|
||||
|
||||
import { useId } from 'react';
|
||||
|
||||
import { getPlatformCategoryChipClassName } from './platformCategoryChipModel';
|
||||
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
import {
|
||||
PLATFORM_GAME_MAX_PRICE_MUD_POINTS,
|
||||
type PlatformGamePricingMode,
|
||||
} from './platformGamePricingModel';
|
||||
import { PlatformSubpanel } from './PlatformSubpanel';
|
||||
import { PlatformTextField } from './PlatformTextField';
|
||||
|
||||
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,价格校验、提交
|
||||
* 与业务状态由使用方持有。上限取自共享常量,两端不各写一份。
|
||||
*
|
||||
* 组成全部复用平台原语:外壳是 `PlatformSubpanel`(与网页发布页 `game-publish-fieldset`
|
||||
* 同族的描边卡片),付费方式是「原生 radio + 标签 chip」,价格是 `PlatformTextField`。
|
||||
* 两个选项是互斥单选,所以语义仍由原生 radio 承载(读屏的单选组、方向键切换、
|
||||
* `.checked` / `.disabled`、label 关联全都照旧),chip 只负责外观。
|
||||
*/
|
||||
export function PlatformGamePricingField({
|
||||
mode,
|
||||
priceInput,
|
||||
disabled = false,
|
||||
error = '',
|
||||
onModeChange,
|
||||
onPriceInputChange,
|
||||
radioName = 'platform-game-pricing-mode',
|
||||
}: PlatformGamePricingFieldProps) {
|
||||
// 错误行与价格输入必须显式关联:读屏用户只听到「买断价(泥点)」时不知道哪里非法。
|
||||
const errorId = `${useId()}-price-error`;
|
||||
const priceInputId = `${useId()}-price-input`;
|
||||
// 提示句同样要挂在输入上:它是「最多 1000000 泥点」这类上限信息,光靠 label 念不到。
|
||||
const hintId = `${useId()}-price-hint`;
|
||||
const describedBy = [hintId, error ? errorId : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
className="platform-game-pricing-field"
|
||||
radius="xs"
|
||||
padding="xs"
|
||||
surface="platform"
|
||||
title={<PlatformFieldLabel variant="field">付费方式</PlatformFieldLabel>}
|
||||
titleVariant="strong"
|
||||
>
|
||||
<div
|
||||
className="platform-game-pricing-field__modes"
|
||||
role="radiogroup"
|
||||
aria-label="付费方式"
|
||||
>
|
||||
<label className="platform-game-pricing-field__option">
|
||||
<input
|
||||
className="platform-game-pricing-field__option-input"
|
||||
type="radio"
|
||||
name={radioName}
|
||||
checked={mode === 'free'}
|
||||
disabled={disabled}
|
||||
onChange={() => onModeChange('free')}
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
'platform-game-pricing-field__option-chip',
|
||||
getPlatformCategoryChipClassName(mode === 'free'),
|
||||
].join(' ')}
|
||||
>
|
||||
免费
|
||||
</span>
|
||||
</label>
|
||||
<label className="platform-game-pricing-field__option">
|
||||
<input
|
||||
className="platform-game-pricing-field__option-input"
|
||||
type="radio"
|
||||
name={radioName}
|
||||
checked={mode === 'paid'}
|
||||
disabled={disabled}
|
||||
onChange={() => onModeChange('paid')}
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
'platform-game-pricing-field__option-chip',
|
||||
getPlatformCategoryChipClassName(mode === 'paid'),
|
||||
].join(' ')}
|
||||
>
|
||||
买断制
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{mode === 'paid' ? (
|
||||
<div className="platform-game-pricing-field__price">
|
||||
<label
|
||||
className="platform-game-pricing-field__price-label"
|
||||
htmlFor={priceInputId}
|
||||
>
|
||||
买断价(泥点)
|
||||
</label>
|
||||
<div className="platform-game-pricing-field__price-control">
|
||||
<PlatformTextField
|
||||
id={priceInputId}
|
||||
className="platform-game-pricing-field__price-input"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
size="md"
|
||||
density="compact"
|
||||
value={priceInput}
|
||||
disabled={disabled}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
onChange={(event) => onPriceInputChange(event.target.value)}
|
||||
/>
|
||||
{/* 单位是价格的视觉附属:label 里已经写了「(泥点)」,这里对读屏重复一次没有意义。 */}
|
||||
<span
|
||||
className="platform-game-pricing-field__price-unit"
|
||||
aria-hidden="true"
|
||||
>
|
||||
泥点
|
||||
</span>
|
||||
</div>
|
||||
<span id={hintId} className="platform-game-pricing-field__hint">
|
||||
买断一次永久拥有;价格随本次版本审核通过后生效,最多{' '}
|
||||
{PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 泥点
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<span
|
||||
id={errorId}
|
||||
className="platform-game-pricing-field__error"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
puzzle_author_incentive_claim: '拼图作者奖励',
|
||||
daily_task_reward: '每日任务奖励',
|
||||
game_purchase: '购买游戏',
|
||||
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
|
||||
|
||||
export type ProfileWalletLedgerEntryPresentation = {
|
||||
|
||||
@@ -56,6 +56,13 @@ export {
|
||||
PLATFORM_GAME_LOADING_TIMEOUT_MS,
|
||||
resolvePlatformGameLoadingMessage,
|
||||
} from './platformGameLoadingSurfaceModel';
|
||||
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: '' };
|
||||
}
|
||||
@@ -104,8 +104,10 @@ export type GameDistributionVersionSummary = {
|
||||
/**
|
||||
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||||
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
|
||||
*
|
||||
* 买断制作品在未购买时不下发公开入口,这一项为 `null`;游玩入口改由播放会话接口签发。
|
||||
*/
|
||||
entryUrl: string;
|
||||
entryUrl: string | null;
|
||||
sha256: string;
|
||||
publishedAt: string;
|
||||
controls: string[];
|
||||
@@ -310,6 +312,15 @@ export type GameDistributionGame = {
|
||||
* 排序与公开主题列表共用同一份比较器(创建时间倒序 + themeId 升序兜底)。
|
||||
*/
|
||||
themes?: GameDistributionThemeReference[];
|
||||
/**
|
||||
* 买断价(整数泥点,`0` 表示免费)。后端公开投影始终下发;作者侧与旧响应可省略
|
||||
* (Rust 侧 `#[serde(default)]` 同样允许缺字段),读取方必须按 `0`(免费)兜底。
|
||||
*/
|
||||
priceMudPoints?: number;
|
||||
/**
|
||||
* 当前查看者是否已拥有;匿名与未登录恒为 `false`。旧响应可省略,读取方按未购买兜底。
|
||||
*/
|
||||
purchased?: boolean;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -591,6 +602,13 @@ export type GameDistributionCreateVersionRequest = {
|
||||
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
/**
|
||||
* 作者提交的买断价(整数泥点,`0` 表示免费)。
|
||||
*
|
||||
* 价格随版本冻结,审核通过时与资料一起生效到游戏行;缺省按 `0`(免费)处理。
|
||||
* 取值范围由服务端约束:`0..=1_000_000`,付费必须是正整数。
|
||||
*/
|
||||
priceMudPoints?: number;
|
||||
packageSha256: string;
|
||||
packageBytes: number;
|
||||
packageFileCount: number;
|
||||
@@ -629,6 +647,8 @@ export type GameDistributionPrivateVersion = {
|
||||
status: GameDistributionVersionStatus;
|
||||
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
|
||||
publicationRevision: number;
|
||||
/** 该版本冻结的买断价(整数泥点,`0` 表示免费);无价格的历史版本按免费口径处理。 */
|
||||
priceMudPoints?: number;
|
||||
reviewReason?: string | null;
|
||||
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
|
||||
entryUrl: string | null;
|
||||
@@ -720,3 +740,36 @@ export type GameDistributionCollectionState = {
|
||||
collected: boolean;
|
||||
replayed?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 买断制购买请求体。
|
||||
*
|
||||
* 客户端带上自己看到的价格:与服务端当前价格不一致时返回 409,不会按旧价扣费。
|
||||
*/
|
||||
export type GameDistributionPurchaseRequest = {
|
||||
expectedPriceMudPoints: number;
|
||||
};
|
||||
|
||||
/** 购买记录快照:每账号每作品最多一条,成交价随购买冻结,之后调价不影响既有所有权。 */
|
||||
export type GameDistributionPurchase = {
|
||||
purchaseId: string;
|
||||
gameId: string;
|
||||
priceMudPoints: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** 购买响应:`walletBalance` 是扣费后的泥点余额,`replayed` 表示服务端按同一幂等键重放。 */
|
||||
export type GameDistributionPurchaseResponse = {
|
||||
purchase: GameDistributionPurchase;
|
||||
walletBalance: number;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放会话响应:`playUrl` 是绑定「`gameId` + 用户 + 短时效」的发行入口。
|
||||
* 结算入口地址后交给 iframe,会话过期或作品下架后不可继续播放。
|
||||
*/
|
||||
export type GameDistributionPlaySessionResponse = {
|
||||
playUrl: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
@@ -111,7 +111,8 @@ export type ProfileWalletLedgerEntry = {
|
||||
| 'recharge_refund_recovery'
|
||||
| 'redeem_code_reward'
|
||||
| 'puzzle_author_incentive_claim'
|
||||
| 'daily_task_reward';
|
||||
| 'daily_task_reward'
|
||||
| 'game_purchase';
|
||||
createdAt: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user