合并 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

- 同步 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:
2026-10-06 13:12:32 +08:00
201 changed files with 15890 additions and 13199 deletions
@@ -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,
@@ -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, };
+6
View File
@@ -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";
@@ -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