合并 origin/master(52c83cc2a,248 个提交)到 feat/game-purchase
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m56s
Project CI / Backend tests (pull_request) Successful in 9m4s
Project CI / Repository checks (pull_request) Successful in 7m13s
Project CI / Native shell tests (pull_request) Successful in 10m16s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m56s
Project CI / Backend tests (pull_request) Successful in 9m4s
Project CI / Repository checks (pull_request) Successful in 7m13s
Project CI / Native shell tests (pull_request) Successful in 10m16s
Project CI / Frontend tests (pull_request) Successful in 2m26s
- 同步 master 侧 248 个提交:会员与泥点计费(PR #588 / #603)、创作者主页与关注粉丝(PR #638)、运行页刷新真重载(PR #640)、AGC 模板 templateVersion 递增(PR #643)等;确认 master 未触碰发行网关与 `api-server/src/modules/game_distribution.rs`。 - 冲突 1(server-rs/crates/module-runtime/src/domain.rs:1157-1162)`RuntimeProfileWalletLedgerSourceType` 两侧各自在末尾追加变体:保留 master 的 `MembershipUpgradeGrant` 为索引 16(线上已落库口径),本分支 `GamePurchase` 顺延为索引 17,并保留其「每账号每游戏一次扣费」注释。 - 冲突 2(server-rs/crates/module-runtime/src/domain.rs:1184-1188)`as_str()` 同时保留 `membership_upgrade_grant` 与 `game_purchase` 两个分支。 - 冲突 3(server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs:95-101)bindings→领域枚举映射同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,按新索引顺序排列。 - 冲突 4(server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs:43-47)生成枚举按模块源码顺序合并为 …`MembershipUpgradeGrant`、`GamePurchase`;随后 `npm run spacetime:generate` 复核该文件与生成结果逐字节一致。 - 冲突 5(server-rs/crates/api-server/src/admin.rs:4849-4853)sats 索引映射改为 `16 => membership_upgrade_grant` 与 `17 => game_purchase` 两条并存。 - 冲突 6(server-rs/crates/api-server/src/admin.rs:6368-6461)索引断言并入 master 的 `[16] => membership_upgrade_grant`,并把 master 的「17 尚未映射」占位断言替换为 `[17] => game_purchase`;本分支的后台消耗统计两个用例原样保留。 - 冲突 7(server-rs/crates/api-server/src/runtime_profile.rs:219-226)来源文案格式化同时保留 `MembershipUpgradeGrant` 与 `GamePurchase` 两支,注释随 `GamePurchase` 保留。 - 冲突 8(docs/project-memory/shared-memory/decision-log.md:3-24)两条决策记录并列保留:本分支 2026-10-05「播放会话前缀在三处入口清空 Cookie」在前,master 2026-10-03「每日免费发放额为 0 时前端隐藏该池」在后。 - 保住 master 侧:发行响应 ETag/304/gzip(`release_asset_response_with_cache` 五参形态与 `release_asset_etag` / `if_none_match_matches` / `accepts_gzip_encoding` / `gzip_release_asset` / `release_asset_not_modified_response`)、共享加载面 `PlatformGameLoadingSurface` 及其使用方、三池钱包与会员档位目录改造。 - 保住本分支侧:付费作品 404 守卫(`price_mud_points > 0` 且位于 `load_package` 之前)、购买与播放会话网关路由、nginx×3 / Pingora / Vite 的播放会话 Cookie 清空与 `nginx-route-parity.matrix.json` 登记、viewer 感知公开详情、后台审核价格、`PlatformGamePricingField` 共享定价组件与两端接入、AGC 项目版本只读 + 平台派发下一版本号、schema 与迁移白名单、钱包来源 `game_purchase`。 - 验证:`git merge-base --is-ancestor origin/master HEAD` 返回 0;`check:encoding`、`check:doc-index`、`check:spacetime-schema`、`check:server-rs-ddd`、`check:rustfmt`、`check:npm-workspaces`、`check:game-distribution-dto-parity`、`check:game-distribution-price-limit-parity`、`check:pingora-route-parity`、`check:nginx-spa-routes`、根/admin-web/AGC typecheck、`git diff --check` 全绿。 - 测试:`cargo test -p module-game-distribution` 30 passed,`cargo test -p api-server game_distribution` 61 passed,`cargo test -p spacetime-module game_distribution` 7 passed;定向 vitest 28 个文件 252 个用例全通过。 - `npm run spacetime:generate` 除 11 个 procedure 文件的纯空白格式差异外无任何语义改动(`git diff -w` 为空),已保留 master 的生成产物形态,提交内无 schema/绑定语义变化。
This commit is contained in:
@@ -23,7 +23,8 @@ describe('GameDetailDisplay', () => {
|
||||
screenshotObjectKeys={['screenshot/one.png']}
|
||||
assetUrls={{
|
||||
'cover/alpha.png': 'https://assets.example.test/cover/alpha.png',
|
||||
'screenshot/one.png': 'https://assets.example.test/screenshot/one.png',
|
||||
'screenshot/one.png':
|
||||
'https://assets.example.test/screenshot/one.png',
|
||||
}}
|
||||
controlsLabel="键盘"
|
||||
versionLabel="v2"
|
||||
|
||||
@@ -8,10 +8,9 @@ import { formatMudPointCount } from '../../utils/format';
|
||||
import { PlatformMudPointWalletEntry } from './index.tsx';
|
||||
|
||||
const breakdown = {
|
||||
totalPoints: 207,
|
||||
permanentPoints: 100,
|
||||
limitedPoints: 80,
|
||||
limitedExpiresAt: '2026-07-06T16:00:00Z',
|
||||
monthlyPoints: 80,
|
||||
monthlyResetsAt: '2026-07-06T16:00:00Z',
|
||||
dailyFreePoints: 27,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
@@ -22,7 +21,7 @@ test('formats mud point counts consistently', () => {
|
||||
expect(formatMudPointCount(12_345, true)).toBe('1.2万');
|
||||
});
|
||||
|
||||
test('shows only permanent and daily free points in the shared wallet panel', async () => {
|
||||
test('shows permanent and monthly points in the shared wallet panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequestDetails = vi.fn();
|
||||
const onRecharge = vi.fn();
|
||||
@@ -38,28 +37,80 @@ test('shows only permanent and daily free points in the shared wallet panel', as
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
const balanceButton = screen.getByRole('button', {
|
||||
name: /可用总额 207,月度/,
|
||||
});
|
||||
await user.hover(balanceButton);
|
||||
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
|
||||
expect(details.className).toContain('rounded-[1.25rem]');
|
||||
expect(within(details).getByText('不限时泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('按量充值、兑换码获得')).toBeTruthy();
|
||||
expect(within(details).getByText('永久泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('月度泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('不过期,创作照常扣点')).toBeTruthy();
|
||||
expect(within(details).getByText('100')).toBeTruthy();
|
||||
expect(within(details).getByText('80')).toBeTruthy();
|
||||
expect(
|
||||
within(details).getByText('2026-07-07 00:00 清零并发放新额度'),
|
||||
).toBeTruthy();
|
||||
expect(within(details).queryByText('限时泥点')).toBeNull();
|
||||
expect(within(details).queryByText('2026-07-07 到期')).toBeNull();
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('27')).toBeTruthy();
|
||||
expect(within(details).getByText('每天重置为 20 泥点')).toBeTruthy();
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(within(details).getByRole('button', { name: '使用详情' }));
|
||||
expect(onOpenLedger).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '充值' }));
|
||||
await user.click(screen.getByRole('button', { name: '会员与泥点' }));
|
||||
expect(onRecharge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('hides the daily free row when the daily grant is zero', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={100}
|
||||
breakdown={{
|
||||
...breakdown,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 0,
|
||||
}}
|
||||
onRequestDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByRole('button', { name: /可用总额 100,月度/ }));
|
||||
|
||||
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
|
||||
expect(within(details).queryByText('每日免费泥点')).toBeNull();
|
||||
expect(within(details).getByText('永久泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('月度泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('keeps the daily free row while the same-day remainder is still spendable', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlatformMudPointWalletEntry
|
||||
balance={115}
|
||||
breakdown={{
|
||||
...breakdown,
|
||||
dailyFreePoints: 15,
|
||||
dailyFreeResetPoints: 0,
|
||||
}}
|
||||
onRequestDetails={vi.fn()}
|
||||
onRecharge={vi.fn()}
|
||||
onOpenLedger={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.hover(screen.getByRole('button', { name: /可用总额 115,月度/ }));
|
||||
|
||||
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
|
||||
expect(within(details).getByText('每日免费泥点')).toBeTruthy();
|
||||
expect(within(details).getByText('15')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows the redeem-code entry directly in the wallet bar when provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCode = vi.fn();
|
||||
@@ -90,12 +141,18 @@ test('keeps the chip on the live balance when cached details are stale', () => {
|
||||
<PlatformMudPointWalletEntry balance={207} {...props} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '泥点 207' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /可用总额 207,月度/ }),
|
||||
).toBeTruthy();
|
||||
|
||||
rerender(<PlatformMudPointWalletEntry balance={195} {...props} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: '泥点 195' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '泥点 207' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /可用总额 195,月度/ }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /可用总额 207,月度/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('refreshes cached balance details while open and after reopening', () => {
|
||||
@@ -110,7 +167,9 @@ test('refreshes cached balance details while open and after reopening', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
const balanceButton = screen.getByRole('button', {
|
||||
name: /可用总额 207,月度/,
|
||||
});
|
||||
fireEvent.mouseEnter(balanceButton);
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -124,11 +183,15 @@ test('refreshes cached balance details while open and after reopening', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
|
||||
).toBeTruthy();
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '泥点 195' }));
|
||||
fireEvent.mouseEnter(
|
||||
screen.getByRole('button', { name: /可用总额 195,月度/ }),
|
||||
);
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -146,7 +209,7 @@ test('requests missing details after the opening balance load completes', () =>
|
||||
<PlatformMudPointWalletEntry {...props} isLoading />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '泥点 20' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /可用总额 20,月度/ }));
|
||||
expect(onRequestDetails).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<PlatformMudPointWalletEntry {...props} isLoading={false} />);
|
||||
@@ -167,25 +230,33 @@ test('keeps the desktop panel open while moving across the gap without click pin
|
||||
/>,
|
||||
);
|
||||
|
||||
const balanceButton = screen.getByRole('button', { name: '泥点 207' });
|
||||
const balanceButton = screen.getByRole('button', {
|
||||
name: /可用总额 207,月度/,
|
||||
});
|
||||
const root = balanceButton.closest('.platform-mud-point-wallet-entry');
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
fireEvent.mouseEnter(balanceButton);
|
||||
const details = screen.getByRole('dialog', { name: '泥点账户详情' });
|
||||
const details = screen.getByRole('dialog', { name: '会员与泥点账户详情' });
|
||||
|
||||
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
|
||||
fireEvent.mouseEnter(details);
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
|
||||
).toBeTruthy();
|
||||
|
||||
balanceButton.focus();
|
||||
fireEvent.click(balanceButton);
|
||||
expect(screen.getByRole('dialog', { name: '泥点账户详情' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '会员与泥点账户详情' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.mouseLeave(root as HTMLElement, { relatedTarget: null });
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
expect(screen.queryByRole('dialog', { name: '泥点账户详情' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '会员与泥点账户详情' }),
|
||||
).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -207,7 +278,7 @@ test('requests the balance breakdown when a compact entry opens', async () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '泥点 20' }));
|
||||
await user.click(screen.getByRole('button', { name: /可用总额 20,月度/ }));
|
||||
expect(onRequestDetails).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('余额明细暂不可用')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
import type { ProfileMudPointBalance } from '../../contracts/runtime';
|
||||
import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png';
|
||||
import { formatMudPointCount } from '../../utils/format';
|
||||
import {
|
||||
formatMudPointResetAt,
|
||||
resolveProfileMudPointBalanceView,
|
||||
shouldShowProfileDailyFreePool,
|
||||
} from '../../utils/mudPoints';
|
||||
|
||||
export type PlatformMudPointWalletEntryProps = {
|
||||
balance: number | null;
|
||||
@@ -55,7 +60,7 @@ function MudPointBalanceRow({
|
||||
|
||||
export function PlatformMudPointWalletEntry({
|
||||
balance,
|
||||
breakdown,
|
||||
breakdown: rawBreakdown,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
variant = 'desktop',
|
||||
@@ -71,6 +76,9 @@ export function PlatformMudPointWalletEntry({
|
||||
const closeTimerRef = useRef<number | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isCompact = variant === 'mobile';
|
||||
const breakdown = rawBreakdown
|
||||
? resolveProfileMudPointBalanceView(rawBreakdown)
|
||||
: null;
|
||||
const displayedBalance = balance ?? breakdown?.totalPoints ?? null;
|
||||
const balanceLabel =
|
||||
displayedBalance === null
|
||||
@@ -79,6 +87,15 @@ export function PlatformMudPointWalletEntry({
|
||||
const exactBalanceLabel =
|
||||
displayedBalance === null ? '--' : formatMudPointCount(displayedBalance);
|
||||
const detailsRequestKey = `balance:${balance ?? breakdown?.totalPoints ?? 'unknown'}`;
|
||||
const monthlyResetLabel = formatMudPointResetAt(breakdown?.monthlyResetsAt);
|
||||
const monthlyInlineLabel = formatMudPointCount(
|
||||
breakdown?.monthlyPoints ?? 0,
|
||||
true,
|
||||
);
|
||||
const permanentInlineLabel = formatMudPointCount(
|
||||
breakdown?.permanentPoints ?? 0,
|
||||
true,
|
||||
);
|
||||
|
||||
const cancelPendingClose = useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
@@ -183,7 +200,9 @@ export function PlatformMudPointWalletEntry({
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-1.5 px-2 font-black outline-none transition-colors hover:bg-white/70 focus-visible:bg-white/80"
|
||||
aria-label={`泥点 ${exactBalanceLabel}`}
|
||||
aria-label={`泥点账户:可用总额 ${exactBalanceLabel},月度 ${formatMudPointCount(
|
||||
breakdown?.monthlyPoints ?? 0,
|
||||
)},永久 ${formatMudPointCount(breakdown?.permanentPoints ?? 0)}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-busy={isLoading}
|
||||
@@ -209,8 +228,16 @@ export function PlatformMudPointWalletEntry({
|
||||
className={`${isCompact ? 'h-[1.05rem] w-[1.05rem]' : 'h-[1.2rem] w-[1.2rem]'} shrink-0 object-cover mix-blend-multiply`}
|
||||
/>
|
||||
<span className="truncate whitespace-nowrap">
|
||||
泥点 {balanceLabel}
|
||||
可用总额 {balanceLabel}
|
||||
</span>
|
||||
{!isCompact && breakdown ? (
|
||||
<span className="hidden shrink-0 items-center gap-1 truncate whitespace-nowrap text-[10px] font-bold text-[var(--platform-text-soft)] sm:inline-flex">
|
||||
<span aria-hidden="true" className="opacity-40">
|
||||
|
|
||||
</span>
|
||||
月度 {monthlyInlineLabel} · 永久 {permanentInlineLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span
|
||||
className="my-1.5 w-px shrink-0 bg-[rgba(196,153,120,0.36)]"
|
||||
@@ -224,7 +251,7 @@ export function PlatformMudPointWalletEntry({
|
||||
onRecharge();
|
||||
}}
|
||||
>
|
||||
充值
|
||||
会员与泥点
|
||||
</button>
|
||||
{onRedeemCode ? (
|
||||
<>
|
||||
@@ -249,13 +276,13 @@ export function PlatformMudPointWalletEntry({
|
||||
{isOpen ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="泥点账户详情"
|
||||
aria-label="会员与泥点账户详情"
|
||||
onMouseEnter={isCompact ? undefined : cancelPendingClose}
|
||||
className="absolute right-0 top-[calc(100%+0.5rem)] z-[95] w-[min(19rem,calc(100vw-1rem))] overflow-hidden rounded-[1.25rem] border border-[rgba(194,145,111,0.46)] bg-[linear-gradient(180deg,#fffdfa,#fff7ef)] text-left shadow-[0_1.15rem_2.8rem_rgba(76,44,27,0.14),0_0.18rem_0.55rem_rgba(112,62,32,0.07)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="text-[15px] font-black text-[var(--platform-text-strong)]">
|
||||
泥点 {exactBalanceLabel}
|
||||
可用总额 {exactBalanceLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -265,23 +292,33 @@ export function PlatformMudPointWalletEntry({
|
||||
onRecharge();
|
||||
}}
|
||||
>
|
||||
充值
|
||||
会员与泥点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{breakdown ? (
|
||||
<>
|
||||
<MudPointBalanceRow
|
||||
label="不限时泥点"
|
||||
label="永久泥点"
|
||||
points={breakdown.permanentPoints}
|
||||
detail="按量充值、兑换码获得"
|
||||
detail="不过期,创作照常扣点"
|
||||
/>
|
||||
{/*TODO 限时泥点 is not enabled by now */}
|
||||
<MudPointBalanceRow
|
||||
label="每日免费泥点"
|
||||
points={breakdown.dailyFreePoints}
|
||||
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
|
||||
label="月度泥点"
|
||||
points={breakdown.monthlyPoints}
|
||||
detail={
|
||||
monthlyResetLabel
|
||||
? `${monthlyResetLabel} 清零并发放新额度`
|
||||
: '会员每期额度,刷新日清零'
|
||||
}
|
||||
/>
|
||||
{shouldShowProfileDailyFreePool(breakdown) ? (
|
||||
<MudPointBalanceRow
|
||||
label="每日免费泥点"
|
||||
points={breakdown.dailyFreePoints}
|
||||
detail={`每天重置为 ${formatMudPointCount(breakdown.dailyFreeResetPoints)} 泥点`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="border-t border-[rgba(194,145,111,0.28)] px-4 py-6 text-center text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
ProfileMembershipModelAccess,
|
||||
ProfileMembershipPlan,
|
||||
ProfileMembershipPlanRecord,
|
||||
} from '../../contracts/runtime';
|
||||
|
||||
/**
|
||||
* 会员侧的纯展示文案。
|
||||
*
|
||||
* 价格、每期泥点、模型权限、并发上限、标题、推荐档位与「是否不设并发上限」的唯一真相源
|
||||
* 都是后端 `profile_membership_plan` 目录响应;这里只放后端没有、也不该由后端承担的
|
||||
* 市场表达(数量估算、省费口径)。
|
||||
*/
|
||||
|
||||
type MembershipPlanCopy = {
|
||||
/** 每月预计可完成的数量估算。 */
|
||||
estimateCount: string;
|
||||
/** 数量估算对应的项目形态。 */
|
||||
estimateKind: string;
|
||||
/** 数量估算的补充说明。 */
|
||||
estimateAlt: string;
|
||||
};
|
||||
|
||||
const MEMBERSHIP_PLAN_COPY: Partial<
|
||||
Record<ProfileMembershipPlan, MembershipPlanCopy>
|
||||
> = {
|
||||
starter: {
|
||||
estimateCount: '1–2 个',
|
||||
estimateKind: '简单小游戏原型',
|
||||
estimateAlt: '先验证核心玩法与基础交互',
|
||||
},
|
||||
plus: {
|
||||
estimateCount: '2–4 个',
|
||||
estimateKind: '轻量微抖小游戏',
|
||||
estimateAlt: '按可玩版本估算',
|
||||
},
|
||||
pro: {
|
||||
estimateCount: '1–2 个',
|
||||
estimateKind: '中等项目 Demo',
|
||||
estimateAlt: '或约 7–14 个轻量小游戏',
|
||||
},
|
||||
max: {
|
||||
estimateCount: '1–2 个',
|
||||
estimateKind: '独立游戏可玩章节',
|
||||
estimateAlt: '或约 2–5 个中等项目 Demo',
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK_PLAN_COPY: MembershipPlanCopy = {
|
||||
estimateCount: '按实际创作量',
|
||||
estimateKind: '以作品规模为准',
|
||||
estimateAlt: '具体消耗取决于创作复杂度',
|
||||
};
|
||||
|
||||
export function resolveMembershipPlanCopy(
|
||||
plan: ProfileMembershipPlan,
|
||||
): MembershipPlanCopy {
|
||||
return MEMBERSHIP_PLAN_COPY[plan] ?? FALLBACK_PLAN_COPY;
|
||||
}
|
||||
|
||||
export function resolveMembershipModelAccessLabel(
|
||||
modelAccess: ProfileMembershipModelAccess,
|
||||
): string {
|
||||
return modelAccess === 'full' ? '基础及高性能模型' : '基础模型';
|
||||
}
|
||||
|
||||
/** 套餐卡里的并发文案;是否不设上限由后端 `unlimitedConcurrency` 标志决定。 */
|
||||
export function resolveMembershipConcurrencyLabel(
|
||||
plan: Pick<
|
||||
ProfileMembershipPlanRecord,
|
||||
'concurrentJobLimit' | 'unlimitedConcurrency'
|
||||
>,
|
||||
): string {
|
||||
return plan.unlimitedConcurrency
|
||||
? '独立任务并发不设套餐上限'
|
||||
: `同时运行 ${plan.concurrentJobLimit} 个独立任务`;
|
||||
}
|
||||
|
||||
/** 对比表里的并发短文案。 */
|
||||
export function resolveMembershipConcurrencyShortLabel(
|
||||
plan: Pick<
|
||||
ProfileMembershipPlanRecord,
|
||||
'concurrentJobLimit' | 'unlimitedConcurrency'
|
||||
>,
|
||||
): string {
|
||||
return plan.unlimitedConcurrency
|
||||
? '不设套餐上限'
|
||||
: `${plan.concurrentJobLimit} 个`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
ProfileMembershipCycleKind,
|
||||
ProfileMembershipPlan,
|
||||
} from '../../contracts/runtime';
|
||||
|
||||
/**
|
||||
* 会员商品没有配置行,`product_id` 由「档位 + 周期」拼出,与后端
|
||||
* `parse_runtime_profile_membership_product_id` 双向对应。
|
||||
*/
|
||||
export function resolveMembershipProductId(
|
||||
plan: ProfileMembershipPlan,
|
||||
cycleKind: ProfileMembershipCycleKind,
|
||||
) {
|
||||
return `membership-${plan}-${cycleKind}`;
|
||||
}
|
||||
@@ -103,6 +103,9 @@ test('builds wallet ledger presentation with stable source fallbacks', () => {
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
|
||||
expect(getWalletLedgerSourceLabel('llm_router_consume')).toBe('LLM 调用消耗');
|
||||
expect(getWalletLedgerSourceLabel('membership_upgrade_grant')).toBe(
|
||||
'会员升级补点',
|
||||
);
|
||||
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
|
||||
expect(formatWalletLedgerDate('not-a-date')).toBe('not-a-date');
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
|
||||
snapshot_sync: '账户同步',
|
||||
membership_period_grant: '会员周期发放',
|
||||
membership_period_reset: '会员周期重置',
|
||||
membership_upgrade_grant: '会员升级补点',
|
||||
daily_free_grant: '每日免费发放',
|
||||
daily_free_reset: '每日免费重置',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { formatAdminDate, formatAdminDateTime } from './AdminFormat';
|
||||
import {
|
||||
formatAdminCurrencyCents,
|
||||
formatAdminDate,
|
||||
formatAdminDateTime,
|
||||
} from './AdminFormat';
|
||||
|
||||
describe('formatAdminDateTime', () => {
|
||||
test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => {
|
||||
@@ -44,3 +48,11 @@ describe('formatAdminDate', () => {
|
||||
expect(formatAdminDate('1791008678.817257Z')).not.toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAdminCurrencyCents', () => {
|
||||
test('分转成带两位小数的元', () => {
|
||||
expect(formatAdminCurrencyCents(0)).toBe('¥0.00');
|
||||
expect(formatAdminCurrencyCents(600)).toBe('¥6.00');
|
||||
expect(formatAdminCurrencyCents(1234)).toBe('¥12.34');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,11 @@ export function formatAdminDateTime(
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台统一的金额展示:分 → `¥12.34`;避免各后台页面各自实现导致格式漂移。 */
|
||||
export function formatAdminCurrencyCents(cents: number): string {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */
|
||||
export function formatAdminDate(
|
||||
value: string | number | Date | null | undefined,
|
||||
|
||||
@@ -19,7 +19,11 @@ export type {
|
||||
AdminFormRowProps,
|
||||
} from './AdminField';
|
||||
export { AdminField, AdminFieldGroup, AdminFormRow } from './AdminField';
|
||||
export { formatAdminDate, formatAdminDateTime } from './AdminFormat';
|
||||
export {
|
||||
formatAdminCurrencyCents,
|
||||
formatAdminDate,
|
||||
formatAdminDateTime,
|
||||
} from './AdminFormat';
|
||||
export type {
|
||||
AdminInfoItemProps,
|
||||
AdminInfoListEntry,
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken";
|
||||
import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken";
|
||||
|
||||
/**
|
||||
* 后台按档位改会员价格与权益;`rank` 不在请求里,升级比较基准由代码内目录决定。
|
||||
*/
|
||||
export type AdminUpsertProfileMembershipPlanRequest = {
|
||||
/**
|
||||
* 档位 token,作为主键即档位身份。
|
||||
*/
|
||||
plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPriceCents: number, periodPoints: number,
|
||||
/**
|
||||
* 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。
|
||||
*/
|
||||
modelAccess: ProfileMembershipModelAccessToken,
|
||||
/**
|
||||
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
|
||||
*/
|
||||
concurrentJobLimit: number, enabled: boolean, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 客户端启动的执行器。与线协议正交。
|
||||
*/
|
||||
export type AgcAgentMode = "codex" | "cc";
|
||||
@@ -0,0 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 上游线协议,与执行器正交。
|
||||
*
|
||||
* `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与
|
||||
* `/api/llm/v1/chat/completions`;`anthropic` 走独立的
|
||||
* `/api/llm/anthropic/{*path}`。
|
||||
*/
|
||||
export type AgcModelProtocol = "openai_responses" | "openai_chat" | "anthropic";
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgcAgentMode } from "./AgcAgentMode";
|
||||
import type { AgcModelProtocol } from "./AgcModelProtocol";
|
||||
|
||||
/**
|
||||
* 本档可用的一条模型摘要。不包含上游真实模型名。
|
||||
*/
|
||||
export type LlmModelSummary = { displayName: string, id: string,
|
||||
/**
|
||||
* 执行器;旧服务端缺字段时按 `codex` 兼容。
|
||||
*/
|
||||
agentMode: AgcAgentMode,
|
||||
/**
|
||||
* 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。
|
||||
*/
|
||||
protocol: AgcModelProtocol, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 模型对当前账号不可用的原因。
|
||||
*
|
||||
* `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。
|
||||
*/
|
||||
export type LlmModelUnavailableReason = "plan_required" | "disabled" | "unknown";
|
||||
@@ -0,0 +1,20 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { LlmModelSummary } from "./LlmModelSummary";
|
||||
import type { LlmUnavailableModel } from "./LlmUnavailableModel";
|
||||
|
||||
/**
|
||||
* `GET /api/llm/models` 的响应。
|
||||
*/
|
||||
export type LlmModelsResponse = { defaultModelId: string,
|
||||
/**
|
||||
* 本档可用,保持目录顺序。
|
||||
*/
|
||||
models: Array<LlmModelSummary>,
|
||||
/**
|
||||
* 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。
|
||||
*/
|
||||
unavailableModels: Array<LlmUnavailableModel>,
|
||||
/**
|
||||
* 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。
|
||||
*/
|
||||
revision: number, };
|
||||
@@ -0,0 +1,13 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AgcAgentMode } from "./AgcAgentMode";
|
||||
import type { AgcModelProtocol } from "./AgcModelProtocol";
|
||||
import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason";
|
||||
|
||||
/**
|
||||
* 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。
|
||||
*/
|
||||
export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode,
|
||||
/**
|
||||
* 上游线协议;旧服务端缺字段时按 `agent_mode` 的默认协议兼容。
|
||||
*/
|
||||
protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ProfileDailyFreePointsResponse = { dayKey: number, grantedPoints: number, remainingPoints: number, resetsAt: string, updatedAt: string, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 会员订单权益变更类型 token;区分“新购”与“补差升级”。
|
||||
*/
|
||||
export type ProfileMembershipChangeKindToken = "purchase" | "upgrade";
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 会员购买周期 token;与前端 `ProfileMembershipCycleKind` 一一对应。
|
||||
*/
|
||||
export type ProfileMembershipCycleKindToken = "monthly" | "yearly";
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 模型权限 token;与前端 `ProfileMembershipModelAccess` 一一对应。
|
||||
*/
|
||||
export type ProfileMembershipModelAccessToken = "basic" | "full";
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ProfileMembershipPlanResponse } from "./ProfileMembershipPlanResponse";
|
||||
|
||||
export type ProfileMembershipPlanAdminListResponse = { entries: Array<ProfileMembershipPlanResponse>,
|
||||
/**
|
||||
* 并发哨兵阈值:`concurrent_job_limit >= 该值` 即不设上限;由后端下发,前端不硬编码。
|
||||
*/
|
||||
unlimitedConcurrencyThreshold: number, };
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ProfileMembershipModelAccessToken } from "./ProfileMembershipModelAccessToken";
|
||||
import type { ProfileMembershipPlanToken } from "./ProfileMembershipPlanToken";
|
||||
|
||||
export type ProfileMembershipPlanResponse = {
|
||||
/**
|
||||
* 档位 token:`normal` / `starter` / `plus` / `pro` / `max`;未知取值按 `max` 兜底。
|
||||
*/
|
||||
plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents: number, yearPriceCents: number, periodPoints: number,
|
||||
/**
|
||||
* 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。
|
||||
*/
|
||||
modelAccess: ProfileMembershipModelAccessToken,
|
||||
/**
|
||||
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
|
||||
*/
|
||||
concurrentJobLimit: number,
|
||||
/**
|
||||
* 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。
|
||||
*/
|
||||
recommended: boolean,
|
||||
/**
|
||||
* 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。
|
||||
*/
|
||||
unlimitedConcurrency: boolean, enabled: boolean, };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user