Compare commits

..

11 Commits

Author SHA1 Message Date
suzmii eed72ba39a Merge branch 'master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 18s
Project CI / Native shell tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 18s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
2026-09-21 00:38:08 +08:00
suzmii aa3e29f04e 修掉生图弹窗点输入框就弹素材选择框:label 转发点击到 @ 按钮
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
- 现象:点生成提示词输入框任意位置都会弹出「选择素材」。根因是这一格用 `<label>` 包住,而 `<label>` 会把点击转发给内部第一个可标注控件——正好是引用输入区里的「插入素材引用」按钮
- 处理:该格改为 `<div>` 包裹;可访问名仍由控件自身的 aria-label 提供(引用输入区 `ariaLabel` 与 `PlatformTextField` 的 `aria-label`),不依赖 label 关联
- 用例:`resourceCanvasAssetGenerationReferences` 新增「点提示词输入区不该自己弹出素材选择框,只有 @ 按钮才开」——先用旧代码复现出红灯再修
- 顺带扫过资源工作台其余 `<label>`:都只包纯 input / textarea(音频与视频生成面板用纯 textarea,没有 @ 按钮),无同类隐患
- 待办文档补「2026-09-21 第三批」:卡片浮层提交即关、动画面板样式整档照抄与守卫升级、本条 label 修复
2026-09-21 00:37:08 +08:00
suzmii 55259683b8 删除角色动画面板里重复回显提示词的「动画描述」行
- 共享组件 ImageCanvasCharacterAnimationPanelView 不再渲染只读的生成文本回显(与上方输入区内容重复,且两个宿主都没有为它定义样式)
- 用例同步:原断言「生成文本:…」改为钉住该节点不再渲染,并保留「输入区确实收下这段文本」的检查
2026-09-21 00:28:26 +08:00
suzmii 5b4f9e961d 按美术画布原样补齐动画面板样式:关闭键落位、提示词态与 760px 整档
- 补 `.image-canvas-editor__generation-close` 的第二条同名规则(浮层内绝对定位):上一版只搬了基础外观,关闭键因此掉进网格流、独自占一行并居中
- 补提示词输入区的聚焦态与 aria-invalid 两档,以及 composer 内参数簇的透明底色与 hover / 展开态
- 新增 `@media (max-width: 760px)` 整档照抄(浮层落底部整宽、composer 定宽、动作竖排、参数与提交整宽),替换上一版按 720px 手写的那一小段,选择器与断点都与网页端逐字一致
- 守卫升级为「选择器 + 声明」逐条比对:同名规则只搬第一条不再判过;范围限定在 `image-canvas-editor__` 命名空间(共享 chrome 类与全局 reset 不归宿主 chrome 表照抄)
2026-09-21 00:26:31 +08:00
suzmii 973e965f4f 卡片浮层改为提交即关:快速编辑 / 生成动画只负责交任务
- 快速编辑与生成动画点提交当帧即关面板(不等 IPC、不留等待态),进度、阶段与结果只由「生成任务」侧栏承载,与图片类生成面板同一口径
- 受理之后的失败不再重开面板:侧栏那条收口为失败 + 提示条,草稿回落成「未完成编辑」等用户重开;只有点击瞬间就失败(名称预检、源素材不在画布、无客户端)留在面板里
- 派生请求身份改为按「入口 + 资源路径」记忆:重开面板再提交时提示词没变就沿用同一 operationId / 幂等键,命中同一 operation 账本;派生成成功后清除
- 删除 canDismissResourceCanvasQuickEdit 与宿主里只为它服务的两块面板状态 ref,clearResourceCanvasFocus 回到「清选中 + 关两块浮层」的直线逻辑
- 三条既有用例按新口径改写为「重开面板再重试,身份不变」;PRD §3.10 与 decision-log 同步
2026-09-21 00:19:42 +08:00
suzmii 573f9f447a 补齐 AGC「生成动画」面板的宿主样式并加样式缺口守卫
- resourceCanvasChrome.css 逐条照抄 src/index.css 的角色动画面板规则:面板 / 提示词 textarea / 动作预设 / 底部动作行 / 提交按钮,以及关闭键、素材缩略图与 reference-chip 全族
- 窄屏(≤720px)与网页端同口径:面板落到底部整宽、动作竖排、提交按钮整宽
- 新增 tests/resourceCanvasCharacterAnimationPanelStyle.test.tsx:渲染共享动画面板,按 src/main.tsx 的真实 import 关系解析 AGC 实际加载的样式表,断言「网页端给出规则的类 AGC 必须也有一条」并抽查撑开布局的关键声明
- 变异验证:临时移除本次补的样式块,守卫用例列出缺规则的 15 个类并红灯
2026-09-20 23:48:53 +08:00
suzmii 5b73082ad0 补齐画布验收第二批:派生任务进生成任务侧栏、提交中让出草稿与重复提交防护
- 新增 resourceCanvasResourceEditTaskModel:原生资源编辑阶段到面板状态/文案的映射,本地提交记录与原生待办按 operationId 合并,派生任务的终态与耗时判据
- 「生成任务」侧栏同时收派生/修改类任务:与图片类生成任务同一条列表、同一套分栏与状态徽标,多一行提示词,样式沿用既有卡片类
- 快速编辑、生成动画、视频/音效/背景音乐三条派生提交:按下提交当帧写入本地记录并在侧栏可见,成功后收口为已完成、失败带原因并重读原生待办
- 「管理未完成编辑」不再列出正在提交的那一笔草稿,失败后自动回落,提示词与 @ 引用不丢
- 同一张素材存在在途提交时,重新打开面板再提交会被拦下并给出原因,避免重复派生与重复计费
- 侧栏与工具条入口的在途计数把派生任务一并计入
- 新增 tests/resourceCanvasResourceEditTasks.test.ts(9 条);resourceCanvasQuickEditDraft.test.tsx 补两组宿主用例(提交当帧进侧栏并收口、在途重复提交被拦),两处新判据均做过变异验证
- 同步 PRD 派生任务侧栏口径、decision-log 与画布验收待办文档
2026-09-20 23:04:13 +08:00
suzmii a0d432b63d Merge remote-tracking branch 'origin/master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 6s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
2026-09-20 21:27:23 +08:00
suzmii 6fb17de9db Merge remote-tracking branch 'origin/master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / AI game creator shell web tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
2026-09-20 19:25:45 +08:00
suzmii 1e17d2c852 补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口
润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化
共享聊天输入区显示润色提示,并让提示随用户手改提示词失效
快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉
恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目
账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用
派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘
名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮
浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉
恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式
空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码
补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离
同步 decision-log 与画布验收待办的进展说明
2026-09-20 17:36:02 +08:00
suzmii ca80cb28d2 登记首批合并后的画布验收遗留问题
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m9s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m2s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / Backend tests (pull_request) Successful in 11m35s
Project CI / Frontend tests (pull_request) Successful in 11m42s
Project CI / Repository checks (pull_request) Successful in 11m40s
Project CI / Native shell tests (pull_request) Successful in 14m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 9m39s
从master已合并的PR412建立后续修复范围。
区分动画与编辑等缺陷、卡片与任务浮层需求及未验证风险,不宣称已修复。
2026-09-18 15:57:32 +08:00
180 changed files with 4750 additions and 22355 deletions
-2
View File
@@ -213,8 +213,6 @@ module.exports = {
'!src/services/clipboard.test.ts',
'!src/services/frontendRuntimeConfigService.ts',
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
@@ -5,12 +5,9 @@ import {
executeAdminRechargeRefund,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
@@ -367,134 +364,3 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
-72
View File
@@ -29,9 +29,6 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -1179,72 +1176,3 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
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<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
-45
View File
@@ -1031,48 +1031,3 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
-7
View File
@@ -26,7 +26,6 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -301,12 +300,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
-2
View File
@@ -5,7 +5,6 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -50,7 +49,6 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -147,24 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
-2
View File
@@ -15,7 +15,6 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -55,7 +54,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -1,186 +0,0 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
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 : '请求失败',
),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
versionNumber: 2,
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
});
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 () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [token, versionId, idempotencyKey, payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(versionId).toBe('version-1');
expect(String(idempotencyKey)).toContain('version-1');
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
2,
),
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
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(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
@@ -1,365 +0,0 @@
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
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 createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-suspend-${gameId}-${random}`.slice(0, 128);
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
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,
}: AdminGameDistributionReviewPageProps) {
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
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 [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadReviews();
}, [loadReviews]);
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
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) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
setBusyVersionId(entry.versionId);
setErrorMessage('');
setStatusMessage('');
try {
await reviewAdminGameDistributionVersion(
token,
entry.versionId,
createReviewIdempotencyKey(entry.versionId),
decision === 'approve'
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
},
);
setStatusMessage(
decision === 'approve'
? `版本 v${entry.versionNumber} 已通过审核`
: `版本 v${entry.versionNumber} 已拒绝`,
);
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyVersionId('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} 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 loadReviews()}
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"> {entries.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<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"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
</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')}
>
</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)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -16,8 +16,7 @@
{ "url": "https://www.genarrative.world/api/*" },
{ "url": "https://*/api/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" },
{ "url": "https://*.aliyuncs.com/*" }
{ "url": "http://127.0.0.1:*/*" }
]
},
"opener:default",
@@ -5933,16 +5933,6 @@ pub(crate) fn export_local_project_package(
export_local_project_package_at(root)
}
#[tauri::command]
pub(crate) fn read_local_project_export_package(
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
@@ -2692,7 +2692,6 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
@@ -1,27 +1,5 @@
use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Cursor, Read};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackageFileDigest {
pub(crate) path: String,
pub(crate) size_bytes: u64,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackagePayload {
pub(crate) package_relative_path: String,
pub(crate) package_bytes: Vec<u8>,
pub(crate) package_sha256: String,
pub(crate) package_size_bytes: u64,
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
}
pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
@@ -132,114 +110,6 @@ pub(crate) fn export_local_project_package_at(
})
}
/// Read a previously exported package for the explicit AGC publish flow.
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
) -> Result<LocalProjectExportPackagePayload, String> {
validate_project_root(root)?;
let normalized = normalize_export_package_entry_path(package_relative_path)?;
if !normalized.starts_with("exports/playtest-package-")
|| !normalized.ends_with(".zip")
|| normalized.contains('/') && normalized.split('/').count() != 2
{
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
}
let package_path = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
if !metadata.is_file() {
return Err("发行包必须是普通文件".to_string());
}
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("发行包大小超出本地发布上限".to_string());
}
let package_bytes =
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
if package_bytes.len() as u64 != metadata.len() {
return Err("发行包在读取期间发生变化,请重新导出".to_string());
}
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
let mut entries = Vec::with_capacity(archive.len());
let mut seen = BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let source_path = normalize_export_package_entry_path(entry.name())?;
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
let path = source_path
.strip_prefix("game/")
.unwrap_or(source_path.as_str())
.to_string();
let path = normalize_export_package_entry_path(&path)?;
if !seen.insert(path.clone()) {
return Err(format!("发行包包含重复条目:{path}"));
}
let expected_size = entry.size();
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
entry
.read_to_end(&mut content)
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
if content.len() as u64 != expected_size {
return Err(format!("发行包条目长度不一致:{path}"));
}
entries.push((path, content));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if entries.is_empty() {
return Err("发行包没有可上传文件".to_string());
}
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, content) in &entries {
normalized_writer
.start_file(path, options)
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
normalized_writer
.write_all(content)
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
}
let normalized_cursor = normalized_writer
.finish()
.map_err(|error| format!("完成发行包失败:{error}"))?;
let package_bytes = normalized_cursor.into_inner();
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("归一化发行包大小超出本地发布上限".to_string());
}
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
let files = entries
.into_iter()
.map(|(path, content)| LocalProjectExportPackageFileDigest {
size_bytes: content.len() as u64,
sha256: format!("{:x}", Sha256::digest(&content)),
path,
})
.collect::<Vec<_>>();
if !files.iter().any(|file| file.path == "index.html") {
return Err("归一化发行包缺少根 index.html".to_string());
}
Ok(LocalProjectExportPackagePayload {
package_relative_path: normalized,
package_size_bytes: package_bytes.len() as u64,
package_bytes,
package_sha256,
files,
})
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis();
for suffix in 0..1000 {
@@ -3906,54 +3906,6 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let exported = export_local_project_package_at(&root).expect("export package");
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
.expect("read publish payload");
assert_eq!(
payload.package_relative_path,
exported.package_relative_path
);
assert_eq!(
payload.package_size_bytes,
payload.package_bytes.len() as u64
);
assert_eq!(payload.files.len(), 2);
assert!(payload.files.iter().any(|file| file.path == "index.html"));
assert!(payload
.files
.iter()
.any(|file| file.path == "exports/README.md"));
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes))
.expect("read normalized package");
let names = (0..archive.len())
.map(|index| {
archive
.by_index(index)
.expect("normalized entry")
.name()
.to_string()
})
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name == "index.html"));
assert!(!names.iter().any(|name| name.starts_with("game/")));
assert_eq!(payload.package_sha256.len(), 64);
assert!(payload
.package_sha256
.chars()
.all(|value| value.is_ascii_hexdigit()));
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path();
-32
View File
@@ -97,7 +97,6 @@ import type {
TauriInvoke,
UploadLocalAssetResult,
} from './app/types';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import { useWindowChrome } from './components/windowChromeContext';
import {
agentConversationId,
@@ -1071,9 +1070,6 @@ export function App({
const [filePath, setFilePath] = useState('game/index.html');
const [fileDraft, setFileDraft] = useState('');
const [fileStatus, setFileStatus] = useState('未读取');
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
const [agentRunTrace, setAgentRunTrace] =
useState<GameCreationAgentRunTrace | null>(null);
const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>(
@@ -7487,8 +7483,6 @@ export function App({
{ projectPath: nextProjectPath },
);
setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
setCommandLog((current) => [...current, 'project.export_package']);
void refreshManifest(nextProjectPath);
if (announceToChat) {
@@ -11791,15 +11785,6 @@ export function App({
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
versions={chatProjectVersions}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={supervisorProjectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -11985,15 +11970,6 @@ export function App({
onProfessionalToolAction={handleProjectProfessionalAgentToolAction}
onProfessionalRetry={handleProjectProfessionalAgentRetry}
onUserInput={handleProjectSupervisorUserInput}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -12101,14 +12077,6 @@ export function App({
workspaceStatus={workspaceStatus}
/>
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={localProject?.projectPath}
@@ -794,20 +794,6 @@ export interface LocalProjectExportPackageResult {
totalBytes: number;
}
export interface LocalProjectExportPackageFileDigest {
path: string;
sizeBytes: number;
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
}
export interface LocalProjectExportPackageSummary {
packagePath: string;
packageRelativePath: string;
@@ -150,8 +150,6 @@ function AgentReasoning({
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
type ProjectSupervisorViewProps = RuntimePanelProps & {
/** 面板层:发布等业务面板以 portal 形式挂到 supervisor 视图内部。 */
overlay?: ReactNode;
activeVersionId?: string | null;
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
attachments?: DirectCodexTurnAttachment[];
@@ -217,7 +215,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
};
export function ProjectSupervisorView({
overlay,
activeVersionId = null,
attachments = [],
attachmentNotice = '',
@@ -898,7 +895,6 @@ export function ProjectSupervisorView({
closeOnEscape={false}
/>
) : null}
{overlay}
</section>
);
}
@@ -759,8 +759,15 @@ function ResourceReferenceEditor({
const reminderDisabledRef = useRef(reminderDisabled);
reminderDisabledRef.current = reminderDisabled;
/**
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
* 否则刚显示出来的提示会被自己的回填立刻清掉。
*/
const appliedPromptRef = useRef<string | null>(null);
const applyPromptText = useCallback(
(text: string) => {
appliedPromptRef.current = text;
flushSync(() => {
onChange({ text, references: liveDraftRef.current.references });
});
@@ -782,13 +789,25 @@ function ResourceReferenceEditor({
const {
polishing,
error: polishError,
notice: polishNotice,
originalText: polishedOriginalText,
polish: runPolish,
restoreOriginal: restoreOriginalPrompt,
clearError: clearPolishError,
clearNotice: clearPolishNotice,
reset: resetPromptPolish,
} = promptPolishState;
useEffect(() => {
if (
appliedPromptRef.current !== null &&
value === appliedPromptRef.current
) {
return;
}
clearPolishNotice();
}, [clearPolishNotice, value]);
const polishPrompt = useCallback(async () => {
await runPolish();
}, [runPolish]);
@@ -1083,13 +1102,16 @@ function ResourceReferenceEditor({
) : null}
</div>
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
{showPolishAction &&
!reminderOpen &&
(polishing || polishError || polishNotice) ? (
<span
className="resource-reference-input-status"
role="status"
aria-live="polite"
>
{polishing ? '润色中…' : polishError}
{polishing ? '润色中…' : (polishError ?? polishNotice)}
</span>
) : null}
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
@@ -2,7 +2,6 @@ import { Send, Settings } from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
ReactNode,
Ref,
RefObject,
UIEvent,
@@ -43,7 +42,6 @@ type RuntimeControlProps = ComponentProps<
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
overlay?: ReactNode;
activeVersionId?: string | null;
chatAgentBusy: boolean;
chatInput: string;
@@ -82,7 +80,6 @@ type SupervisorChatOnlyViewProps = {
};
export function SupervisorChatOnlyView({
overlay,
activeVersionId = null,
chatAgentBusy,
chatInput,
@@ -354,7 +351,6 @@ export function SupervisorChatOnlyView({
onClose={onCloseRuntimeConfig}
/>
) : null}
{overlay}
</>
);
}
@@ -2,6 +2,15 @@ import { useCallback, useRef, useState } from 'react';
import { requestChatPromptPolish } from './chatPromptPolish';
/**
* 回包与原文一字不差时的提示语。
*
* 平台侧有时会把同一句话原样还回来(AGC-004:快速编辑里点「AI 润色」后文案毫无变化,
* 用户以为按钮没反应或已经改过)。这种情况必须给一条说得清的提示,而且**不能**落下
* 原文快照——没有可回退的变化,就不该冒出「恢复原文」这种假入口。
*/
export const PROMPT_POLISH_UNCHANGED_NOTICE = 'AI 润色结果与原文相同,未做修改';
/**
* 润色回填前的规范化结果。
*
@@ -41,14 +50,19 @@ export type UsePromptPolishOptions = {
export type UsePromptPolishResult = {
polishing: boolean;
error: string | null;
/** 最近一次成功回填的截断提示;没有截断时为 null。 */
/** 最近一次成功回包的提示:截断说明,或「与原文相同,未做修改」;两者都没有时为 null。 */
notice: string | null;
/** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */
/**
* 首次**真的改动了文本**的润色时落下的原文快照;非空时宿主渲染「恢复原文」。
* 回包与原文相同的那些次不落快照(没什么可恢复的)。
*/
originalText: string | null;
/** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */
polish: (options?: PromptPolishRunOptions) => Promise<string | null>;
restoreOriginal: () => void;
clearError: () => void;
/** 用户自己改了提示词:把上一轮往返留下的提示(截断 / 与原文相同)收掉。 */
clearNotice: () => void;
/** 清掉往返状态(草稿清空、面板换资源时用)。 */
reset: () => void;
};
@@ -56,6 +70,10 @@ export type UsePromptPolishResult = {
/**
* 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。
*
* 成功但回包与原文逐字相同(规范化前后都没变)时不算「润色出了新东西」:不落原文快照、
* 不回填宿主状态,只给 {@link PROMPT_POLISH_UNCHANGED_NOTICE} 这条明确反馈,
* 免得用户把「按钮点了没反应」误当成功能坏了、或者以为文案已经被改过。
*
* 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件,
* 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧
* `requestChatPromptPolish`),共享 composer 只接一个可选的注入位。
@@ -108,6 +126,18 @@ export function usePromptPolish({
const normalized = normalizeResult
? normalizeResult(polished)
: { text: polished };
if (normalized.text === prompt) {
// 最终要写回宿主的文本与当前文本逐字相同:既没有新内容可回填,也没有可回退的
// 变化,因此不落原文快照、不写宿主状态——`applyPrompt` 在宿主侧还有「提示词变了
// 就重铸请求身份」这类副作用,回填一份没变的文本会平白作废一次请求身份。
//
// 判据必须是**规范化之后**的文本,不能只看回包:润色结果被按长度上限截回原文时
// `truncateResourceEditPrompt` 是切片不是 trim),回包与原文不同、写回去却一字
// 未变,那会渲染出一枚点了等于没点的「恢复原文」,正是要消灭的那种假入口。
// 截断提示优先——它解释的是「这次为什么没变」。
setNotice(normalized.notice ?? PROMPT_POLISH_UNCHANGED_NOTICE);
return normalized.text;
}
// 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。
setOriginalText((current) => current ?? prompt);
setNotice(normalized.notice ?? null);
@@ -150,6 +180,10 @@ export function usePromptPolish({
setError(null);
}, []);
const clearNotice = useCallback(() => {
setNotice(null);
}, []);
const reset = useCallback(() => {
// 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。
requestIdRef.current += 1;
@@ -166,6 +200,7 @@ export function usePromptPolish({
polish,
restoreOriginal,
clearError,
clearNotice,
reset,
};
}
@@ -408,7 +408,13 @@ export function ResourceCanvasAssetGenerationPanelView({
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
</label>
<label>
{/*
这一格**不能**用 `<label>` 包:`<label>` 会把点击转发给内部第一个可标注控件,而这一格里
第一个可标注控件是引用输入区的「插入素材引用」按钮——于是点输入框任意位置都会弹出素材
选择框(客户端验收现场那条)。两边的可访问名都由控件自身的 `aria-label` 提供(引用输入区
的 `ariaLabel` 与 `PlatformTextField` 的 `aria-label`),不依赖 label 关联。
*/}
<div>
<span></span>
{referenceEnabled ? (
/*
@@ -444,7 +450,7 @@ export function ResourceCanvasAssetGenerationPanelView({
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
)}
</label>
</div>
{action.adjustableDimensions ? (
<div className="resource-canvas-asset-generation-dimensions">
<PlatformSegmentedTabs
@@ -12,6 +12,12 @@ import {
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
import {
resourceCanvasResourceEditElapsedLabel,
type ResourceCanvasResourceEditTask,
resourceCanvasResourceEditTaskElapsedMillis,
resourceCanvasResourceEditTaskIsTerminal,
} from './resourceCanvasResourceEditTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
@@ -24,6 +30,14 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
tasks: readonly ResourceCanvasAssetGenerationTask[];
/**
* 派生/修改类任务(快速编辑、生成动画、抠图…)。
*
* 它们不在图片类生成任务的账本里(原生资源编辑账本按 `operationId` 记),但用户眼里都是
* 「我交出去、等着出结果的那件事」,所以进同一个侧栏、同一套分栏;状态与阶段文案各按自己的
* 账本渲染。缺省为空数组:老调用方不传就没有这一段。
*/
resourceEditTasks?: readonly ResourceCanvasResourceEditTask[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
@@ -31,7 +45,53 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
/**
* 侧栏的一行。
*
* 两种来源合并成同一条列表:图片类生成任务按后端账本推进,派生/修改任务按原生资源编辑账本
* 推进;排序与分栏只认「提交时间」和「是否终态」,用户不需要知道它们来自两套账本。
*/
type ResourceCanvasGenerationTaskRow =
| {
readonly source: 'asset-generation';
readonly createdAtMillis: number;
readonly task: ResourceCanvasAssetGenerationTask;
}
| {
readonly source: 'resource-edit';
readonly createdAtMillis: number;
readonly task: ResourceCanvasResourceEditTask;
};
function resourceCanvasGenerationTaskRowKey(
row: ResourceCanvasGenerationTaskRow,
): string {
return row.source === 'asset-generation'
? row.task.taskId
: row.task.operationId;
}
function resourceCanvasGenerationTaskRowIsTerminal(
row: ResourceCanvasGenerationTaskRow,
): boolean {
return row.source === 'asset-generation'
? resourceCanvasAssetGenerationTaskIsTerminal(row.task)
: resourceCanvasResourceEditTaskIsTerminal(row.task);
}
function sortResourceCanvasGenerationTaskRows(
rows: readonly ResourceCanvasGenerationTaskRow[],
): ResourceCanvasGenerationTaskRow[] {
return [...rows].sort(
(left, right) =>
right.createdAtMillis - left.createdAtMillis ||
resourceCanvasGenerationTaskRowKey(left).localeCompare(
resourceCanvasGenerationTaskRowKey(right),
),
);
}
function assetGenerationTaskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
@@ -86,6 +146,79 @@ function taskRow(
);
}
/**
* 派生/修改任务的一行。
*
* 与图片类生成的卡片同一套 class、同一套 tone,只有两点不同:① 没有「定位到素材」——原生账本
* 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上
* 这行是哪一次修改。
*/
function resourceEditTaskRow(
task: ResourceCanvasResourceEditTask,
nowMillis: number,
) {
const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis(
task,
nowMillis,
);
return (
<li
key={task.operationId}
className="game-resource-generation-task-card"
data-task-status={task.status}
data-task-source="resource-edit"
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
{elapsedMillis === null ? null : (
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`}
</span>
)}
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{task.prompt ? (
<p
className="game-resource-generation-task-card-prompt"
title={task.prompt}
>
{task.prompt}
</p>
) : null}
</li>
);
}
function resourceCanvasGenerationTaskRowNode(
row: ResourceCanvasGenerationTaskRow,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
return row.source === 'asset-generation'
? assetGenerationTaskRow(row.task, nowMillis, onFocusTask)
: resourceEditTaskRow(row.task, nowMillis);
}
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
@@ -103,6 +236,7 @@ function taskRow(
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
resourceEditTasks = [],
open,
onToggleOpen,
onFocusTask,
@@ -115,20 +249,33 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
open ? 'entering' : 'idle',
);
const inFlightCount = tasks.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
).length;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
/**
* 两套账本合成一条列表:图片类生成任务(后端生成账本)在前端本地队列里已按提交时间排序,
* 派生/修改任务(原生资源编辑账本)自带提交时间,这里统一按时间倒序,用户看到的就是
* 「我最近交出去的那几件事」。
*/
const ordered = useMemo<ResourceCanvasGenerationTaskRow[]>(
() =>
sortResourceCanvasGenerationTaskRows([
...sortResourceCanvasAssetGenerationTasks(tasks).map((task) => ({
source: 'asset-generation' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
...resourceEditTasks.map((task) => ({
source: 'resource-edit' as const,
createdAtMillis: task.createdAtMillis,
task,
})),
]),
[resourceEditTasks, tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
(row) => !resourceCanvasGenerationTaskRowIsTerminal(row),
);
const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal);
const inFlightCount = active.length;
const hasLiveTask = inFlightCount > 0;
const visibleDone = done.slice(
0,
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
@@ -248,8 +395,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.active.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
)}
@@ -271,8 +422,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
{rendered.visibleDone.map((row) =>
resourceCanvasGenerationTaskRowNode(
row,
nowMillis,
onFocusTask,
),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
@@ -1,4 +1,5 @@
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import {
type LocalProjectResourceEditKind,
@@ -30,14 +31,38 @@ export function ResourcePromptPolishSlot({
disabled = false,
applyPrompt,
}: ResourcePromptPolishSlotProps) {
/**
* 我们自己写回去的那一份文本。用于区分「润色回填导致的 prop 变化」与「用户手改」:
* 只有后者该把上一轮往返留下的提示收掉,否则刚显示出来的提示会被自己的回填清掉。
*/
const appliedPromptRef = useRef<string | null>(null);
// 宿主回调存 ref:包一层只为记账,不该让这份包装每次渲染都换身份、
// 进而把 `usePromptPolish` 的 `polish` 也跟着重造。
const applyPromptRef = useRef(applyPrompt);
applyPromptRef.current = applyPrompt;
const applyPromptAndTrack = useCallback((text: string) => {
appliedPromptRef.current = text;
applyPromptRef.current(text);
}, []);
const polish = usePromptPolish({
readPrompt: () => prompt,
applyPrompt,
applyPrompt: applyPromptAndTrack,
canPolish: () => !disabled,
resolveContext: () => resourceAssetPromptPolishContext(subject),
normalizeResult: (polished) =>
truncateResourceEditPrompt(polished, editKind),
});
const { clearNotice } = polish;
useEffect(() => {
if (
appliedPromptRef.current !== null &&
prompt === appliedPromptRef.current
) {
return;
}
// 手改过提示词:上一轮「与原文相同 / 已截断」的结论不再描述当前这段文本。
clearNotice();
}, [clearNotice, prompt]);
const statusText = polish.polishing
? '润色中…'
: (polish.error ?? polish.notice);

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