合并:把 master 的告警清理与首页对话退役并进接单化分支

- 冲突 codex_app_server/mod.rs:保留类型化回合失败 DirectTurnRunFailure 与 EnvironmentNotReady 分类,接受删除 audit/metrics 参数
- 冲突 direct_runtime/mod.rs:保留类型化错误反馈循环与"交付报告只读一次",保留 check_direct_turn_preconditions,接受 master 的 turn_kind 贯穿与首页无项目对话退役
- 冲突 direct_runtime/user_input.rs:保留接单后发射器与 canonical 用户条目的新调用签名
- 删除:随 master 移除 direct_codex_audit、direct_turn_metrics 两个账本模块及其全部引用
- 删除:direct_codex_error_should_feedback 字符串判据与首页对话函数及其测试
- 调整:direct_codex_error_feedback_prompt 收成单参数,与已落地的提示词模板一致
- 修复:master 新插入的用户条目冻结块改用 DirectTurnError::turn_failed
This commit is contained in:
2026-09-24 14:30:02 +08:00
466 changed files with 13787 additions and 45034 deletions
@@ -41,3 +41,14 @@
## 注意
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
## 通用表查询页的枚举展示(2026-09-23 起)
后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析:
- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。
- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。
- 映射同时应用到 `cells``raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。
- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。
因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`
+2 -6
View File
@@ -237,14 +237,10 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.worldrelease 对应 https://www.genarrative.world。
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
# dev 使用 https://dev.genarrative.worldrelease 使用 https://www.genarrative.world。
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
# the fixed `bge-large-zh` embedding model.
+21
View File
@@ -210,6 +210,14 @@ _Avoid_: 运行中回合、乐观锁、发送队列
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**项目对话输入**:
AGC 项目对话的输入只有自然语言回合(含 `@` 素材引用与附件);需要动作时由 Runtime 工具与确认卡承接,不从输入文本解析控制词。
_Avoid_: 斜杠命令、聊天命令草稿、命令发现列表
**项目命令 id**:
AGC 运行期工具与项目权限策略使用的稳定标识(`GAME_CREATION_APP_COMMANDS``GameCreationAppPermission`),由 Rust 运行期策略校验与 App 权限审计 / 项目前置条件判定消费,不是用户输入语法。
_Avoid_: 把命令 id 当作可输入的聊天命令、为权限位补聊天入口
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
@@ -226,6 +234,18 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用名**:
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
**引用候选枚举**:
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
**引用粘贴解析**:
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
@@ -256,6 +276,7 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
+81 -2
View File
@@ -8,10 +8,12 @@ import {
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionGames,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
restoreAdminGameDistributionGame,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
@@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
@@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
ok: true,
data: {
game: {
id: 'game/1',
title: '测试游戏',
status: 'published',
publicationRevision: 10,
},
replayed: false,
},
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
await listAdminGameDistributionGames(
'admin-token',
{ limit: 80 },
controller.signal,
);
await restoreAdminGameDistributionGame(
'admin-token',
' game/1 ',
' game-restore-key-1 ',
{ expectedPublicationRevision: 9 },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games?limit=50',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/restore',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-restore-key-1',
}),
body: JSON.stringify({ expectedPublicationRevision: 9 }),
}),
);
expect(() =>
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 9,
}),
).toThrow('缺少游戏 ID');
expect(() =>
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 9,
}),
).toThrow('恢复幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+43
View File
@@ -32,6 +32,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionGameListResponse,
AdminGameDistributionRestoreRequest,
AdminGameDistributionRestoreResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
@@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
);
}
export function listAdminGameDistributionGames(
token: string,
options: { limit?: number } = {},
signal?: AbortSignal,
) {
const requestedLimit = options.limit ?? 50;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
: 50;
return request<AdminGameDistributionGameListResponse>(
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
{ token, signal },
);
}
export function restoreAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: AdminGameDistributionRestoreRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('恢复幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionRestoreResponse>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
+58 -1
View File
@@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload {
export interface AdminDashboardMetricsPayload {
generatedAssets: number;
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
consumedMudPoints: number;
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
refundedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
@@ -955,6 +958,8 @@ export interface AdminRechargeOrderEntryPayload {
productTitle: string;
productKind: string;
amountCents: number;
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
paidAmountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
@@ -994,6 +999,8 @@ export interface AdminUserDetailResponse {
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
cumulativeRechargedCents?: number | null;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
@@ -1103,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
@@ -1126,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse {
replayed: boolean;
}
export interface AdminGameDistributionGameVersionEntry {
versionId: string;
gameId: string;
versionNumber: number;
status: string;
reviewReason: string | null;
packageBytes: number;
packageSha256: string;
createdAt: string;
updatedAt: string;
reviewedAt: string | null;
publishedAt: string | null;
entryUrl: string | null;
}
export interface AdminGameDistributionGameEntry {
gameId: string;
title: string;
author: {
id: string;
name: string;
avatarUrl: string | null;
};
status: string;
versionCount: number;
playCount: number;
activeVersionId: string | null;
publicationRevision: number;
createdAt: string;
updatedAt: string;
versions: AdminGameDistributionGameVersionEntry[];
}
export interface AdminGameDistributionGameListResponse {
games: AdminGameDistributionGameEntry[];
}
export interface AdminGameDistributionRestoreRequest {
expectedPublicationRevision: number;
}
export interface AdminGameDistributionRestoreResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
+7
View File
@@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -321,6 +322,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-management' ? (
<AdminGameManagementPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+2
View File
@@ -14,6 +14,7 @@ import {
ReceiptText,
ShieldCheck,
Star,
Swords,
Table2,
TicketCheck,
TicketPercent,
@@ -52,6 +53,7 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'game-management': Swords,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -168,6 +168,16 @@ test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('后台游戏管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-management',
label: '游戏管理',
hash: '#game-management',
});
expect(resolveAdminRoute('#game-management')).toBe('game-management');
expect(routeHash('game-management')).toBe('#game-management');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
+2
View File
@@ -17,6 +17,7 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'game-management'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = {
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
cumulativeRechargedCents: 128800,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
@@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('¥1288.00')).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() =>
@@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
cumulativeRechargedCents: null,
});
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
expect(screen.getByText('累计充值')).toBeTruthy();
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
'未知',
);
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
@@ -364,6 +364,7 @@ export function AdminUserDetailDialog({
<th></th>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
@@ -377,11 +378,13 @@ export function AdminUserDetailDialog({
</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>{order.productTitle || order.productId}</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
{order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付'}
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>{order.pointsDelta} </td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
@@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{typeof detail.cumulativeRechargedCents === 'number'
? formatMoney(detail.cumulativeRechargedCents)
: '未知'}
</dd>
</div>
<div>
<dt></dt>
<dd>
@@ -339,7 +339,10 @@ test.each([409, 503])(
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
expect(document.activeElement).toBe(feedback);
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
await waitFor(() => expect(document.activeElement).toBe(feedback));
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);
@@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = {
metrics: {
generatedAssets: 12,
consumedMudPoints: 88,
refundedMudPoints: 24,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
newUserPaymentConversion: {
@@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(await screen.findByText('总计数据')).toBeTruthy();
expect(screen.getByText('时段数据')).toBeTruthy();
expect(screen.getByText('本日生产素材数')).toBeTruthy();
expect(screen.getByText('本日消耗泥点数')).toBeTruthy();
expect(screen.getByText('本日退还泥点数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
@@ -128,6 +128,12 @@ export function AdminDashboardPage({
value: metrics?.consumedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'refunded-mud-points',
label: `${rangePrefix(granularity)}退还泥点数`,
value: metrics?.refundedMudPoints ?? 0,
unit: '泥点',
},
{
id: 'new-registered-users',
label: `${rangePrefix(granularity)}新增用户数`,
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
@@ -9,10 +15,7 @@ import {
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
@@ -53,23 +56,7 @@ beforeEach(() => {
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
@@ -83,9 +70,10 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
expect(screen.queryByLabelText('发行入口')).toBeNull();
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
expect(screen.queryByLabelText('拒绝理由')).toBeNull();
expect(screen.queryByLabelText('下架原因')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
@@ -99,7 +87,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
@@ -108,7 +95,12 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
test('点击拒绝后填写理由再提交审核接口', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'rejected', reviewReason: '运行时报错' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -118,12 +110,32 @@ test('缺少拒绝理由时不调用审核接口', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
const reasonInput = within(dialog).getByRole('textbox', {
name: '拒绝理由',
});
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
fireEvent.change(reasonInput, { target: { value: '运行时报错' } });
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [, versionId, , payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(versionId).toBe('version-1');
expect(payload).toEqual({
decision: 'reject',
expectedPublicationRevision: 4,
reviewReason: '运行时报错',
});
});
test('安全下架需要二次确认并携带公开修订号与原因', async () => {
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
@@ -142,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
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(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await screen.findByText('确认操作');
const confirmDialog = screen.getByRole('dialog');
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
@@ -168,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
test('取消理由输入时不做审核操作', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('取消安全下架确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -178,8 +212,20 @@ test('取消确认时不下架', async () => {
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
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: '继续下架' }),
);
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();
@@ -1,3 +1,4 @@
import { Modal, TextField } from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps {
onUnauthorized: (message?: string) => void;
}
interface ReviewReasonPrompt {
decision: 'reject' | 'suspend';
entry: AdminGameDistributionReviewEntry;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
@@ -47,26 +53,6 @@ function createReviewIdempotencyKey(versionId: string) {
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
@@ -78,15 +64,11 @@ export function AdminGameDistributionReviewPage({
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
null,
);
const [reasonDraft, setReasonDraft] = useState('');
const [reasonError, setReasonError] = useState('');
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
@@ -110,16 +92,10 @@ export function AdminGameDistributionReviewPage({
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
reason = '',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
const trimmedReason = reason.trim();
if (decision === 'reject' && !trimmedReason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
@@ -135,12 +111,11 @@ export function AdminGameDistributionReviewPage({
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
reviewReason: trimmedReason,
},
);
setStatusMessage(
@@ -160,8 +135,11 @@ export function AdminGameDistributionReviewPage({
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
async function suspendGame(
entry: AdminGameDistributionReviewEntry,
reason: string,
) {
const trimmedReason = reason.trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
@@ -177,11 +155,10 @@ export function AdminGameDistributionReviewPage({
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
...(trimmedReason ? { reason: trimmedReason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
@@ -190,6 +167,39 @@ export function AdminGameDistributionReviewPage({
}
}
function openReasonPrompt(
entry: AdminGameDistributionReviewEntry,
decision: ReviewReasonPrompt['decision'],
) {
setReasonDraft('');
setReasonError('');
setReasonPrompt({ decision, entry });
}
function closeReasonPrompt() {
setReasonPrompt(null);
setReasonDraft('');
setReasonError('');
}
function confirmReasonPrompt() {
if (!reasonPrompt) return;
const reason = reasonDraft.trim();
if (reasonPrompt.decision === 'reject' && !reason) {
setReasonError('拒绝审核必须填写理由');
return;
}
const { decision, entry } = reasonPrompt;
closeReasonPrompt();
if (decision === 'reject') {
void submitReview(entry, 'reject', reason);
return;
}
void suspendGame(entry, reason);
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -268,25 +278,6 @@ export function AdminGameDistributionReviewPage({
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
@@ -295,55 +286,19 @@ export function AdminGameDistributionReviewPage({
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
onClick={() => openReasonPrompt(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId
? '正在下架…'
@@ -359,6 +314,52 @@ export function AdminGameDistributionReviewPage({
</div>
) : null}
</div>
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
closeLabel="关闭理由输入"
onClose={closeReasonPrompt}
size="sm"
className="genarrative-ui"
footer={
<div className="admin-confirm-actions" style={{ width: '100%' }}>
<button
type="button"
className="admin-secondary-button"
onClick={closeReasonPrompt}
>
</button>
<button
type="button"
className={
reasonPrompt.decision === 'reject'
? 'admin-ghost-button'
: 'admin-danger-button'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</button>
</div>
}
>
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
value={reasonDraft}
error={reasonError}
rows={4}
onChange={(event) => {
setReasonDraft(event.target.value);
if (reasonError) setReasonError('');
}}
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</section>
);
@@ -0,0 +1,265 @@
/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { AdminGameManagementPage } from './AdminGameManagementPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionGames: vi.fn(),
restoreAdminGameDistributionGame: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const version: AdminGameDistributionGameVersionEntry = {
versionId: 'version-3',
gameId: 'game_1',
versionNumber: 3,
status: 'published',
reviewReason: '测试原因',
packageBytes: 2048,
packageSha256:
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
reviewedAt: '2026-09-20T09:00:00Z',
publishedAt: '2026-09-20T10:00:00Z',
entryUrl: 'https://game.example.com/game_1',
};
const publishedGame: AdminGameDistributionGameEntry = {
gameId: 'game_1',
title: '测试游戏',
author: {
id: 'user_1',
name: '作者甲',
avatarUrl: 'https://example.com/avatar.png',
},
status: 'published',
versionCount: 21,
playCount: 345,
activeVersionId: 'version-3',
publicationRevision: 4,
createdAt: '2026-09-18T08:00:00Z',
updatedAt: '2026-09-20T10:00:00Z',
versions: [version],
};
const suspendedGame: AdminGameDistributionGameEntry = {
gameId: 'game_2',
title: '下架游戏',
author: {
id: 'user_2',
name: '作者乙',
avatarUrl: null,
},
status: 'suspended',
versionCount: 2,
playCount: 8,
activeVersionId: null,
publicationRevision: 7,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-21T08:00:00Z',
versions: [],
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionGames)
.mockReset()
.mockResolvedValue({ games: [publishedGame] });
vi.mocked(restoreAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: suspendedGame.gameId,
title: suspendedGame.title,
status: 'published',
publicationRevision: 8,
},
replayed: false,
});
vi.mocked(suspendAdminGameDistributionGame)
.mockReset()
.mockResolvedValue({
game: {
id: publishedGame.gameId,
title: publishedGame.title,
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('列表展示作者头像、状态、版本数和游玩数', async () => {
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
games: [publishedGame, suspendedGame],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!;
expect(within(publishedRow).getByText('作者甲')).toBeTruthy();
const avatar = within(publishedRow).getByRole('img', {
name: '作者甲 头像',
});
expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png');
expect(within(publishedRow).getByText('已公开')).toBeTruthy();
expect(within(publishedRow).getByText('21')).toBeTruthy();
expect(within(publishedRow).getByText('345')).toBeTruthy();
const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!;
expect(within(suspendedRow).getByText('作者乙')).toBeTruthy();
expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe(
'作',
);
expect(within(suspendedRow).getByText('已下架')).toBeTruthy();
});
test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => {
vi.mocked(listAdminGameDistributionGames)
.mockResolvedValueOnce({ games: [suspendedGame] })
.mockResolvedValueOnce({
games: [{ ...suspendedGame, status: 'published' }],
});
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('下架游戏');
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_2');
expect(String(idempotencyKey)).toContain('game_2');
expect(payload).toEqual({ expectedPublicationRevision: 7 });
await waitFor(() =>
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
);
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
});
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('测试游戏');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '违规内容' },
});
fireEvent.click(screen.getByRole('button', { name: '下架' }));
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
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('游戏《测试游戏》已下架')).toBeTruthy();
});
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');
expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy();
expect(within(dialog).getByText('v3')).toBeTruthy();
expect(within(dialog).getByText('published')).toBeTruthy();
expect(within(dialog).getByText('2.0 KiB')).toBeTruthy();
expect(within(dialog).getByText('abcdef012345')).toBeTruthy();
expect(within(dialog).getByText('测试原因')).toBeTruthy();
expect(
within(dialog).getByText('https://game.example.com/game_1'),
).toBeTruthy();
});
test('接口失败显示错误文案', async () => {
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
new Error('游戏列表读取失败'),
);
render(
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('游戏列表读取失败')).toBeTruthy();
});
test('401 交给 onUnauthorized 处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
Object.assign(new Error('未授权'), { status: 401 }),
);
render(
<AdminGameManagementPage
token="admin-token"
onUnauthorized={onUnauthorized}
/>,
);
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
expect(screen.queryByRole('alert')).toBeNull();
});
@@ -0,0 +1,456 @@
import { RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionGames,
restoreAdminGameDistributionGame,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameEntry,
AdminGameDistributionGameVersionEntry,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameManagementPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const GAME_STATUS_META: Record<string, { label: string; className: string }> = {
published: { label: '已公开', className: 'admin-status-ok' },
suspended: { label: '已下架', className: 'admin-status-error' },
unpublished: { label: '未公开', className: 'admin-status-pending' },
};
function gameStatusMeta(status: string) {
return (
GAME_STATUS_META[status] ?? {
label: status || '—',
className: 'admin-status-pending',
}
);
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function formatOptionalTime(value: string | null) {
return value ? formatTime(value) : '—';
}
function authorName(entry: AdminGameDistributionGameEntry) {
return entry.author?.name?.trim() || '—';
}
function authorInitial(entry: AdminGameDistributionGameEntry) {
const name = authorName(entry);
return name === '—' ? '—' : (Array.from(name)[0] ?? '—');
}
function createGameActionIdempotencyKey(
action: 'suspend' | 'restore',
gameId: string,
) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore';
return `${prefix}-${gameId}-${random}`.slice(0, 128);
}
export function AdminGameManagementPage({
token,
onUnauthorized,
}: AdminGameManagementPageProps) {
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [versionGame, setVersionGame] =
useState<AdminGameDistributionGameEntry | null>(null);
const writeConfirm = useAdminWriteConfirm();
const loadGames = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionGames(token, {
limit: 50,
});
setGames(response.games);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadGames();
}, [loadGames]);
useEffect(() => {
if (!versionGame) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setVersionGame(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [versionGame]);
async function suspendGame(entry: AdminGameDistributionGameEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
if (!reason) {
setErrorMessage('下架原因不能为空');
setStatusMessage('');
return;
}
const confirmed = await writeConfirm.confirmWrite({
action: '下架游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('suspend', entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
reason,
},
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: '',
}));
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
async function restoreGame(entry: AdminGameDistributionGameEntry) {
const confirmed = await writeConfirm.confirmWrite({
action: '恢复游戏',
target: `${entry.title || entry.gameId}${entry.gameId}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await restoreAdminGameDistributionGame(
token,
entry.gameId,
createGameActionIdempotencyKey('restore', entry.gameId),
{ expectedPublicationRevision: entry.publicationRevision },
);
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
await loadGames();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadGames()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {games.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && games.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th>gameId</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{games.map((entry) => {
const status = gameStatusMeta(entry.status);
const isSuspended = entry.status === 'suspended';
const busy = busyGameId === entry.gameId;
return (
<tr key={entry.gameId}>
<td>
<strong>{entry.title?.trim() || '—'}</strong>
</td>
<td>
<div
className="admin-database-user-cell"
style={{ justifyContent: 'flex-start' }}
>
<div
className="admin-user-avatar"
style={{
width: 32,
height: 32,
flex: '0 0 32px',
fontSize: 13,
}}
>
{entry.author?.avatarUrl ? (
<img
alt={`${authorName(entry)} 头像`}
src={entry.author.avatarUrl}
/>
) : (
authorInitial(entry)
)}
</div>
<div>
<span>{authorName(entry)}</span>
<small>{entry.author?.id?.trim() || '—'}</small>
</div>
</div>
</td>
<td>
<code>{entry.gameId}</code>
</td>
<td>
<span className={`admin-status ${status.className}`}>
{status.label}
</span>
</td>
<td>{entry.versionCount}</td>
<td>{entry.playCount}</td>
<td>
<div className="admin-action-row">
{!isSuspended ? (
<>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.gameId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.gameId}`}
value={
suspendReasonByGame[entry.gameId] ?? ''
}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy}
onClick={() => void suspendGame(entry)}
>
{busy ? '处理中…' : '下架'}
</button>
</>
) : (
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void restoreGame(entry)}
>
{busy ? '处理中…' : '恢复'}
</button>
)}
<button
type="button"
className="admin-secondary-button"
disabled={busy}
onClick={() => setVersionGame(entry)}
>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{versionGame ? (
<div
className="admin-confirm-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setVersionGame(null);
}
}}
>
<section
aria-labelledby="admin-game-version-history-title"
aria-modal="true"
className="admin-detail-panel"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h2 id="admin-game-version-history-title"></h2>
<span>
{versionGame.title?.trim() || '—'}{versionGame.gameId}
</span>
</div>
<button
aria-label="关闭版本历史"
className="admin-ghost-button"
type="button"
onClick={() => setVersionGame(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<p className="admin-muted-text">
{versionGame.versionCount} 20
</p>
{versionGame.versions.length === 0 ? (
<p className="admin-muted-text"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>SHA</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{versionGame.versions.map((version) => (
<GameVersionRow
key={version.versionId}
version={version}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
) : null}
{writeConfirm.confirmDialog}
</section>
);
}
function GameVersionRow({
version,
}: {
version: AdminGameDistributionGameVersionEntry;
}) {
return (
<tr>
<td>v{version.versionNumber}</td>
<td>{version.status || '—'}</td>
<td>{formatBytes(version.packageBytes)}</td>
<td>
<code>{version.packageSha256?.slice(0, 12) || '—'}</code>
</td>
<td>{formatOptionalTime(version.createdAt)}</td>
<td>{formatOptionalTime(version.reviewedAt)}</td>
<td>{formatOptionalTime(version.publishedAt)}</td>
<td>{version.reviewReason?.trim() || '—'}</td>
<td>
{version.entryUrl ? (
<a href={version.entryUrl} rel="noreferrer" target="_blank">
{version.entryUrl}
</a>
) : (
'—'
)}
</td>
</tr>
);
}
@@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = {
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
paidAmountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
@@ -129,6 +130,40 @@ beforeEach(() => {
);
});
test('未支付订单不显示实付金额,发放泥点单独成列', async () => {
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
entries: [
{
...baseOrder,
orderId: 'order-pending',
status: 'pending',
paidAtMicros: null,
paidAmountCents: 0,
pointsDelta: 0,
},
{ ...baseOrder, orderId: 'order-paid' },
],
});
renderPage();
expect(
await screen.findByRole('columnheader', { name: '实付' }),
).toBeTruthy();
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
const unpaidRow = (await screen.findByText('order-pending')).closest(
'tr',
) as HTMLElement;
const unpaidCells = within(unpaidRow).getAllByRole('cell');
expect(unpaidCells[3]?.textContent).toContain('未支付');
expect(unpaidCells[4]?.textContent).toBe('0 泥点');
const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement;
const paidCells = within(paidRow).getAllByRole('cell');
expect(paidCells[3]?.textContent).toBe('¥6.00');
expect(paidCells[4]?.textContent).toBe('60 泥点');
});
test('充值订单查询传递全部筛选字段', async () => {
const user = userEvent.setup();
renderPage();
@@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({
<th></th>
<th></th>
<th></th>
<th> / </th>
<th></th>
<th></th>
<th>退</th>
<th></th>
<th></th>
@@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
<div className="admin-refund-summary-grid">
<Metric
label="订单实付"
value={formatMoney(refundOrder.amountCents)}
value={formatMoney(refundOrder.paidAmountCents)}
/>
<Metric
label="累计已退"
@@ -1118,8 +1119,13 @@ function RechargeOrderRow({
</small>
</td>
<td>
<strong>{formatMoney(order.amountCents)}</strong>
<small> {order.pointsDelta} </small>
<strong>{formatOrderPaidAmount(order)}</strong>
{order.paidAmountCents > 0 ? null : (
<small> {formatMoney(order.amountCents)}</small>
)}
</td>
<td>
<span>{order.pointsDelta} </span>
</td>
<td>
<span> {formatMoney(order.cumulativeSuccessRefundCents)}</span>
@@ -1312,6 +1318,13 @@ function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */
function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) {
return order.paidAmountCents > 0
? formatMoney(order.paidAmountCents)
: '未支付';
}
function formatCentsInput(cents: number) {
return (cents / 100).toFixed(2);
}
@@ -217,7 +217,7 @@ export function AdminRedeemCodePage({
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<span></span>
<input
min={1}
step={1}
@@ -319,7 +319,7 @@ export function AdminRedeemCodePage({
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -337,7 +337,7 @@ export function AdminRedeemCodePage({
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.rewardPoints} </td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
-10
View File
@@ -15,20 +15,10 @@
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
"swarm": "node scripts/run-cli-with-config.mjs --swarm-chat",
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
"agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery",
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
"agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry",
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
File diff suppressed because it is too large Load Diff
@@ -1,69 +1,8 @@
import { createHash } from '../dependencies.mjs';
import { isPlainObject } from '../harness/config.mjs';
import {
interactiveCliOutput,
writeInteractiveCliLine,
} from '../harness/process.mjs';
import {
shutdownWaiters,
state,
userInputAnswerText,
} from '../runtime-state.mjs';
import { shutdownWaiters, state } from '../runtime-state.mjs';
import { disposableProjectPathVariants } from './runtime.mjs';
export async function answerRemainingInteractiveQuestions(session) {
let answeredPromptCount = 1;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (output.includes(`[\u5df2\u56de\u7b54] ${state.userInput.requestId}`))
return;
const promptCount = output.split('或直接输入其他答案:').length - 1;
while (answeredPromptCount < promptCount && answeredPromptCount < 3) {
writeInteractiveCliLine(session, userInputAnswerText);
answeredPromptCount += 1;
}
if (output.includes('[待确认]')) {
throw codedError('user-input-unexpected-tool-confirmation');
}
if (session.closed) throw codedError('user-input-cli-closed-before-answer');
await sleep(100);
}
throw codedError('user-input-answer-timeout');
}
export function parseSingleSwarmTurnReport(output, codePrefix) {
const reportLines = output
.split(/\r?\n/u)
.filter((line) => line.startsWith('[turn.report] '));
assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`);
const report = JSON.parse(reportLines[0].slice('[turn.report] '.length));
assert(
isPlainObject(report) &&
JSON.stringify(Object.keys(report).sort()) ===
JSON.stringify(
[
'schemaVersion',
'outcome',
'parentAgentId',
'sessionId',
'parentRunId',
'runtimeCount',
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'newAssistantMessageCount',
'finalReplyChars',
'reconciliationAgentCount',
].sort(),
),
`${codePrefix}-turn-report-shape-invalid`,
);
return report;
}
export function isFailedTask(task) {
return (
['failed', 'cancelled', 'budget-exhausted'].includes(task.status) ||

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