Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eed72ba39a | |||
| aa3e29f04e | |||
| 55259683b8 | |||
| 5b4f9e961d | |||
| 973e965f4f | |||
| 573f9f447a | |||
| 5b73082ad0 | |||
| a0d432b63d | |||
| 6fb17de9db | |||
| 1e17d2c852 | |||
| ca80cb28d2 |
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
-511
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+24
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
+171
-16
@@ -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 ? (
|
||||
|
||||
+26
-1
@@ -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
Reference in New Issue
Block a user