合并 origin/master 到 feat/adapt-xhs-skill:发布入口迁入导出产物面板并吸收发布绑定、内部版本与上传进度
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Repository checks (pull_request) Failing after 33s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m55s
Project CI / Native shell tests (pull_request) Successful in 8m39s

- 陶泥儿 tab 发布面板去掉前端灰度:删除 readGamePublishAvailability 调用、发布权限占位与对应测试 mock,与 master 删除原生灰度命令保持一致,只在项目清单就绪前显示「正在读取项目清单」
- App.tsx 保留本分支导出产物面板架构,发布入口不再挂 Direct 聊天头;useTaonierTab 收敛 allowed/availabilityPending 状态并同步注释
- 发布表单 hook 合并本分支表单状态与 master 的发布绑定回读、AGC 工程内部版本派生、线上最近提交与上传进度阶段
- GameDistributionPublishFormView 接入发布状态面板、只读「项目版本」标签、发布阶段步骤与「更新游戏」等文案
- 合并发布表单与发布反馈测试:恢复封面/试玩包断言,补 readGameDistributionPublication mock
- agc-skills/manifest.json 取本分支新增的 platform-abstract、vite-export-xhs-minitool 与 master 的三处 sha256,version 提升到 2026-08-26.66
- decision-log.md 保留双方新增条目;技术方案文档补回双方新增章节;玩法链路文档取 master 重写后的发行合同
This commit is contained in:
2026-10-06 20:01:16 +08:00
478 changed files with 49995 additions and 21633 deletions
+2
View File
@@ -222,6 +222,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',
+3
View File
@@ -20,6 +20,9 @@ env:
CARGO_HTTP_MULTIPLEXING: 'false'
CARGO_NET_RETRY: '10'
CARGO_TERM_COLOR: always
# 等价于给每条 `cargo test` 加 `-q`:通过的用例只打点号,失败的才按名字列出。
# 对 npm 脚本、shard runner 里嵌套的 cargo 同样生效,避免逐条 `test ... ok` 刷屏。
CARGO_TERM_QUIET: 'true'
NPM_CONFIG_AUDIT: 'false'
NPM_CONFIG_FETCH_RETRIES: '10'
NPM_CONFIG_FETCH_RETRY_FACTOR: '2'
+5 -2
View File
@@ -2,14 +2,17 @@ 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/view/project-development/export/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.*
+50
View File
@@ -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
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
+27 -1
View File
@@ -618,7 +618,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=50',
'/admin/api/game-distribution/games?limit=80',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
@@ -656,6 +656,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('游戏管理列表把筛选与游标编码进查询串', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }),
{ status: 200 },
),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionGames('admin-token', {
limit: 20,
keyword: ' 拼图 ',
owner: 'user_9',
status: 'suspended',
cursor: '1710000000000:game_1',
});
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1',
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+76 -4
View File
@@ -33,11 +33,14 @@ import type {
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionGameStatus,
AdminGameDistributionPreviewSessionResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminGameDistributionVersionDetailResponse,
AdminGameReviewDetailResponse,
AdminGameReviewGamesQuery,
AdminGameReviewGamesResponse,
@@ -71,6 +74,7 @@ import type {
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileMembershipPlanRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
@@ -87,6 +91,8 @@ import type {
EditorGenerationPricingConfigPayload,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse,
ProfileMembershipPlanAdminListResponse,
ProfileMembershipPlanAdminResponse,
ProfileRechargeProductConfigAdminListResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminListResponse,
@@ -792,6 +798,27 @@ export function upsertProfileRechargeProduct(
);
}
export function listProfileMembershipPlans(token: string) {
return request<ProfileMembershipPlanAdminListResponse>(
'/admin/api/profile/membership-plans',
{ token },
);
}
export function upsertProfileMembershipPlan(
token: string,
payload: AdminUpsertProfileMembershipPlanRequest,
) {
return request<ProfileMembershipPlanAdminResponse>(
'/admin/api/profile/membership-plans',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listAdminRechargeOrders(
token: string,
query: AdminRechargeOrderListQuery = {},
@@ -1257,6 +1284,39 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
export function getAdminGameDistributionVersion(
token: string,
versionId: string,
) {
return request<AdminGameDistributionVersionDetailResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}`,
{ token },
);
}
export function createAdminGameDistributionPreviewSession(
token: string,
versionId: string,
) {
return request<AdminGameDistributionPreviewSessionResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}/preview-session`,
{ token, method: 'POST' },
).then((response) => ({
...response,
previewUrl: new URL(
response.previewUrl,
ADMIN_API_BASE_URL || window.location.origin,
).toString(),
}));
}
/**
* 后台作品管理列表。
*
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
*/
function gameReviewQuery(
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
) {
@@ -1311,18 +1371,30 @@ export function moderateAdminGameReview(
},
);
}
export function listAdminGameDistributionGames(
token: string,
options: { limit?: number } = {},
options: {
limit?: number;
keyword?: string;
owner?: string;
status?: AdminGameDistributionGameStatus | '';
cursor?: string | null;
} = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? 50;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200)
: 50;
const params = new URLSearchParams({ limit: String(normalizedLimit) });
const keyword = options.keyword?.trim();
if (keyword) params.set('keyword', keyword);
const owner = options.owner?.trim();
if (owner) params.set('owner', owner);
if (options.status) params.set('status', options.status);
if (options.cursor) params.set('cursor', options.cursor);
return request<AdminGameDistributionGameListResponse>(
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
`/admin/api/game-distribution/games?${params.toString()}`,
{ token, signal },
);
}
+126 -24
View File
@@ -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 {
@@ -1143,6 +1188,52 @@ export interface AdminGameDistributionReviewEntry {
updatedAt: string;
}
export interface AdminGameDistributionGameDetail {
id: string;
title: string;
summary: string;
description: string;
category: string;
tags: string[];
coverObjectKey: string | null;
screenshots: string[];
author: {
id: string;
name: string;
avatarUrl: string | null;
};
deviceSupport: {
desktop: boolean;
mobile: boolean;
touch: boolean;
};
inputModes: string[];
orientation: string;
status: string;
publicationRevision: number;
playCount: number;
createdAt: string;
}
export interface AdminGameDistributionVersionDetail
extends AdminGameDistributionReviewEntry {
packageFileCount: number;
entryUrl: string | null;
recoveryAction: string;
frozenMetadata: Record<string, unknown> | null;
}
export interface AdminGameDistributionVersionDetailResponse {
game: AdminGameDistributionGameDetail;
version: AdminGameDistributionVersionDetail;
}
export interface AdminGameDistributionPreviewSessionResponse {
previewUrl: string;
expiresAt: string;
versionId: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
@@ -1197,18 +1288,29 @@ export interface AdminGameDistributionGameEntry {
name: string;
avatarUrl: string | null;
};
status: string;
status: AdminGameDistributionGameStatus;
versionCount: number;
playCount: number;
activeVersionId: string | null;
publicationRevision: number;
createdAt: string;
updatedAt: string;
/** 作者软删除时间;为空表示未删除。 */
deletedAt: string | null;
versions: AdminGameDistributionGameVersionEntry[];
}
/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */
export type AdminGameDistributionGameStatus =
| 'published'
| 'unpublished'
| 'suspended'
| 'deleted';
export interface AdminGameDistributionGameListResponse {
games: AdminGameDistributionGameEntry[];
/** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */
nextCursor?: string | null;
}
export interface AdminGameDistributionRestoreRequest {
+14
View File
@@ -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<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const [membershipPlanResult, setMembershipPlanResult] =
useState<ProfileMembershipPlanAdminResponse | null>(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' ? (
<AdminMembershipPlanPage
result={membershipPlanResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setMembershipPlanResult}
/>
) : null}
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
+1
View File
@@ -56,6 +56,7 @@ const routeIcons = {
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'membership-plans': ShieldCheck,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
+8 -1
View File
@@ -13,6 +13,7 @@ export type AdminRouteId =
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'membership-plans'
| 'recharge-orders'
| 'payment-orders'
| 'editor-generation-pricing'
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' },
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'membership-plans', label: '会员档位', hash: '#membership-plans' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
{
@@ -109,7 +111,12 @@ export const adminNavGroups: AdminNavGroupDefinition[] = [
{
id: 'billing',
label: '充值与支付',
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
routeIds: [
'recharge-products',
'membership-plans',
'recharge-orders',
'payment-orders',
],
},
{
id: 'agc',
@@ -30,7 +30,7 @@ const wallet: AdminProfileWalletPayload = {
totalBalance: 96,
spendableBalance: 40,
dailyFreePoints: 6,
membershipLimitedPoints: 20,
monthlyPoints: 20,
permanentPoints: 70,
heldPoints: 5,
refundDebtPoints: 25,
@@ -54,6 +54,19 @@ const detail: AdminUserDetailResponse = {
cumulativeRechargedCents: 128800,
canReconcileConsumption: true,
wallet,
membership: {
plan: 'plus',
status: 'active',
cycleKind: 'monthly',
cycleIndex: 2,
cycleCount: 1,
cycleGrantedPoints: 500,
cycleRemainingPoints: 320,
startedAtMicros: 1_720_000_000_000_000,
expiresAtMicros: 1_722_592_000_000_000,
cycleResetsAtMicros: 1_722_592_000_000_000,
updatedAtMicros: 1_720_000_000_000_000,
},
rechargeOrders: [
{
orderId: 'order-1',
@@ -125,6 +138,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('月度泥点')).toBeTruthy();
expect(screen.getByText('Plus(plus)')).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('¥1288.00')).toBeTruthy();
@@ -9,6 +9,7 @@ import {
AdminStatusPill,
AdminTag,
AdminTagList,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
@@ -22,9 +23,15 @@ import {
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileMembershipPayload,
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {
formatMembershipCycleKind,
formatMembershipPlan,
formatMembershipStatus,
} from '../config/membershipDisplay';
import { useAdminWriteConfirm } from './useAdminWriteConfirm';
interface AdminUserDetailDialogProps {
@@ -265,6 +272,8 @@ export function AdminUserDetailDialog({
onReconcile={() => void handleConsumptionReconcile()}
/>
<MembershipSection membership={detail.membership} />
<section className="admin-user-restriction-section">
<AdminPanelHeading
title="人工冻结"
@@ -374,12 +383,14 @@ export function AdminUserDetailDialog({
<td>{order.productTitle || order.productId}</td>
<td>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
? formatAdminCurrencyCents(order.paidAmountCents)
: '未支付'}
</td>
<td>{order.pointsDelta} 泥点</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
{formatAdminCurrencyCents(
order.cumulativeSuccessRefundCents,
)}
<small>欠账 {order.unrecoveredPoints} 泥点</small>
</td>
<td>{formatOrderStatus(order.status)}</td>
@@ -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 (
<section className="admin-user-wallet-section">
<AdminPanelHeading title="会员" />
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
);
}
function formatMicros(value: number) {
@@ -0,0 +1,44 @@
import type {
ProfileMembershipCycleKind,
ProfileMembershipPlan,
ProfileMembershipStatus,
} from '../api/adminApiTypes';
/** 会员档位展示名;`normal` 即非会员。 */
export const membershipPlanLabels: Record<ProfileMembershipPlan, string> = {
normal: '非会员',
starter: 'Starter',
plus: 'Plus',
pro: 'Pro',
max: 'Max',
};
/** 账期展示名;有效期内不支持月年互换。 */
export const membershipCycleKindLabels: Record<
ProfileMembershipCycleKind,
string
> = {
monthly: '月付',
yearly: '年付',
};
/** 会员状态展示名;与后端 `RuntimeProfileMembershipStatus` 的 `normal` / `active` 对齐。 */
export const membershipStatusLabels: Record<ProfileMembershipStatus, string> = {
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;
}
@@ -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(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
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,
@@ -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({
<option value="anthropic">Anthropic Messages</option>
</select>
</td>
<td>
<select
aria-label={`模型 ${index + 1} 权限档`}
value={model.access ?? 'basic'}
disabled={busy}
onChange={(e) =>
update(model.id, {
access: e.target.value as AdminAgcModel['access'],
})
}
>
<option value="basic">基础模型</option>
<option value="full">高性能模型</option>
</select>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
@@ -2537,6 +2537,7 @@ const databaseTableLabelMap: Record<string, string> = {
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<string, string> = {
public_work_play_daily_stat: '公开作品日游玩统计表',
public_work_like: '公开作品点赞表',
profile_membership: '会员状态表',
profile_membership_plan: '会员档位价格与权益目录表',
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
@@ -1,6 +1,8 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
fireEvent,
render,
screen,
@@ -10,11 +12,17 @@ import {
import { beforeEach, expect, test, vi } from 'vitest';
import {
createAdminGameDistributionPreviewSession,
getAdminGameDistributionVersion,
listAdminGameDistributionGames,
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import type {
AdminGameDistributionGameListResponse,
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
} from '../api/adminApiTypes';
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
@@ -28,11 +36,12 @@ vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
createAdminGameDistributionPreviewSession: vi.fn(),
getAdminGameDistributionVersion: vi.fn(),
listAdminGameDistributionGames: vi.fn(),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
@@ -42,18 +51,72 @@ const entry: AdminGameDistributionReviewEntry = {
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
createdAt: '1791008678.817257Z',
updatedAt: '1791008678.817257Z',
};
/** 游戏名与作者由既有后台作品列表接口按 gameId 反查(待审列表本身不含这两个字段)。 */
const gameTitle = '测试游戏名';
const gameListResponse: AdminGameDistributionGameListResponse = {
games: [
{
gameId: 'game_1',
title: gameTitle,
author: { id: 'user-9f1', name: '136****8867', avatarUrl: null },
status: 'unpublished',
versionCount: 1,
playCount: 0,
activeVersionId: null,
publicationRevision: 4,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
deletedAt: null,
versions: [],
},
],
nextCursor: null,
};
const detailResponse: AdminGameDistributionVersionDetailResponse = {
game: {
id: 'game_1',
title: '测试游戏',
summary: '测试简介',
description: '测试详细介绍',
category: '益智',
tags: ['测试'],
coverObjectKey: null,
screenshots: [],
author: { id: 'user-1', name: '测试作者', avatarUrl: null },
deviceSupport: { desktop: true, mobile: false, touch: true },
inputModes: ['keyboard'],
orientation: 'landscape',
status: 'unpublished',
publicationRevision: 7,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
},
version: {
...entry,
packageFileCount: 3,
entryUrl: null,
recoveryAction: 'wait',
frozenMetadata: { title: '测试游戏' },
},
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(listAdminGameDistributionGames).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(getAdminGameDistributionVersion).mockReset();
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
vi.mocked(listAdminGameDistributionGames).mockResolvedValue(gameListResponse);
});
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
@@ -68,7 +131,7 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async ()
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
await screen.findByText(gameTitle);
expect(screen.queryByLabelText('发行入口')).toBeNull();
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
@@ -107,7 +170,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
@@ -135,54 +198,44 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
});
});
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
);
// 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页;
// 审核一个新版本时不该出现该动作。
expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull();
expect(screen.queryByText('继续下架')).toBeNull();
expect(screen.queryByLabelText('下架原因')).toBeNull();
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull();
expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy();
});
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
const row = screen.getByText(gameTitle).closest('tr');
expect(row).not.toBeNull();
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
expect(submittedCell?.textContent).not.toBe('-');
expect(submittedCell?.textContent).toMatch(
/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u,
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '盗用素材',
});
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
});
test('取消理由输入时不做审核操作', async () => {
@@ -192,7 +245,7 @@ test('取消理由输入时不做审核操作', async () => {
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
@@ -202,31 +255,163 @@ test('取消理由输入时不做审核操作', async () => {
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('取消安全下架确认时不下架', async () => {
test('游戏列显示游戏名与作者,不再把 gameId 当主展示', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
const reasonDialog = await screen.findByRole('dialog');
fireEvent.change(
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
{
target: { value: '盗用素材' },
},
);
fireEvent.click(
within(reasonDialog).getByRole('button', { name: '继续下架' }),
// 按 gameId 反查一次,第二次刷新走缓存(本次只加载一次列表)。
expect(listAdminGameDistributionGames).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({ keyword: 'game_1' }),
);
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
const row = screen.getByText(gameTitle).closest('tr');
expect(row).not.toBeNull();
const gameCell = within(row as HTMLElement).getAllByRole('cell')[0];
// 主展示是游戏名 + 打码作者名;gameId 只留在悬浮标题里。
expect(gameCell?.textContent).toContain('136****8867');
expect(gameCell?.textContent).not.toContain('game_1');
expect(screen.getByTitle('user-9f1').textContent).toBe('user-9f1');
expect(gameCell?.querySelector('[title="game_1"]')).not.toBeNull();
});
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',
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('测试详细介绍')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
expect(frame.getAttribute('src')).toBe(
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
);
expect(frame.getAttribute('sandbox')).toBe('allow-scripts');
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledWith(
'admin-token',
'version-1',
expect.any(String),
expect.objectContaining({
decision: 'approve',
expectedPublicationRevision: 7,
}),
),
);
});
/** 打开详情并点开试玩,返回试玩 iframe。 */
async function openPendingVersionPreview() {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
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();
}
});
File diff suppressed because it is too large Load Diff
@@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = {
publicationRevision: 4,
createdAt: '2026-09-18T08:00:00Z',
updatedAt: '2026-09-20T10:00:00Z',
deletedAt: null,
versions: [version],
};
@@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = {
publicationRevision: 7,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-21T08:00:00Z',
deletedAt: null,
versions: [],
};
const deletedGame: AdminGameDistributionGameEntry = {
gameId: 'game_3',
title: '已删除作品',
author: {
id: 'user_3',
name: '作者丙',
avatarUrl: null,
},
status: 'deleted',
versionCount: 1,
playCount: 3,
activeVersionId: null,
publicationRevision: 2,
createdAt: '2026-09-17T08:00:00Z',
updatedAt: '2026-09-22T08:00:00Z',
deletedAt: '2026-09-22T08:00:00Z',
versions: [],
};
@@ -197,20 +218,62 @@ test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
// 操作列不再内嵌输入框,点按钮才打开面板。
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
const reasonInput = within(dialog).getByLabelText('下架原因');
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
});
test('下架面板取消不触发请求', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.click(screen.getByRole('button', { name: '下架' }));
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
@@ -277,3 +340,92 @@ test('401 交给 onUnauthorized 处理', async () => {
);
expect(screen.queryByRole('alert')).toBeNull();
});
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
target: { value: ' 拼图 ' },
});
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
target: { value: 'user_9' },
});
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
target: { value: 'suspended' },
});
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
expect(applied).toEqual({
limit: 50,
cursor: null,
keyword: '拼图',
owner: 'user_9',
status: 'suspended',
});
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
);
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
expect(reset).toEqual({
limit: 50,
cursor: null,
keyword: '',
owner: '',
status: '',
});
expect(
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
.value,
).toBe('');
});
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
const nextButton = screen.getByRole('button', { name: '下一页' });
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(nextButton);
await screen.findByText('下架游戏');
expect(screen.queryByText('测试游戏')).toBeNull();
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
.calls[1]!;
expect(secondPage?.cursor).toBe('cursor-1');
expect(
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [deletedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const row = (await screen.findByText('已删除作品')).closest('tr')!;
expect(within(row).getByText('已删除')).toBeTruthy();
expect(within(row).getByText(/删除于/)).toBeTruthy();
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
});
File diff suppressed because it is too large Load Diff
@@ -290,6 +290,7 @@ export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) {
</AdminPanel>
<AdminListPanel
title="评价列表"
tableClassName="admin-game-reviews-table"
busy={loading}
busyText="正在加载评价…"
busyVariant="line"
@@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
'游戏发布入口灰度:未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
},
];
@@ -0,0 +1,463 @@
import {
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminCurrencyCents,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
listProfileMembershipPlans,
upsertProfileMembershipPlan,
} from '../api/adminApiClient';
import type {
ProfileMembershipModelAccess,
ProfileMembershipPlan,
ProfileMembershipPlanAdminResponse,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import {
formatMembershipPlan,
membershipPlanLabels,
} from '../config/membershipDisplay';
import { handlePageError, parsePositiveIntegerOrZero } from './pageUtils';
interface AdminMembershipPlanPageProps {
token: string;
result: ProfileMembershipPlanAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileMembershipPlanAdminResponse) => void;
}
/** 模型权限的唯一文案表:表单选项与列表摘要共用,避免两处手工映射漂移。 */
const modelAccessLabels: Record<ProfileMembershipModelAccess, string> = {
basic: '仅基础模型',
full: '基础及高性能模型',
};
const modelAccessOptions: Array<{
value: ProfileMembershipModelAccess;
label: string;
}> = (Object.keys(modelAccessLabels) as ProfileMembershipModelAccess[]).map(
(value) => ({ value, label: modelAccessLabels[value] }),
);
/**
* 会员档位目录。
*
* 一行一档,档位即主键(也是身份)。价格与权益以本表为唯一真相源:充值中心展示、
* 下单金额重算、补差升级报价都读它。`rank` 由代码内的档位目录决定,后台不能改,
* 因此这里只读展示。模型权限与并发上限本期只落字段、只用于展示,服务端不做拦截。
*/
export function AdminMembershipPlanPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminMembershipPlanPageProps) {
const [entries, setEntries] = useState<ProfileMembershipPlanAdminResponse[]>(
[],
);
const [plan, setPlan] = useState<ProfileMembershipPlan>('starter');
const [title, setTitle] = useState('');
const [monthPriceCents, setMonthPriceCents] = useState('0');
const [yearPriceCents, setYearPriceCents] = useState('0');
const [periodPoints, setPeriodPoints] = useState('0');
const [modelAccess, setModelAccess] =
useState<ProfileMembershipModelAccess>('basic');
const [concurrentJobLimit, setConcurrentJobLimit] = useState('1');
const [unlimitedConcurrencyThreshold, setUnlimitedConcurrencyThreshold] =
useState(0);
const [enabled, setEnabled] = useState(false);
const [rankLabel, setRankLabel] = useState('0');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshPlans();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshPlans() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileMembershipPlans(token);
// 与保存后的排序保持一致:列表按升级基准 `rank` 展示。
const sortedEntries = sortPlanEntries(response.entries);
setEntries(sortedEntries);
setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold);
const firstEntry =
sortedEntries.find((entry) => entry.plan === plan) ?? sortedEntries[0];
if (firstEntry) {
fillForm(firstEntry);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const normalizedMonthPriceCents =
parsePositiveIntegerOrZero(monthPriceCents);
const normalizedYearPriceCents = parsePositiveIntegerOrZero(yearPriceCents);
const normalizedConcurrentJobLimit =
parsePositiveIntegerOrZero(concurrentJobLimit);
const normalizedPeriodPoints = parsePositiveIntegerOrZero(periodPoints);
if (plan !== 'normal') {
if (!normalizedMonthPriceCents || !normalizedYearPriceCents) {
setErrorMessage('月价与年价必须是大于 0 的整数分');
return;
}
if (!normalizedPeriodPoints) {
setErrorMessage('每期泥点必须是大于 0 的整数');
return;
}
if (!normalizedConcurrentJobLimit) {
setErrorMessage(
unlimitedConcurrencyThreshold > 0
? `并发上限必须是大于 0 的整数;${unlimitedConcurrencyThreshold} 表示不设上限`
: '并发上限必须是大于 0 的整数',
);
return;
}
}
const confirmed = await confirmWrite({
action: '保存会员档位',
target: `${membershipPlanLabels[plan]}(${plan})`,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertProfileMembershipPlan(token, {
plan,
title: title.trim(),
monthPriceCents: normalizedMonthPriceCents,
yearPriceCents: normalizedYearPriceCents,
periodPoints: normalizedPeriodPoints,
modelAccess,
concurrentJobLimit: normalizedConcurrentJobLimit,
enabled,
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function upsertEntry(next: ProfileMembershipPlanAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.plan !== next.plan);
return sortPlanEntries([...rest, next]);
});
}
function fillForm(entry: ProfileMembershipPlanAdminResponse) {
setPlan(entry.plan);
setTitle(entry.title);
setMonthPriceCents(String(entry.monthPriceCents));
setYearPriceCents(String(entry.yearPriceCents));
setPeriodPoints(String(entry.periodPoints));
setModelAccess(entry.modelAccess);
setConcurrentJobLimit(String(entry.concurrentJobLimit));
setEnabled(entry.enabled);
setRankLabel(String(entry.rank));
}
// 目录里还没有这一档时清空表单,避免把上一档的价格 / 权益误当成它的值提交。
function clearForm(nextPlan: ProfileMembershipPlan) {
setPlan(nextPlan);
setTitle('');
setMonthPriceCents('0');
setYearPriceCents('0');
setPeriodPoints('0');
setModelAccess('basic');
setConcurrentJobLimit('1');
setEnabled(false);
setRankLabel('');
}
const isPurchasable = plan !== 'normal';
return (
<AdminPage wide>
<AdminPageHeading
title="会员档位"
description="月价 / 年价 / 每期额度 / 模型权限 / 并发上限,充值中心与下单都读这张表"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshPlans}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
<div className="admin-two-column admin-two-column-wide">
<AdminPanel as="form" onSubmit={handleSave}>
<AdminFormRow>
<AdminField label="档位" fill>
<select
value={plan}
onChange={(event) => {
const nextPlan = event.target.value as ProfileMembershipPlan;
const entry = entries.find((item) => item.plan === nextPlan);
if (entry) {
fillForm(entry);
} else {
clearForm(nextPlan);
}
}}
>
{Object.entries(membershipPlanLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}({value})
</option>
))}
</select>
</AdminField>
<AdminField label="升级基准 rank(只读)">
<input readOnly value={rankLabel} />
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="标题">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</AdminField>
<label className="admin-switch-field">
<input
checked={enabled}
disabled={!isPurchasable}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>可购买</span>
</label>
</AdminFormRow>
<AdminFormRow>
<AdminField label="月价(分)">
<input
min={isPurchasable ? 1 : 0}
step={1}
type="number"
value={monthPriceCents}
onChange={(event) => setMonthPriceCents(event.target.value)}
/>
</AdminField>
<AdminField label="年价(分,独立可配置)">
<input
min={isPurchasable ? 1 : 0}
step={1}
type="number"
value={yearPriceCents}
onChange={(event) => setYearPriceCents(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="每期泥点(一个自然月)">
<input
min={0}
step={1}
type="number"
value={periodPoints}
onChange={(event) => setPeriodPoints(event.target.value)}
/>
</AdminField>
<AdminField
label={
unlimitedConcurrencyThreshold > 0
? `并发上限(${unlimitedConcurrencyThreshold} = 不设上限)`
: '并发上限'
}
>
<input
min={1}
step={1}
type="number"
value={concurrentJobLimit}
onChange={(event) => setConcurrentJobLimit(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminFormRow>
<AdminField label="模型权限">
<select
value={modelAccess}
onChange={(event) =>
setModelAccess(
event.target.value as ProfileMembershipModelAccess,
)
}
>
{modelAccessOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</AdminField>
</AdminFormRow>
{!isPurchasable ? (
<AdminAlert>
非会员档位不可购买,价格与额度会被后端按 0 落库。
</AdminAlert>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<AdminButton
variant="primary"
disabled={isSaving || !title.trim()}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</AdminButton>
</AdminPanel>
<div className="admin-stack">
<AdminListPanel
count={entries.length}
rows={entries}
showEmpty={false}
title="档位目录"
columns={[
{
key: 'plan',
label: '档位',
sortable: true,
sortDescription: '按升级基准 rank',
sortValue: (entry) => entry.rank,
},
{
key: 'price',
label: '月价 / 年价',
sortable: true,
sortValue: (entry) => entry.monthPriceCents,
},
{
key: 'points',
label: '每期泥点',
sortable: true,
sortValue: (entry) => entry.periodPoints,
},
{
key: 'limits',
label: '权限',
sortable: true,
sortValue: (entry) => entry.concurrentJobLimit,
},
{
key: 'enabled',
label: '状态',
sortable: true,
sortDescription: '可购买状态',
sortValue: (entry) => (entry.enabled ? 1 : 0),
},
]}
renderRow={(entry) => (
<tr key={entry.plan}>
<td>
<AdminButton
variant="text"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || formatMembershipPlan(entry.plan)}
</AdminButton>
<small>
{entry.plan} · rank {entry.rank}
</small>
</td>
<td>
{formatAdminCurrencyCents(entry.monthPriceCents)} /{' '}
{formatAdminCurrencyCents(entry.yearPriceCents)}
</td>
<td>{entry.periodPoints}</td>
<td>{formatPlanLimits(entry)}</td>
<td>{entry.enabled ? '可购买' : '不可购买'}</td>
</tr>
)}
size="compact"
/>
{result ? (
<AdminPanel title="最近保存" count={result.plan}>
<AdminInfoList
items={[
{
label: <>档位</>,
value: (
<>
{formatMembershipPlan(result.plan)}({result.plan})
</>
),
},
{
label: <>可购买</>,
value: <>{result.enabled ? '是' : '否'}</>,
},
]}
/>
</AdminPanel>
) : null}
</div>
</div>
{confirmDialog}
</AdminPage>
);
}
function sortPlanEntries(entries: ProfileMembershipPlanAdminResponse[]) {
return [...entries].sort((left, right) => left.rank - right.rank);
}
function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) {
const modelLabel = modelAccessLabels[entry.modelAccess];
const concurrency = entry.unlimitedConcurrency
? '不设上限'
: `${entry.concurrentJobLimit} 个`;
return `${modelLabel} · 并发 ${concurrency}`;
}
@@ -94,7 +94,7 @@ test('账号配置页一次保存初始和每日免费泥点', async () => {
);
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
test('账号配置页拒绝负数或非整数每日免费额度', async () => {
render(
<AdminProfileWalletConfigPage
token="admin-token"
@@ -109,5 +109,38 @@ test('账号配置页拒绝非正整数每日免费额度', async () => {
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.change(input, { target: { value: '-1' } });
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
test('账号配置页允许把每日免费泥点配置为 0', async () => {
const user = userEvent.setup();
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({
...configResponse,
dailyFreePointsPerDay: 0,
});
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('每日免费泥点数'), {
target: { value: '0' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByText('初始 100 泥点,每日免费 0 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {
initialMudPoints: 100,
dailyFreePointsPerDay: 0,
}),
);
});
@@ -18,7 +18,11 @@ import {
} from '../api/adminApiClient';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
import {
handlePageError,
parseNonNegativeInteger,
parsePositiveInteger,
} from './pageUtils';
interface AdminProfileWalletConfigPageProps {
token: string;
@@ -66,11 +70,11 @@ export function AdminProfileWalletConfigPage({
if (isSaving) {
return;
}
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
const normalizedDailyFreePointsPerDay = parseNonNegativeInteger(
dailyFreePointsPerDay,
);
if (!normalizedDailyFreePointsPerDay) {
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
if (normalizedDailyFreePointsPerDay === null) {
setErrorMessage('每日免费泥点数必须是不小于 0 的整数');
return;
}
@@ -139,7 +143,7 @@ export function AdminProfileWalletConfigPage({
<AdminField label="每日免费泥点数">
<input
min={1}
min={0}
step={1}
type="number"
value={dailyFreePointsPerDay}
@@ -154,7 +158,7 @@ export function AdminProfileWalletConfigPage({
disabled={
isSaving ||
!parsePositiveInteger(initialMudPoints) ||
!parsePositiveInteger(dailyFreePointsPerDay)
parseNonNegativeInteger(dailyFreePointsPerDay) === null
}
type="submit"
>
@@ -200,8 +204,3 @@ export function AdminProfileWalletConfigPage({
</AdminPage>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}

Some files were not shown because too many files have changed in this diff Show More