diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d7c198f3e..c4712a723 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -218,6 +218,8 @@ module.exports = { '!src/services/frontendRuntimeConfigService.ts', '!src/services/frontendRuntimeConfigService.test.ts', '!src/services/gameDistributionClient.ts', + '!src/services/creatorProfileClient.ts', + '!src/services/creatorProfileClient.test.ts', '!src/services/gameDistributionClient.test.ts', '!src/services/gamePlayClientId.ts', '!src/services/sseStream.ts', diff --git a/.prettierignore b/.prettierignore index a32676e37..446fa63d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,13 +2,16 @@ dist node_modules .git .codex-logs -public/Icons -# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写 +# ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。 +# 一律以原始输出提交,禁止 prettier 二次改写。 packages/shared/src/contracts/generated/ +public/Icons apps/ai-game-creator-shell/src/features/ui-editor/types/ apps/ai-game-creator-shell/src/view/project-development/chat/generated/ apps/ai-game-creator-shell/src/services/generated/ apps/ai-game-creator-shell/src-tauri/resources/agc-skills/ +# 预览桥脚本:随包注入浏览器的资源,按字节搬运自 Rust 内联字符串,禁止 prettier 二次改写 +apps/ai-game-creator-shell/src-tauri/resources/preview/ media *.log .preview.* diff --git a/CONTEXT.md b/CONTEXT.md index 00f7c178e..460a1abfc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -287,6 +287,56 @@ _Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另 聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。 _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件 +### 泥点与会员计费 + +**每日免费泥点**: +账号每天按固定额度自动获得的泥点,只在北京时间当天有效,当日 24 点清零后重新发放;不受会员状态影响。 +_Avoid_: 永久赠送、会员额度、可累计的每日额度 + +**月度泥点**: +会员在当前会员账期内可用的泥点,只在本期有效;换期时本期余额清零并由新一期额度覆盖,会员到期时一并清零。 +_Avoid_: 会员点数、周期点数、限时点数、永久可用的会员赠送 + +**永久泥点**: +不过期、不受会员状态影响的泥点,来源是充值与非会员奖励,创作扣点与月度泥点完全一致。 +_Avoid_: 不限时泥点、充值余额、无限泥点、免费泥点 + +**永久余数**: +当前实现中「钱包总额扣除每日免费泥点与月度泥点之后剩下的部分」,即永久泥点在存储层的临时形态,不表示一种来源。 +_Avoid_: 独立余额、第四个池、充值本金 + +**会员账期**: +会员权益的一期有效期,从开通日时刻起按自然月推进到同一天的下一月;年付包含 12 期。 +_Avoid_: 自然月、固定 30 天、账单周期 + +**补差升级**: +在会员有效期内升到更高档位,只补付本期剩余时间段与新档位后续各期的差价,并补发当前期额度差额;已用泥点不返还。 +_Avoid_: 套餐切换、退订重买、即时月转年 + +**可用总额**: +界面上的「现在可用」口径,等于月度泥点加永久泥点,不含每日免费泥点。 +_Avoid_: 钱包总额、总泥点、含每日免费的总和 + +**会员档位**: +会员等级身份,取值为「非会员」与 `Starter` / `Plus` / `Pro` / `Max`,决定每期泥点额度、可用的模型范围、同时运行的独立任务数与升级比较次序;每个档位分月付与年付两种周期。 +_Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串、用 `tier` 当档位的规范词 + +**会员计划目录**: +会员档位可配置的价格与权益集合:月价、年价、每期泥点、模型权限、并发上限、排序与上下架。 +_Avoid_: 把价格写死在代码、按档位名字硬编码权益 + +**模型权限**: +会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串 + +**并发上限**: +同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入 + +**独立生成任务**: +用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。 +_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发) + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9c3470426..241019468 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -74,6 +74,7 @@ import type { AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileMembershipPlanRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, @@ -90,6 +91,8 @@ import type { EditorGenerationPricingConfigPayload, ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileMembershipPlanAdminListResponse, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, ProfileRedeemCodeAdminListResponse, @@ -795,6 +798,27 @@ export function upsertProfileRechargeProduct( ); } +export function listProfileMembershipPlans(token: string) { + return request( + '/admin/api/profile/membership-plans', + { token }, + ); +} + +export function upsertProfileMembershipPlan( + token: string, + payload: AdminUpsertProfileMembershipPlanRequest, +) { + return request( + '/admin/api/profile/membership-plans', + { + method: 'POST', + token, + body: payload, + }, + ); +} + export function listAdminRechargeOrders( token: string, query: AdminRechargeOrderListQuery = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8fecfe485..b47ed6fcc 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1,3 +1,12 @@ +import type { + ProfileMembershipChangeKindToken, + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, + ProfileMembershipPlanResponse, + ProfileMembershipStatus, +} from '@genarrative/shared'; + export interface ApiMeta { apiVersion?: string; requestId?: string; @@ -381,16 +390,13 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; export type ProfileTaskCycle = 'daily'; export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export type ProfileRechargeProductKind = 'points' | 'membership'; -export type ProfileMembershipTier = - | 'normal' - | 'month' - | 'season' - | 'year' - | 'starter' - | 'basic' - | 'pro' - | 'ultimate'; - +/** 会员档位 / 周期 / 权限:统一来自 `@genarrative/shared` 的生成类型,禁止手写字面量。 */ +export type { + ProfileMembershipCycleKind, + ProfileMembershipModelAccess, + ProfileMembershipPlan, + ProfileMembershipStatus, +}; export interface AdminTrackingEventListQuery { eventKey?: string; userId?: string; @@ -678,15 +684,8 @@ export interface AdminUpsertProfileRechargeProductRequest { priceCents: number; kind: ProfileRechargeProductKind; pointsAmount: number; - bonusPoints: number; - durationDays: number; badgeLabel?: string | null; description?: string | null; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; } @@ -775,11 +774,6 @@ export interface ProfileRechargeProductConfigAdminResponse { durationDays: number; badgeLabel: string; description: string; - tier: ProfileMembershipTier; - membershipPeriodPoints: number; - membershipPeriodDays: number; - membershipQueueLimit: number; - membershipDiscountBps: number; enabled: boolean; sortOrder: number; createdBy: string; @@ -792,6 +786,14 @@ export interface ProfileRechargeProductConfigAdminListResponse { entries: ProfileRechargeProductConfigAdminResponse[]; } +/** 会员档位目录行;直接复用 `@genarrative/shared` 的后端生成类型,避免手工漂移。 */ +export type ProfileMembershipPlanAdminResponse = ProfileMembershipPlanResponse; + +export type { + AdminUpsertProfileMembershipPlanRequest, + ProfileMembershipPlanAdminListResponse, +} from '@genarrative/shared'; + export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; @@ -906,7 +908,8 @@ export interface AdminProfileWalletPayload { totalBalance: number; spendableBalance: number; dailyFreePoints: number; - membershipLimitedPoints: number; + /** 月度泥点:当前会员账期内发放、随账期重置而清零的泥点;无会员时为 0。 */ + monthlyPoints: number; permanentPoints: number; heldPoints: number; refundDebtPoints: number; @@ -950,6 +953,25 @@ export interface AdminRechargeRefundHoldPayload { updatedAtMicros: number; } +export interface AdminMembershipOrderChangePayload { + /** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */ + changeKind: ProfileMembershipChangeKindToken; + planBefore?: ProfileMembershipPlan | null; + planAfter?: ProfileMembershipPlan | null; + cycleKindBefore?: ProfileMembershipCycleKind | null; + cycleKindAfter?: ProfileMembershipCycleKind | null; + periodPointsBefore?: number | null; + periodPointsAfter?: number | null; + grantedPointsDelta: number; + cycleIndex?: number | null; + expiresAtBeforeMicros?: number | null; + expiresAtAfterMicros?: number | null; + cycleResetsAtBeforeMicros?: number | null; + cycleResetsAtAfterMicros?: number | null; + /** 订单价格拆分 JSON(目标档位价、当前档位价、实付金额);人工复核时原样展示。 */ + priceBreakdownJson?: string | null; +} + export interface AdminRechargeOrderEntryPayload { orderId: string; userId: string; @@ -977,6 +999,8 @@ export interface AdminRechargeOrderEntryPayload { remainingRefundableCents: number; refundEligible: boolean; refundBlockReasonCode?: string | null; + /** 会员购买 / 升级快照;非会员订单为 null。 */ + membershipChange?: AdminMembershipOrderChangePayload | null; } export interface AdminRechargeOrderListResponse { @@ -1003,9 +1027,26 @@ export interface AdminUserDetailResponse { cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; + membership: AdminProfileMembershipPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; } +/** 当前会员状态与账期;无会员时为 `normal` 档位、无到期时间。 */ +export interface AdminProfileMembershipPayload { + plan: ProfileMembershipPlan; + /** 与后端 `RuntimeProfileMembershipStatus` 对齐:`normal` / `active`。 */ + status: ProfileMembershipStatus; + cycleKind: ProfileMembershipCycleKind; + cycleIndex: number; + cycleCount: number; + cycleGrantedPoints: number; + cycleRemainingPoints: number; + startedAtMicros?: number | null; + expiresAtMicros?: number | null; + cycleResetsAtMicros?: number | null; + updatedAtMicros?: number | null; +} + export interface AdminUserConsumptionReconcileRequest { userId: string; } @@ -1109,6 +1150,8 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; +/** 复用 `@genarrative/shared` 的生成类型,与后端 `ProfileMembershipModelAccessToken` 同源。 */ +export type AdminAgcModelAccess = ProfileMembershipModelAccess; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' @@ -1122,6 +1165,8 @@ export interface AdminAgcModel { agentMode: AdminAgcAgentMode; /** 上游线协议,与执行器正交。 */ protocol?: AdminAgcModelProtocol; + /** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */ + access?: AdminAgcModelAccess; } export interface AdminAgcModelCatalog { @@ -1138,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry { packageBytes: number; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; reviewReason: string | null; createdAt: string; updatedAt: string; @@ -1166,6 +1213,8 @@ export interface AdminGameDistributionGameDetail { orientation: string; status: string; publicationRevision: number; + /** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */ + priceMudPoints?: number; playCount: number; createdAt: string; } diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 52957c245..7e7a5fe6f 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -9,6 +9,7 @@ import { } from '../api/adminApiClient'; import type { AdminSessionPayload, + ProfileMembershipPlanAdminResponse, ProfileRechargeProductConfigAdminResponse, ProfileTaskConfigAdminResponse, ProfileWalletConfigAdminResponse, @@ -35,6 +36,7 @@ import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; +import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; @@ -69,6 +71,8 @@ export function AdminApp() { useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); + const [membershipPlanResult, setMembershipPlanResult] = + useState(null); const accessibleRoutes = useMemo( () => (admin ? getAccessibleAdminRoutes(admin) : []), [admin], @@ -84,6 +88,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -174,6 +179,7 @@ export function AdminApp() { setTaskConfigResult(null); setProfileWalletConfigResult(null); setRechargeProductResult(null); + setMembershipPlanResult(null); setLoginNotice(''); setStatus('authenticated'); }, @@ -292,6 +298,14 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {activeRouteId === 'membership-plans' ? ( + + ) : null} {activeRouteId === 'recharge-orders' ? ( void handleConsumptionReconcile()} /> + +
{order.productTitle || order.productId} {order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'} {order.pointsDelta} 泥点 - {formatMoney(order.cumulativeSuccessRefundCents)} + {formatAdminCurrencyCents( + order.cumulativeSuccessRefundCents, + )} 欠账 {order.unrecoveredPoints} 泥点 {formatOrderStatus(order.status)} @@ -434,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) { value: ( <> {typeof detail.cumulativeRechargedCents === 'number' - ? formatMoney(detail.cumulativeRechargedCents) + ? formatAdminCurrencyCents(detail.cumulativeRechargedCents) : '未知'} ), @@ -478,7 +489,7 @@ function WalletSection({ ['可消费', wallet.spendableBalance], ['永久泥点', wallet.permanentPoints], ['每日免费', wallet.dailyFreePoints], - ['会员限时', wallet.membershipLimitedPoints], + ['月度泥点', wallet.monthlyPoints], ['退款占用', wallet.heldPoints], ['退款欠账', wallet.refundDebtPoints], ['历史花费', historicalConsumedPoints], @@ -527,8 +538,49 @@ function WalletSection({ ); } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; +function MembershipSection({ + membership, +}: { + membership: AdminProfileMembershipPayload; +}) { + const metrics: Array<[string, string]> = [ + ['档位', `${formatMembershipPlan(membership.plan)}(${membership.plan})`], + ['状态', formatMembershipStatus(membership.status) || '-'], + [ + '账期', + // 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。 + membership.status === 'active' + ? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期` + : '-', + ], + ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], + ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], + [ + '到期', + membership.expiresAtMicros + ? formatMicros(membership.expiresAtMicros) + : '-', + ], + [ + '账期重置', + membership.cycleResetsAtMicros + ? formatMicros(membership.cycleResetsAtMicros) + : '-', + ], + ]; + return ( +
+ +
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); } function formatMicros(value: number) { diff --git a/apps/admin-web/src/config/membershipDisplay.ts b/apps/admin-web/src/config/membershipDisplay.ts new file mode 100644 index 000000000..3a903c5fb --- /dev/null +++ b/apps/admin-web/src/config/membershipDisplay.ts @@ -0,0 +1,44 @@ +import type { + ProfileMembershipCycleKind, + ProfileMembershipPlan, + ProfileMembershipStatus, +} from '../api/adminApiTypes'; + +/** 会员档位展示名;`normal` 即非会员。 */ +export const membershipPlanLabels: Record = { + normal: '非会员', + starter: 'Starter', + plus: 'Plus', + pro: 'Pro', + max: 'Max', +}; + +/** 账期展示名;有效期内不支持月年互换。 */ +export const membershipCycleKindLabels: Record< + ProfileMembershipCycleKind, + string +> = { + monthly: '月付', + yearly: '年付', +}; + +/** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */ +export const membershipStatusLabels: Record = { + normal: '非会员', + active: '生效中', +}; + +/** 把后端的档位 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipPlan(plan: string): string { + return membershipPlanLabels[plan as ProfileMembershipPlan] ?? plan; +} + +/** 把后端的账期 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipCycleKind(kind: string): string { + return membershipCycleKindLabels[kind as ProfileMembershipCycleKind] ?? kind; +} + +/** 把后端的会员状态 code 映射成展示名,未知值原样返回。 */ +export function formatMembershipStatus(status: string): string { + return membershipStatusLabels[status as ProfileMembershipStatus] ?? status; +} diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index feb4ce9d3..3279a5342 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () => modelId: '', enabled: true, agentMode: 'cc', + access: 'basic', }); }); }); +test('权限档可按模型单独选择并随目录保存', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'gpt-6-astra', + enabled: true, + agentMode: 'codex' as const, + access: 'basic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + + const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement; + expect(access.value).toBe('basic'); + fireEvent.change(access, { target: { value: 'full' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ access: 'full' }); + }); +}); + test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { const catalog = { revision: 3, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index b009aa222..9e14400cb 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -118,6 +118,7 @@ export function AdminAgcModelsPage({ { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, { key: 'protocol', label: '协议' }, + { key: 'access', label: '权限档' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -159,6 +160,7 @@ export function AdminAgcModelsPage({ enabled: true, agentMode: 'codex', protocol: 'openai_responses', + access: 'basic', }, ], }, @@ -246,6 +248,21 @@ export function AdminAgcModelsPage({ + + + = { public_work_play_daily_stat: '公开作品日游玩统计', public_work_like: '公开作品点赞', profile_membership: '会员状态', + profile_membership_plan: '会员档位目录', profile_recharge_order: '充值订单', profile_feedback_submission: '反馈提交', profile_save_archive: '存档记录', @@ -2578,6 +2579,7 @@ const databaseTableDescriptionMap: Record = { public_work_play_daily_stat: '公开作品日游玩统计表', public_work_like: '公开作品点赞表', profile_membership: '会员状态表', + profile_membership_plan: '会员档位价格与权益目录表', profile_recharge_order: '充值订单表', profile_feedback_submission: '反馈提交记录表', profile_save_archive: '用户存档记录表', diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 3ca7ab6be..b5f122a2b 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -1,6 +1,8 @@ /* @vitest-environment jsdom */ +import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components'; import { + act, fireEvent, render, screen, @@ -17,6 +19,7 @@ import { reviewAdminGameDistributionVersion, } from '../api/adminApiClient'; import type { + AdminGameDistributionGameDetail, AdminGameDistributionGameListResponse, AdminGameDistributionReviewEntry, AdminGameDistributionVersionDetailResponse, @@ -48,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = { packageBytes: 2048, status: 'pending_review', publicationRevision: 4, + priceMudPoints: 30, reviewReason: null, // 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。 createdAt: '1791008678.817257Z', @@ -92,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = { orientation: 'landscape', status: 'unpublished', publicationRevision: 7, + priceMudPoints: 30, playCount: 0, createdAt: '2026-09-20T08:00:00Z', }, @@ -228,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', const row = screen.getByText(gameTitle).closest('tr'); expect(row).not.toBeNull(); - const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; + // 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。 + const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4]; // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 expect(submittedCell?.textContent).not.toBe('-'); expect(submittedCell?.textContent).toMatch( @@ -236,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', ); }); +test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => { + const freeEntry: AdminGameDistributionReviewEntry = { + ...entry, + versionId: 'version-2', + gameId: 'game_2', + versionNumber: 1, + priceMudPoints: 0, + }; + // 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。 + const legacyEntry = { + ...entry, + versionId: 'version-3', + gameId: 'game_3', + versionNumber: 3, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionReviewEntry; + vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ + entries: [entry, freeEntry, legacyEntry], + nextCursor: null, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + const table = screen.getByText(gameTitle).closest('table'); + expect(table).not.toBeNull(); + // 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。 + const priceColumnIndex = within(table as HTMLElement) + .getAllByRole('columnheader') + .findIndex((header) => header.textContent?.trim() === '价格'); + expect(priceColumnIndex).toBeGreaterThanOrEqual(0); + + const cellAt = (text: string) => { + const row = screen.getByText(text).closest('tr'); + expect(row).not.toBeNull(); + return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex] + ?.textContent; + }; + + // 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。 + expect(cellAt(gameTitle)).toBe('30 泥点'); + expect(cellAt('game_2')).toBe('免费'); + expect(cellAt('game_3')).toBe('免费'); +}); + test('取消理由输入时不做审核操作', async () => { render( , + ); + await screen.findByText(gameTitle); + fireEvent.click(screen.getByRole('button', { name: '详情' })); + await screen.findAllByText(/测试作者/u); + fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' })); + return screen.findByTitle('测试游戏 待审版本试玩'); +} + +test('试玩期间先给加载面,待审版本真正载入后让位给画面', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); + vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + expiresAt: '2026-10-02T12:10:00Z', + versionId: 'version-1', + }); + + const frame = await openPendingVersionPreview(); + + // 加载期不能只留一片空白:审核员要看到正在加载哪个游戏。 + const loadingSurface = screen.getByRole('status'); + expect(loadingSurface.textContent).toContain('测试游戏'); + expect( + loadingSurface.classList.contains('platform-game-loading--panel'), + ).toBe(true); + + fireEvent.load(frame); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); +}); + +test('试玩加载超过上限后给出超时说明与重建会话入口', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); + vi.mocked(createAdminGameDistributionPreviewSession) + .mockResolvedValueOnce({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + expiresAt: '2026-10-02T12:10:00Z', + versionId: 'version-1', + }) + .mockResolvedValueOnce({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/', + expiresAt: '2026-10-02T12:20:00Z', + versionId: 'version-1', + }); + render( + , + ); + await screen.findByText(gameTitle); + fireEvent.click(screen.getByRole('button', { name: '详情' })); + await screen.findAllByText(/测试作者/u); + + // 试玩会话与超时定时器都必须落在假定时器窗口内,才能按上限推进等待。 + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTitle('测试游戏 待审版本试玩')).toBeTruthy(); + expect(screen.queryByText('试玩版本加载超时')).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS); + }); + expect(screen.getByRole('status').textContent).toContain( + '试玩版本加载超时', + ); + + fireEvent.click(screen.getByRole('button', { name: '重新创建试玩会话' })); + await act(async () => { + await Promise.resolve(); + }); + expect(createAdminGameDistributionPreviewSession).toHaveBeenCalledTimes(2); + // 新会话必须回到加载态,不能沿用上一个会话的「已就绪」或超时结论。 + const rebuilt = screen.getByTitle('测试游戏 待审版本试玩'); + expect(rebuilt.getAttribute('src')).toBe( + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-2/', + ); + expect(screen.queryByText('试玩版本加载超时')).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + // 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进 + // `game.priceMudPoints`,审核页只渲染这个字段。 + game: { ...detailResponse.game, priceMudPoints: 30 }, + version: { + ...detailResponse.version, + // 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行 + // 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。 + frozenMetadata: { title: '测试游戏', priceMudPoints: 999 }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('价格')).toBeTruthy(); + expect(screen.getByText('30 泥点')).toBeTruthy(); + expect(screen.queryByText('999 泥点')).toBeNull(); + expect(screen.queryByText('免费')).toBeNull(); +}); + +test('旧版本冻结资料没有价格时按免费回落', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({ + game: { + ...detailResponse.game, + priceMudPoints: undefined, + } as unknown as AdminGameDistributionGameDetail, + version: { + ...detailResponse.version, + frozenMetadata: { title: '测试游戏' }, + }, + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + + expect(screen.getByText('免费')).toBeTruthy(); + expect(screen.queryByText(/NaN/u)).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index f63a9c79e..0fe3d3118 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -10,6 +10,8 @@ import { formatAdminDateTime, GameDetailDisplay, Modal, + PLATFORM_GAME_LOADING_TIMEOUT_MS, + PlatformGameLoadingSurface, TextField, } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; @@ -98,6 +100,8 @@ function applyFrozenGameMetadata( typeof metadata.orientation === 'string' ? metadata.orientation : game.orientation, + // 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、 + // 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。 }; } @@ -111,6 +115,11 @@ function formatBytes(value: number) { return `${value} B`; } +/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */ +function formatPriceMudPoints(value: number | null | undefined) { + return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费'; +} + function createReviewIdempotencyKey(versionId: string) { const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto @@ -437,6 +446,7 @@ export function AdminGameDistributionReviewPage({ { key: 'gameId', label: '游戏' }, { key: 'versionNumber', label: '版本' }, { key: 'packageBytes', label: '发行包' }, + { key: 'priceMudPoints', label: '价格' }, { key: 'createdAt', label: '提交时间' }, { key: 'actions', label: '审核' }, ]} @@ -482,6 +492,7 @@ export function AdminGameDistributionReviewPage({ {entry.packageSha256.slice(0, 12)} + {formatPriceMudPoints(entry.priceMudPoints)} {formatAdminDateTime(entry.createdAt)} @@ -591,6 +602,20 @@ function AdminGameDistributionReviewDetailView({ onReject: () => void; onApprove: () => void; }) { + // 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。 + const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false); + const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false); + + useEffect(() => { + setIsPreviewFrameReady(false); + setIsPreviewTimedOut(false); + if (!previewUrl) return undefined; + const timer = window.setTimeout(() => { + setIsPreviewTimedOut(true); + }, PLATFORM_GAME_LOADING_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [previewUrl]); + if (!detail) { return ( @@ -606,6 +631,10 @@ function AdminGameDistributionReviewDetailView({ ); } + const coverUrl = detail.game.coverObjectKey + ? assetUrls[detail.game.coverObjectKey] + : ''; + return ( {previewError} ) : null} {previewUrl ? ( -