Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3

# Conflicts:
#	apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts
#	apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts
#	apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-22 14:08:41 +08:00
1022 changed files with 116346 additions and 74737 deletions
@@ -3,12 +3,15 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -18,6 +21,93 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
+141
View File
@@ -1,5 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -29,9 +30,13 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse,
@@ -45,6 +50,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -83,6 +89,8 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -170,6 +178,8 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -198,6 +208,107 @@ export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
}
export function listAdminProjectSnapshots(
token: string,
query: AdminProjectSnapshotListQuery = {},
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (query.cursor) params.set('cursor', query.cursor);
if (query.channel) params.set('channel', query.channel);
params.set('limit', String(query.limit ?? 20));
return request<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
{ token, signal },
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/admin/api/project-snapshots/channels',
{ token, signal },
);
}
export async function downloadAdminProjectSnapshot(
token: string,
channel: string,
userId: string,
projectId: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (channel) params.set('channel', channel);
const query = params.toString();
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
const response = await fetch(buildRequestUrl(path), {
headers: {
Authorization: `Bearer ${token.trim()}`,
Accept: 'application/zip',
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
},
signal,
});
if (!response.ok) {
const responseText = await response.text();
throw buildAdminApiError(
response,
parseJsonResponse(responseText),
responseText,
);
}
const contentType = response.headers
.get('content-type')
?.split(';')[0]
?.trim()
.toLowerCase();
if (contentType !== 'application/zip') {
await response.body?.cancel();
throw new AdminApiError({
message: '下载失败:服务端未返回 ZIP 工程文件',
status: response.status,
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
});
}
return {
blob: await response.blob(),
filename: projectArchiveFilename(
response.headers.get('content-disposition'),
),
};
}
function projectArchiveFilename(contentDisposition: string | null): string {
const extended = contentDisposition?.match(
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
);
const ordinary = contentDisposition?.match(
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
);
let filename =
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
if (extended?.[1]) {
try {
filename = decodeURIComponent(extended[1].trim());
} catch {
// 非法扩展编码继续使用普通文件名。
}
}
const safeName = Array.from(filename, (character) => {
const code = character.charCodeAt(0);
return code < 32 || code === 127 ? '_' : character;
})
.join('')
.replace(/[<>:"/\\|?*]/g, '_')
.trim()
.replace(/[. ]+$/, '');
if (!safeName || safeName.length > 240) return 'project.zip';
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
}
export function createAdminAccount(
token: string,
payload: AdminCreateAccountRequest,
@@ -1088,3 +1199,33 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
+96
View File
@@ -96,6 +96,36 @@ export interface AdminMeResponse {
admin: AdminSessionPayload;
}
export interface AdminProjectSnapshotEntry {
userId: string;
projectId: string;
projectName: string | null;
syncRevision: number;
syncedAtMs: number;
fileCount: number;
totalBytes: number;
status: 'ready' | 'partial' | 'unverified';
channel: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
}
export interface AdminProjectSnapshotListQuery {
cursor?: string | null;
limit?: number;
channel?: string | null;
}
export interface AdminProjectSnapshotListResponse {
items: AdminProjectSnapshotEntry[];
nextCursor: string | null;
}
export interface AdminProjectSnapshotChannelsResponse {
defaultChannel: string;
channels: string[];
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
@@ -123,6 +153,8 @@ export interface AdminErrorReportListResponse {
}
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
firstFingerprint?: string;
firstSource?: string;
note?: string;
events: Array<Record<string, unknown>>;
logNames: string[];
@@ -1010,3 +1042,67 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
@@ -0,0 +1,148 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from './adminApiClient';
afterEach(() => {
vi.unstubAllGlobals();
});
test('项目列表携带分页与后台授权,解析标准响应', async () => {
const payload = { items: [], nextCursor: 'next' };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(
await listAdminProjectSnapshots(
'admin-token',
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
controller.signal,
),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
signal: controller.signal,
}),
);
});
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/channels',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
);
});
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('PK\u0003\u0004', {
headers: {
'content-type': 'application/zip',
'content-disposition':
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
},
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
const archive = await downloadAdminProjectSnapshot(
'admin-token',
'release',
'user/a',
'project/b',
controller.signal,
);
expect(archive.filename).toBe('三消-r2.zip');
expect(archive.blob.type).toBe('application/zip');
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
Accept: 'application/zip',
}),
signal: controller.signal,
}),
);
});
test.each([
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
['attachment; filename="../secret.zip"', '.._secret.zip'],
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
[null, 'project.zip'],
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
const headers: Record<string, string> = { 'content-type': 'application/zip' };
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
if (header)
headers['content-disposition'] = header.replace('工程', 'project');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('PK', { headers })),
);
expect(
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
.filename,
).toBe(expected.replace('工程', 'project'));
});
test.each([401, 403, 409, 500])(
'下载 HTTP %s 保留后台错误,不返回 ZIP',
async (status) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: false,
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
}),
{
status,
headers: { 'content-type': 'application/json' },
},
),
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({
status,
code: 'SNAPSHOT_FAILURE',
message: '工程尚未同步完成',
});
},
);
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('{"ok":false}', {
headers: { 'content-type': 'application/json' },
}),
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
});
+14
View File
@@ -19,6 +19,7 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -31,6 +32,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
@@ -293,6 +295,12 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
@@ -308,6 +316,12 @@ export function AdminApp() {
{activeRouteId === 'accounts' ? (
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'project-snapshots' ? (
<AdminProjectSnapshotsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+3
View File
@@ -4,6 +4,7 @@ import {
Bug,
Coins,
Database,
FolderArchive,
GitBranch,
Images,
LayoutDashboard,
@@ -49,8 +50,10 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
@@ -122,3 +122,55 @@ test('零权限 member 不回落到 Dashboard', () => {
expect(routes).toEqual([]);
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
});
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
const route = {
id: 'project-snapshots',
label: '项目工程',
hash: '#project-snapshots',
};
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
).toContainEqual(route);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['project-snapshots'],
}),
).toEqual([route]);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}),
).not.toContainEqual(route);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
+4
View File
@@ -16,7 +16,9 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -52,8 +54,10 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
];
+1
View File
@@ -1,3 +1,4 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import {
formatAdminTimestamp,
parseAdminTimestamp,
} from './AdminErrorReportsPage';
describe('错误报告时间格式化', () => {
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
});
it('解析微秒字符串并拒绝无效时间', () => {
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
expect(formatAdminTimestamp('not-a-date')).toBe('-');
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
});
});
@@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
const ERROR_REPORT_PAGE_SIZE = 50;
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
new: '待处理',
'in-progress': '处理中',
resolved: '已解决',
};
export function parseAdminTimestamp(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
const seconds = Number(secondsText);
const micros = Number(microsText);
return Number.isFinite(seconds) && Number.isFinite(micros)
? seconds * 1000 + Math.floor(micros / 1000)
: Number.NaN;
}
if (/^-?\d+$/u.test(normalized)) {
const numeric = Number(normalized);
if (!Number.isFinite(numeric)) return Number.NaN;
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
if (Math.abs(numeric) >= 1e11) return numeric;
return numeric * 1000;
}
return Date.parse(normalized);
}
export function formatAdminTimestamp(value: string | null | undefined) {
const timestamp = parseAdminTimestamp(value);
if (!Number.isFinite(timestamp)) return '-';
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return '-';
try {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
} catch {
return '-';
}
}
function eventText(event: Record<string, unknown>, key: string) {
const value = event[key];
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
@@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
const openReportRequestId = useRef(0);
const loadRequestId = useRef(0);
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!selected) return;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = window.requestAnimationFrame(() => {
detailCloseButtonRef.current?.focus();
});
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelected(null);
};
window.addEventListener('keydown', handleEscape);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', handleEscape);
previousFocus?.focus?.();
};
}, [selected]);
const load = useCallback(async () => {
const requestId = ++loadRequestId.current;
@@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
<td>{report.batchId}</td>
<td>{report.eventCount}</td>
<td>{report.source ?? '-'}</td>
<td>{report.status}</td>
<td>
<span
className={`admin-error-report-status is-${report.status}`}
>
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
</span>
</td>
<td>{report.userId}</td>
<td>{new Date(report.createdAt).toLocaleString()}</td>
<td>{formatAdminTimestamp(report.createdAt)}</td>
</tr>
))}
</tbody>
@@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
className="admin-detail-modal"
role="dialog"
aria-label="错误报告详情"
aria-modal="true"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setSelected(null);
}}
>
<div className="admin-detail-modal__panel">
<header>
<h2>{selected.batchId}</h2>
<button type="button" onClick={() => setSelected(null)}>
关闭
<div className="admin-detail-modal__panel admin-error-report-detail">
<header className="admin-error-report-detail__header">
<div className="admin-error-report-detail__heading">
<span className="admin-error-report-detail__eyebrow">
错误报告详情
</span>
<h2>{selected.batchId}</h2>
<div className="admin-error-report-detail__badges">
<span
className={`admin-error-report-status is-${selected.status}`}
>
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
selected.status}
</span>
<span className="admin-error-report-detail__badge">
{selected.firstSource ?? selected.source ?? '未知来源'}
</span>
</div>
</div>
<button
type="button"
className="admin-error-report-detail__close"
ref={detailCloseButtonRef}
onClick={() => setSelected(null)}
aria-label="关闭错误报告详情"
>
×
</button>
</header>
<p>
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
{selected.logCount}
</p>
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
{selected.userDescription ? (
<p>用户描述:{selected.userDescription}</p>
) : null}
<label className="admin-detail-modal__note">
处理备注
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
<div className="admin-detail-modal__actions">
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
保存备注
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
<div className="admin-error-report-detail__summary">
<div>
<span>用户</span>
<strong>{selected.userId}</strong>
</div>
<div>
<span>发生时间</span>
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
</div>
<div>
<span>错误事件</span>
<strong>{selected.eventCount}</strong>
</div>
<div>
<span>应用日志</span>
<strong>{selected.logCount}</strong>
</div>
</div>
<div className="admin-error-report-detail__body">
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>错误事件</h3>
<span>展示最近 20 条,完整内容请下载诊断包。</span>
</div>
<span className="admin-error-report-detail__count">
{selected.events.length} 条
</span>
</div>
<div className="admin-error-report-event-list">
{selected.events.length === 0 ? (
<p className="admin-error-report-detail__empty">
归档中没有可展示的错误事件。
</p>
) : null}
{selected.events.slice(0, 20).map((event, index) => {
const message = eventText(event, 'message') || '未知错误';
const stack = eventText(event, 'stack');
const occurredAt = eventText(event, 'occurredAt');
return (
<article
className="admin-error-report-event"
key={`${index}-${message}`}
>
<div className="admin-error-report-event__topline">
<span className="admin-error-report-event__index">
#{index + 1}
</span>
<span>{eventText(event, 'source') || 'client'}</span>
<span>{eventText(event, 'count') || '1'} 次</span>
<time>{formatAdminTimestamp(occurredAt)}</time>
</div>
<p className="admin-error-report-event__message">
{message}
</p>
{eventText(event, 'fingerprint') ? (
<code>{eventText(event, 'fingerprint')}</code>
) : null}
{stack ? (
<details>
<summary>查看调用栈</summary>
<pre>{stack}</pre>
</details>
) : null}
</article>
);
})}
</div>
</section>
{selected.userDescription ? (
<section className="admin-error-report-detail__section">
<div className="admin-error-report-detail__section-heading">
<div>
<h3>用户描述</h3>
</div>
</div>
<p className="admin-error-report-detail__description">
{selected.userDescription}
</p>
</section>
) : null}
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
<label className="admin-detail-modal__note">
<span>处理备注</span>
<textarea
value={selected.note ?? ''}
onChange={(event) =>
setSelected((current) =>
current
? { ...current, note: event.target.value }
: current,
)
}
maxLength={2000}
rows={4}
placeholder="记录处理结论或后续跟进事项"
disabled={busy}
/>
</label>
</section>
</div>
<footer className="admin-error-report-detail__footer">
<label className="admin-error-report-detail__status-field">
状态
<select
value={selected.status}
onChange={(event) => void saveStatus(event.target.value)}
disabled={busy}
>
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
<option key={value} value={value}>
{ERROR_REPORT_STATUS_LABELS[value]}
</option>
))}
</select>
</label>
<div className="admin-error-report-detail__footer-actions">
<button
type="button"
onClick={() => void saveStatus(selected.status)}
disabled={busy}
>
{busy ? '保存中…' : '保存备注'}
</button>
<button
type="button"
onClick={() => void download(selected.batchId)}
disabled={downloading}
>
{downloading ? '下载中…' : '下载诊断包'}
</button>
</div>
</footer>
</div>
</div>
) : null}
@@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
);
});
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'agc:template-library',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('template-library');
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
true,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -27,9 +27,17 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
{
prefix: 'agc',
suffix: 'template-library',
key: 'agc:template-library',
label: '模板库',
description: '客户端模板库灰度',
},
{
prefix: 'image-editor',
suffix: 'agent-sidebar',
@@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({
setSelectedGateKey('');
setGatePrefix(option.prefix);
setGateKey(option.key);
setEnabled(false);
setEnabled(option.key === 'agc:template-library');
setRolloutPercent('0');
setAllowUserIds('');
setAllowUserTags('');
@@ -0,0 +1,431 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
vi.mock('../api/adminApiClient', async () => ({
...(await vi.importActual<typeof import('../api/adminApiClient')>(
'../api/adminApiClient',
)),
downloadAdminProjectSnapshot: vi.fn(),
getAdminProjectSnapshotChannels: vi.fn(),
listAdminProjectSnapshots: vi.fn(),
}));
const entry: AdminProjectSnapshotEntry = {
userId: 'user-1',
projectId: 'project-1',
projectName: '三消工程',
syncRevision: 3,
syncedAtMs: 1_700_000_000_000,
fileCount: 12,
totalBytes: 2048,
status: 'ready',
channel: 'dev',
authorDisplayName: '陶泥作者',
authorPublicUserCode: 'SY-00000007',
};
beforeEach(() => {
vi.mocked(listAdminProjectSnapshots)
.mockReset()
.mockResolvedValue({ items: [entry], nextCursor: null });
vi.mocked(getAdminProjectSnapshotChannels)
.mockReset()
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
vi.mocked(downloadAdminProjectSnapshot).mockReset();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
test('按项目展示完整性并限制未完成工程下载', async () => {
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
items: [
entry,
{
...entry,
projectId: 'partial-project',
projectName: '未完成工程',
status: 'partial',
},
{
...entry,
projectId: 'legacy-project',
projectName: null,
status: 'unverified',
},
],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
expect(
within(completeRow)
.getByRole('button', { name: '下载完整工程' })
.hasAttribute('disabled'),
).toBe(false);
expect(
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.getByText('完整性未知')).toBeTruthy();
expect(
screen
.getByRole('button', { name: '下载已存文件' })
.hasAttribute('disabled'),
).toBe(false);
});
test('按游标翻页回到上一页时复用已取得的游标', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
const pagination = await screen.findByRole('navigation', {
name: '项目工程分页',
});
expect(pagination.textContent).toContain('第 1 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
2,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.queryByText('三消工程')).toBeNull();
expect(pagination.textContent).toContain('第 2 页');
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(pagination.textContent).toContain('第 1 页');
});
test('切换每页条数从第一页按新条数重新加载', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
// 重新加载时页脚会重建,必须重新取节点再断言。
expect(
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
).toContain('第 1 页');
});
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: null,
})
.mockRejectedValueOnce(new Error('翻页读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByRole('alert');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
expect(screen.getByText('第二工程')).toBeTruthy();
expect(screen.queryByText('暂无已上传项目')).toBeNull();
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('暂无已上传项目');
expect(screen.queryByRole('alert')).toBeNull();
});
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const row = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(row).getByText('陶泥作者')).toBeTruthy();
expect(within(row).getByText('SY-00000007')).toBeTruthy();
expect(
within(row).getByRole('button', { name: '查看用户信息' }),
).toBeTruthy();
expect(within(row).queryByText('user-1')).toBeNull();
});
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'release',
channels: ['dev', 'release'],
});
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'release' },
expect.any(AbortSignal),
);
const channelSelect = screen.getByLabelText('项目工程渠道');
expect((channelSelect as HTMLSelectElement).value).toBe('release');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第 2 页,本页 1 个项目');
fireEvent.change(channelSelect, { target: { value: 'dev' } });
await screen.findByText('dev 工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
});
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
const createObjectURL = vi.fn(() => 'blob:archive');
const revokeObjectURL = vi.fn();
vi.stubGlobal(
'URL',
class extends URL {
static createObjectURL = createObjectURL;
static revokeObjectURL = revokeObjectURL;
},
);
let savedFilename = '';
let savedHref = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
savedFilename = this.download;
savedHref = this.href;
});
const blob = new Blob(['PK'], { type: 'application/zip' });
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
blob,
filename: '三消工程-r3.zip',
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const button = await screen.findByRole('button', { name: '下载完整工程' });
vi.useFakeTimers();
await act(async () => {
fireEvent.click(button);
});
expect(savedFilename).toBe('三消工程-r3.zip');
expect(savedHref).toBe('blob:archive');
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
'token',
'dev',
'user-1',
'project-1',
expect.any(AbortSignal),
);
act(() => vi.advanceTimersByTime(1000));
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
});
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
(_token, _channel, _user, _project, signal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const view = render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
expect(
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
).toBe(true);
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
view.unmount();
expect(signal?.aborted).toBe(true);
});
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
const onUnauthorized = vi.fn();
vi.mocked(downloadAdminProjectSnapshot)
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无项目工程权限' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '已过期' }),
);
render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
expect(onUnauthorized).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
const onUnauthorized = vi.fn();
vi.mocked(listAdminProjectSnapshots)
.mockRejectedValueOnce(new Error('清单存储不可用'))
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '已过期' }),
);
render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
);
await screen.findByText('清单存储不可用');
expect(screen.queryByText('暂无已上传项目')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await waitFor(() =>
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
let finishOldRequest!: (value: {
items: AdminProjectSnapshotEntry[];
nextCursor: null;
}) => void;
vi.mocked(listAdminProjectSnapshots)
.mockReturnValueOnce(
new Promise((resolve) => {
finishOldRequest = resolve;
}),
)
.mockResolvedValueOnce({
items: [{ ...entry, projectName: '新账号工程' }],
nextCursor: null,
});
const onUnauthorized = vi.fn();
const view = render(
<AdminProjectSnapshotsPage
token="old-token"
onUnauthorized={onUnauthorized}
/>,
);
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
await waitFor(() =>
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
);
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
view.rerender(
<AdminProjectSnapshotsPage
token="new-token"
onUnauthorized={onUnauthorized}
/>,
);
await screen.findByText('新账号工程');
expect(oldSignal?.aborted).toBe(true);
await act(async () => {
finishOldRequest({ items: [entry], nextCursor: null });
});
expect(screen.queryByText('三消工程')).toBeNull();
expect(screen.getByText('新账号工程')).toBeTruthy();
});
test('卸载后完成的下载不会创建浏览器文件', async () => {
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
new Promise((resolve) => {
finishDownload = resolve;
}),
);
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {});
const view = render(
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
view.unmount();
expect(signal?.aborted).toBe(true);
await act(async () => {
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
});
expect(click).not.toHaveBeenCalled();
});
@@ -0,0 +1,412 @@
import {
ChevronLeft,
ChevronRight,
Download,
RefreshCcw,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminProjectSnapshotsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const snapshotStatuses = {
ready: {
label: '已同步',
className: 'admin-status-ok',
action: '下载完整工程',
},
partial: {
label: '同步未完成',
className: 'admin-status-pending',
action: '同步未完成',
},
unverified: {
label: '完整性未知',
className: 'admin-status-pending',
action: '下载已存文件',
},
};
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
export function AdminProjectSnapshotsPage({
token,
onUnauthorized,
}: AdminProjectSnapshotsPageProps) {
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
const [channel, setChannel] = useState<string | null>(null);
const [channelOptions, setChannelOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const listController = useRef<AbortController | null>(null);
const downloadController = useRef<AbortController | null>(null);
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
const loadPage = useCallback(
async (cursor: string | null, limit: number, page: number) => {
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminProjectSnapshots(
token,
{ cursor, limit, channel },
controller.signal,
);
if (controller.signal.aborted) return;
setItems(response.items);
setNextCursor(response.nextCursor);
setPageIndex(page);
setHasLoaded(true);
} catch (error: unknown) {
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
if (!controller.signal.aborted)
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
if (listController.current === controller) {
listController.current = null;
setIsLoading(false);
}
}
},
[token, onUnauthorized, channel],
);
useEffect(() => {
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
const controller = new AbortController();
void (async () => {
try {
const response = await getAdminProjectSnapshotChannels(
token,
controller.signal,
);
if (controller.signal.aborted) return;
setChannelOptions(response.channels);
setChannel((current) => current ?? response.defaultChannel);
} catch (error: unknown) {
if (controller.signal.aborted) return;
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
handlePageError(error, onUnauthorized, setErrorMessage);
setChannel((current) => current ?? '');
}
})();
return () => controller.abort();
}, [token, onUnauthorized]);
useEffect(() => {
if (channel === null) return undefined;
pageCursors.current = [null];
setItems([]);
setNextCursor(null);
setPageIndex(1);
setHasLoaded(false);
setDownloadingKey(null);
void loadPage(null, pageSize, 1);
return () => {
listController.current?.abort();
listController.current = null;
downloadController.current?.abort();
downloadController.current = null;
};
}, [loadPage, pageSize, channel]);
function goToNextPage() {
if (!nextCursor) return;
pageCursors.current[pageIndex] = nextCursor;
void loadPage(nextCursor, pageSize, pageIndex + 1);
}
function goToPreviousPage() {
if (pageIndex <= 1) return;
void loadPage(
pageCursors.current[pageIndex - 2] ?? null,
pageSize,
pageIndex - 1,
);
}
function refreshCurrentPage() {
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
pageIndex,
);
}
async function downloadProject(entry: AdminProjectSnapshotEntry) {
if (downloadController.current || entry.status === 'partial') return;
const controller = new AbortController();
downloadController.current = controller;
setDownloadingKey(snapshotKey(entry));
setErrorMessage('');
try {
const archive = await downloadAdminProjectSnapshot(
token,
channel ?? '',
entry.userId,
entry.projectId,
controller.signal,
);
if (controller.signal.aborted) return;
const objectUrl = URL.createObjectURL(archive.blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = archive.filename;
document.body.append(link);
try {
link.click();
} finally {
link.remove();
// 给浏览器时间接管下载,随后释放临时 URL。
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
} catch (error: unknown) {
if (!controller.signal.aborted)
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
if (downloadController.current === controller) {
downloadController.current = null;
setDownloadingKey(null);
}
}
}
function cancelDownload() {
downloadController.current?.abort();
downloadController.current = null;
setDownloadingKey(null);
}
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
const visibleChannelOptions = channelOptions.length
? channelOptions
: channel
? [channel]
: [];
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2>项目工程</h2>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>渠道</span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="alert">
{errorMessage}
</div>
) : null}
<section
className="admin-panel admin-stack"
aria-label="项目工程列表"
aria-busy={isLoading}
>
<div className="admin-table-wrap">
<table className="admin-table admin-project-snapshot-table">
<thead>
<tr>
<th>项目</th>
<th>用户</th>
<th>同步时间</th>
<th>文件数</th>
<th>体积</th>
<th>完整性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{items.map((entry) => {
const status = snapshotStatuses[entry.status];
const isDownloading = downloadingKey === snapshotKey(entry);
return (
<tr key={snapshotKey(entry)}>
<td data-label="项目">
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
hour12: false,
})}
<small>版本 {entry.syncRevision}</small>
</span>
</td>
<td data-label="文件数">
{entry.fileCount.toLocaleString('zh-CN')}
</td>
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
<td data-label="完整性">
<span className={`admin-status ${status.className}`}>
{status.label}
</span>
</td>
<td data-label="操作">
{isDownloading ? (
<button
className="admin-secondary-button"
type="button"
onClick={cancelDownload}
>
<X size={16} aria-hidden="true" />
<span>取消下载</span>
</button>
) : (
<button
className="admin-secondary-button"
disabled={
entry.status === 'partial' ||
downloadingKey !== null
}
type="button"
onClick={() => void downloadProject(entry)}
>
<Download size={16} aria-hidden="true" />
<span>{status.action}</span>
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{hasLoaded && items.length === 0 && !errorMessage ? (
<p className="admin-muted-text">暂无已上传项目</p>
) : null}
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
第 {pageIndex} 页
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>每页</span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span>上一页</span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span>下一页</span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
</section>
);
}
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
function formatBytes(bytes: number) {
const units = ['B', 'KiB', 'MiB', 'GiB'];
const unit = Math.min(
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
units.length - 1,
);
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
}
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
attachCoverFiles,
buildTemplateImportFormData,
deriveTemplateUploadRow,
parseTemplateTags,
sanitizeTemplateId,
TEMPLATE_IMPORT_MAX_BATCH,
type TemplateUploadRow,
validateTemplateUploadRows,
} from './adminAgcTemplateUploadModel';
function zipFile(name: string) {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
function coverFile(name: string) {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
type: 'image/png',
});
}
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
}
describe('sanitizeTemplateId', () => {
it('lowercases, keeps whitelisted characters and drops traversal', () => {
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
});
});
describe('deriveTemplateUploadRow', () => {
it('starts from safe defaults so a batch only needs covers attached', () => {
const derived = row('my-template.zip');
expect(derived.id).toBe('my-template');
expect(derived.title).toBe('my-template');
expect(derived.runtime).toBe('html');
expect(derived.templateVersion).toBe('0.1.0');
expect(derived.entry).toBe('index.html');
expect(derived.coverFile).toBeNull();
});
});
describe('attachCoverFiles', () => {
it('matches covers by template id and ignores non-image files', () => {
const rows = [row('alpha.zip'), row('beta.zip')];
const covers = [
coverFile('alpha.png'),
coverFile('beta.webp'),
coverFile('notes.txt'),
];
const next = attachCoverFiles(rows, covers);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
expect(next[1]?.coverFile?.name).toBe('beta.webp');
});
it('keeps an already matched cover when the new selection has no partner', () => {
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
const next = attachCoverFiles(rows, [coverFile('other.png')]);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
});
});
describe('validateTemplateUploadRows', () => {
it('accepts a complete batch', () => {
const rows = [
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
];
expect(validateTemplateUploadRows(rows)).toEqual({});
});
it('reports missing cover, duplicate id and invalid fields per row', () => {
const rows = [
row('alpha.zip'),
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
row('gamma.zip', {
coverFile: coverFile('gamma.png'),
runtime: 'docker',
templateVersion: 'Bad Version',
entry: '../escape.js',
title: ' ',
}),
];
const errors = validateTemplateUploadRows(rows);
expect(errors['alpha.zip']).toContain('缺少封面');
expect(errors.second).toContain('ID 在本批次内重复');
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
});
it('rejects a batch larger than the server limit', () => {
const rows = Array.from(
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
(_, index) =>
row(`template-${index}.zip`, {
coverFile: coverFile(`template-${index}.png`),
}),
);
const errors = validateTemplateUploadRows(rows);
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
expect(errors['template-0.zip']).toContain('单批最多上传');
});
});
describe('buildTemplateImportFormData', () => {
it('writes manifest field names and attaches zip / cover per row', () => {
const rows = [
row('alpha.zip', {
coverFile: coverFile('alpha.png'),
tags: '起步, 起步 2d',
title: ' Alpha ',
}),
row('beta.zip', { coverFile: coverFile('beta.png') }),
];
const form = buildTemplateImportFormData('a'.repeat(64), rows);
const manifest = JSON.parse(String(form.get('manifest')));
expect(manifest.expectedRevision).toBe('a'.repeat(64));
expect(manifest.templates).toHaveLength(2);
expect(manifest.templates[0]).toMatchObject({
id: 'alpha',
title: 'Alpha',
tags: ['起步', '2d'],
zipField: 'zip_0',
coverField: 'cover_0',
});
expect(manifest.templates[1]).toMatchObject({
id: 'beta',
zipField: 'zip_1',
coverField: 'cover_1',
});
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
expect((form.get('cover_1') as File).name).toBe('beta.png');
});
it('parses tags with dedupe and caps them at the server limit', () => {
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
',',
);
expect(parseTemplateTags(many)).toHaveLength(16);
});
});
@@ -0,0 +1,191 @@
import type {
AdminImportAgcTemplateItemPayload,
AdminImportAgcTemplatesManifest,
} from '../api/adminApiTypes';
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
export const TEMPLATE_UPLOAD_RUNTIMES = [
'html',
'unity',
'godot',
'cocos',
] as const;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
export interface TemplateUploadRow {
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
key: string;
zipFile: File;
coverFile: File | null;
id: string;
title: string;
summary: string;
tags: string;
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
}
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
export function sanitizeTemplateId(value: string) {
return value
.replace(/\.zip$/iu, '')
.trim()
.toLowerCase()
.replace(/\.{2,}/gu, '.')
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/^[^a-z0-9]+/u, '')
.slice(0, 64);
}
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
const id = sanitizeTemplateId(zipFile.name);
return {
key: zipFile.name,
zipFile,
coverFile: null,
id,
title: id,
summary: '',
tags: '',
runtime: 'html',
engine: '',
engineVersion: '',
templateVersion: '0.1.0',
entry: 'index.html',
};
}
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
export function attachCoverFiles(
rows: TemplateUploadRow[],
coverFiles: File[],
): TemplateUploadRow[] {
const covers = new Map<string, File>();
for (const file of coverFiles) {
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
const stem = sanitizeTemplateId(
file.name.replace(COVER_EXTENSION_PATTERN, ''),
);
if (!covers.has(stem)) covers.set(stem, file);
}
return rows.map((row) => {
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
return matched ? { ...row, coverFile: matched } : row;
});
}
export function parseTemplateTags(value: string) {
const seen = new Set<string>();
const tags: string[] = [];
for (const raw of value.split(/[,,\s]+/u)) {
const tag = raw.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags.slice(0, 16);
}
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
export function validateTemplateUploadRows(
rows: TemplateUploadRow[],
): Record<string, string> {
const errors: Record<string, string> = {};
const seen = new Set<string>();
const fail = (row: TemplateUploadRow, message: string) => {
if (!errors[row.key]) errors[row.key] = message;
};
if (rows.length === 0) return errors;
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
for (const row of rows) {
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
}
return errors;
}
for (const row of rows) {
const id = row.id.trim();
if (!TEMPLATE_ID_PATTERN.test(id)) {
fail(
row,
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
);
} else if (seen.has(id)) {
fail(row, 'ID 在本批次内重复');
} else {
seen.add(id);
}
if (!row.title.trim() || row.title.trim().length > 80) {
fail(row, '名称必须是 1-80 个字符');
}
if (row.summary.trim().length > 1000) {
fail(row, '简介最多 1000 个字符');
}
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
}
if (
!TEMPLATE_UPLOAD_RUNTIMES.includes(
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
)
) {
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
}
if (!ENTRY_PATTERN.test(row.entry.trim())) {
fail(row, 'entry 必须是模板包内的相对路径');
}
if (!row.coverFile) {
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
}
}
return errors;
}
export function buildTemplateImportManifest(
expectedRevision: string,
rows: TemplateUploadRow[],
): AdminImportAgcTemplatesManifest {
return {
expectedRevision,
templates: rows.map((row, index) => {
const item: AdminImportAgcTemplateItemPayload = {
id: row.id.trim(),
title: row.title.trim(),
summary: row.summary.trim(),
tags: parseTemplateTags(row.tags),
runtime: row.runtime,
engine: row.engine.trim(),
engineVersion: row.engineVersion.trim(),
templateVersion: row.templateVersion.trim(),
entry: row.entry.trim(),
zipField: `zip_${index}`,
coverField: `cover_${index}`,
};
return item;
}),
};
}
export function buildTemplateImportFormData(
expectedRevision: string,
rows: TemplateUploadRow[],
): FormData {
const form = new FormData();
form.append(
'manifest',
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
);
rows.forEach((row, index) => {
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
if (row.coverFile) {
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
}
});
return form;
}
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -9,6 +9,7 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -66,15 +67,16 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@openai/codex": "0.147.0",
"@openai/codex": "0.155.1",
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -0,0 +1,330 @@
/**
* AGC 总版本号(发号源)。
*
* 唯一事实源是 OSS 对象 `agc/global-version.json`;渠道清单只写各自本次拿到的号。
* 仓库里的 5 个版本文件仍由构建改写,但只作构建输入参考,不作为事实源。
*
* 发号顺序固定为「先写总号 → 再构建 → 再发渠道清单」:任何一步失败都不回滚,
* 只烧号。这样渠道之间不会复用同一个号,代价是可能出现空洞。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const AGC_GLOBAL_VERSION_OBJECT_KEY = 'agc/global-version.json';
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
const versionPattern = /^\d+\.\d+\.\d+$/u;
function trimTrailingSlashes(value) {
return value.replace(/\/+$/u, '');
}
export function ossBaseUrl(env = process.env) {
return trimTrailingSlashes(
env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl,
);
}
export function globalVersionUrl(env = process.env) {
return `${ossBaseUrl(env)}/global-version.json`;
}
export function parseVersion(value, label) {
if (typeof value !== 'string' || !versionPattern.test(value.trim())) {
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
}
return value.trim();
}
export function compareVersions(left, right) {
const leftParts = parseVersion(left, '左版本').split('.').map(Number);
const rightParts = parseVersion(right, '右版本').split('.').map(Number);
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}
/** 取较大版本;任一为空时返回另一个。 */
export function maxVersion(...versions) {
return versions
.filter((value) => typeof value === 'string' && value.trim())
.map((value) => parseVersion(value, '候选版本'))
.reduce(
(best, current) =>
best == null || compareVersions(current, best) > 0 ? current : best,
null,
);
}
export function nextVersion(current) {
const [major, minor, patch] = parseVersion(current, '总版本')
.split('.')
.map(Number);
if (patch === Number.MAX_SAFE_INTEGER) {
throw new Error(`版本号 patch 已达到上限:${current}`);
}
return `${major}.${minor}.${patch + 1}`;
}
export function readReleaseDryRun(env = process.env) {
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
return value === '1' || value === 'true';
}
async function fetchJson(url, label, { fetchImpl = fetch } = {}) {
let response;
try {
response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
});
} catch (error) {
throw new Error(`读取 ${label} 失败:${error.message}`);
}
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
}
try {
return await response.json();
} catch (error) {
throw new Error(`${label} 不是有效 JSON:${error.message}`);
}
}
/** 渠道清单版本;缺失或 404 时返回 null(首次启用渠道)。 */
export async function readChannelVersion(channel, options = {}) {
const payload = await fetchJson(
`${ossBaseUrl(options.env)}/${channel}/latest.json`,
`${channel} 渠道清单`,
options,
);
if (payload == null) return null;
const version = typeof payload.version === 'string' ? payload.version : '';
if (!version) {
throw new Error(`${channel} 渠道清单缺少 version 字段`);
}
return parseVersion(version, `${channel} 渠道清单 version`);
}
/** 旧协议迁移指针 `agc/latest.json`;只在迁移窗口内存在,仅参与播种。 */
export async function readLegacyPointerVersion(options = {}) {
const payload = await fetchJson(
`${ossBaseUrl(options.env)}/latest.json`,
'OSS 迁移指针',
options,
);
if (payload == null) return null;
const version = typeof payload.version === 'string' ? payload.version : '';
return version ? parseVersion(version, 'OSS 迁移指针 version') : null;
}
export async function readGlobalVersion(options = {}) {
const payload = await fetchJson(
globalVersionUrl(options.env),
'AGC 总版本号',
options,
);
if (payload == null) return null;
const version = typeof payload.version === 'string' ? payload.version : '';
if (!version) {
throw new Error('AGC 总版本号对象缺少 version 字段');
}
return {
...payload,
version: parseVersion(version, 'AGC 总版本号 version'),
};
}
/**
* 一次性播种基线:仓库当前版本、渠道清单与旧迁移指针里的最大值。
* 基线本身不发给客户端,首个发放号是 baseline + 1。
*/
export async function resolveSeedBaseline({
channels = ['dev-win', 'dev-mac'],
repoVersion = null,
env = process.env,
fetchImpl = fetch,
} = {}) {
const candidates = [];
if (repoVersion) candidates.push(parseVersion(repoVersion, '仓库当前版本'));
for (const channel of channels) {
const version = await readChannelVersion(channel, { env, fetchImpl });
if (version) candidates.push(version);
}
const legacy = await readLegacyPointerVersion({ env, fetchImpl });
if (legacy) candidates.push(legacy);
const baseline = maxVersion(...candidates);
if (!baseline) {
throw new Error('无法确定总版本号播种基线:仓库版本与渠道清单都不可用');
}
return baseline;
}
/**
* 组装 ossutil 参数。
*
* 该桶与凭据按 v1 签名使用(ossutil v2 默认 v4,缺 region 会直接失败),
* 因此默认显式传 `--sign-version v1`;需要 v4 时用 `AGC_OSS_SIGN_VERSION=v4`
* 并同时给 `AGC_OSS_REGION`。
*/
export function buildOssutilArgs({
args,
endpoint,
accessKeyId,
accessKeySecret,
env = process.env,
}) {
const finalArgs = [...args, '--endpoint', endpoint];
const region = env.AGC_OSS_REGION?.trim();
if (region) finalArgs.push('--region', region);
finalArgs.push('--sign-version', env.AGC_OSS_SIGN_VERSION?.trim() || 'v1');
if (accessKeyId) {
finalArgs.push(
'--access-key-id',
accessKeyId,
'--access-key-secret',
accessKeySecret,
);
}
return finalArgs;
}
function runOssutil(args, { env = process.env } = {}) {
const binary = env.OSSUTIL_BIN?.trim() || 'ossutil';
const endpoint =
env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
const accessKeyId = env.AGC_OSS_ACCESS_KEY_ID?.trim();
const accessKeySecret = env.AGC_OSS_ACCESS_KEY_SECRET;
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
const result = spawnSync(
binary,
buildOssutilArgs({
args,
endpoint,
accessKeyId,
accessKeySecret,
env,
}),
{ stdio: 'inherit', shell: false, env },
);
if (result.error) {
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
}
if (result.status !== 0) {
throw new Error(`${binary} 执行失败,退出码 ${result.status}`);
}
}
/** 写入总版本号对象;dry-run 下只打印将要执行的上传。 */
export function writeGlobalVersion(payload, options = {}) {
const { env = process.env, dryRun = readReleaseDryRun(env) } = options;
const bucket = env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
const body = Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
const tempDirectory = fs.mkdtempSync(
path.join(os.tmpdir(), 'agc-global-version-'),
);
const tempPath = path.join(tempDirectory, 'global-version.json');
try {
fs.writeFileSync(tempPath, body);
const ossUrl = `oss://${bucket}/${AGC_GLOBAL_VERSION_OBJECT_KEY}`;
if (dryRun) {
console.log(
`[dry-run] 不写总版本号:${ossUrl} <- ${JSON.stringify(payload)}`,
);
return { written: false, objectUrl: ossUrl, payload };
}
runOssutil(['cp', '--force', tempPath, ossUrl], { env });
return { written: true, objectUrl: ossUrl, payload };
} finally {
fs.rmSync(tempDirectory, { force: true, recursive: true });
}
}
/**
* 发一次号并写回总版本号对象。
*
* 写后回读校验:若远端值与自己写下的不一致,说明有并发发号,按失败关闭处理
* (号已烧,不重试、不回滚),由人工确认后再发。
*/
export async function issueGlobalVersion({
channel,
commit = null,
buildId = null,
repoVersion = null,
env = process.env,
fetchImpl = fetch,
now = () => new Date().toISOString(),
writeImpl = writeGlobalVersion,
} = {}) {
if (!channel) throw new Error('发号必须显式指定 channel');
const dryRun = readReleaseDryRun(env);
const current = await readGlobalVersion({ env, fetchImpl });
let baseline = current?.version ?? null;
let seeded = false;
if (!baseline) {
baseline = await resolveSeedBaseline({ repoVersion, env, fetchImpl });
seeded = true;
}
const issued = nextVersion(baseline);
const payload = {
version: issued,
updatedAt: now(),
channel,
commit,
buildId,
};
console.log(
`[agc-global-version] ${
seeded ? `按播种基线 ${baseline} 首发` : `总号 ${baseline}`
} -> ${issued}(channel=${channel} dry-run=${dryRun})`,
);
writeImpl(payload, { env, dryRun });
if (!dryRun) {
const stored = await readGlobalVersion({ env, fetchImpl });
if (!stored || stored.version !== issued) {
throw new Error(
`总版本号写后回读不一致:期望 ${issued},远端 ${
stored?.version ?? '不存在'
};可能存在并发发号,本次构建失败关闭`,
);
}
}
return issued;
}
/** 渠道高水位断言:请求号低于本渠道清单版本即失败关闭。 */
export function assertRequestedVersionNotBelowChannel({
requested,
channelVersion,
channel,
}) {
const requestedVersion = parseVersion(requested, '请求版本');
if (channelVersion == null) return requestedVersion;
const current = parseVersion(channelVersion, `${channel} 渠道版本`);
if (compareVersions(requestedVersion, current) < 0) {
throw new Error(
`请求版本 ${requestedVersion} 低于 ${channel} 渠道当前清单版本 ${current};拒绝回退发布`,
);
}
return requestedVersion;
}
/** 只读预览:不写回、不烧号。 */
export async function previewNextGlobalVersion(options = {}) {
const current = await readGlobalVersion(options);
const baseline =
current?.version ??
(await resolveSeedBaseline({
repoVersion: options.repoVersion,
env: options.env,
fetchImpl: options.fetchImpl,
}));
return nextVersion(baseline);
}
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
assertRequestedVersionNotBelowChannel,
buildOssutilArgs,
issueGlobalVersion,
maxVersion,
nextVersion,
previewNextGlobalVersion,
resolveSeedBaseline,
} from './agc-global-version.mjs';
function jsonResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => payload,
};
}
/** 以 URL 为键的假 OSS:只读 fetch + 记录写入。 */
function createFakeOss({ objects = {} } = {}) {
const state = { ...objects };
const writes = [];
return {
state,
writes,
fetchImpl: async (url) => {
const key = String(url).replace(/^https?:\/\/[^/]+\//u, '');
if (!(key in state)) return jsonResponse(null, 404);
return jsonResponse(state[key]);
},
writeImpl: (payload, options = {}) => {
writes.push({ payload, dryRun: Boolean(options.dryRun) });
if (!options.dryRun) state['agc/global-version.json'] = payload;
return { written: !options.dryRun, payload };
},
};
}
test('播种基线取仓库版本、各渠道清单与旧迁移指针的最大值', async () => {
const oss = createFakeOss({
objects: {
'agc/dev-win/latest.json': { version: '0.1.57' },
'agc/dev-mac/latest.json': { version: '0.1.12' },
'agc/latest.json': { version: '0.1.60' },
},
});
assert.equal(
await resolveSeedBaseline({
repoVersion: '0.1.48',
env: {},
fetchImpl: oss.fetchImpl,
}),
'0.1.60',
);
assert.equal(maxVersion('0.1.9', '0.1.10', null), '0.1.10');
});
test('无总号时按播种基线发首号,并把总号写回唯一事实源', async () => {
const oss = createFakeOss({
objects: {
'agc/dev-win/latest.json': { version: '0.1.57' },
'agc/dev-mac/latest.json': { version: '0.1.12' },
},
});
const issued = await issueGlobalVersion({
channel: 'dev-win',
commit: 'a'.repeat(40),
buildId: '319',
repoVersion: '0.1.48',
env: {},
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
now: () => '2026-09-20T00:00:00.000Z',
});
assert.equal(issued, '0.1.58');
assert.deepEqual(
oss.writes.map((entry) => entry.payload.version),
['0.1.58'],
);
assert.equal(oss.state['agc/global-version.json'].version, '0.1.58');
assert.equal(oss.state['agc/global-version.json'].channel, 'dev-win');
assert.equal(oss.state['agc/global-version.json'].buildId, '319');
});
test('已有总号时只递增,不再回看渠道清单', async () => {
const oss = createFakeOss({
objects: {
'agc/global-version.json': { version: '0.2.7' },
// 渠道清单被手工改小也不能把总号拉回去。
'agc/dev-win/latest.json': { version: '0.1.10' },
},
});
const issued = await issueGlobalVersion({
channel: 'dev-mac',
env: {},
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
});
assert.equal(issued, '0.2.8');
assert.equal(oss.state['agc/global-version.json'].version, '0.2.8');
});
test('dry-run 只预览下一位,不写回、不烧号', async () => {
const oss = createFakeOss({
objects: { 'agc/global-version.json': { version: '0.3.4' } },
});
const preview = await previewNextGlobalVersion({
env: {},
fetchImpl: oss.fetchImpl,
});
assert.equal(preview, '0.3.5');
assert.equal(oss.writes.length, 0);
const issued = await issueGlobalVersion({
channel: 'dev-win',
env: { AGC_RELEASE_DRY_RUN: '1' },
fetchImpl: oss.fetchImpl,
writeImpl: oss.writeImpl,
});
assert.equal(issued, '0.3.5');
assert.deepEqual(
oss.writes.map((entry) => entry.dryRun),
[true],
);
assert.equal(oss.state['agc/global-version.json'].version, '0.3.4');
});
test('传入低于本渠道清单的号时失败关闭', () => {
assert.equal(
assertRequestedVersionNotBelowChannel({
requested: '0.1.60',
channelVersion: '0.1.60',
channel: 'dev-win',
}),
'0.1.60',
);
assert.throws(
() =>
assertRequestedVersionNotBelowChannel({
requested: '0.1.59',
channelVersion: '0.1.60',
channel: 'dev-win',
}),
/低于 dev-win 渠道当前清单版本/u,
);
assert.equal(
assertRequestedVersionNotBelowChannel({
requested: '0.1.1',
channelVersion: null,
channel: 'dev-mac',
}),
'0.1.1',
);
});
test('写后回读不一致(并发发号)时失败关闭', async () => {
const oss = createFakeOss({
objects: { 'agc/global-version.json': { version: '0.5.1' } },
});
await assert.rejects(
issueGlobalVersion({
channel: 'dev-win',
env: {},
// 模拟另一个发号进程在写入后覆盖了总号。
writeImpl: (payload, options) => {
const result = oss.writeImpl(payload, options);
// 另一个发号进程紧随其后覆盖总号。
oss.state['agc/global-version.json'] = { version: '0.5.9' };
return result;
},
fetchImpl: oss.fetchImpl,
}),
/写后回读不一致/u,
);
});
test('nextVersion 只在 patch 位递增', () => {
assert.equal(nextVersion('0.1.9'), '0.1.10');
assert.equal(nextVersion('1.0.0'), '1.0.1');
assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u);
});
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: [
'cp',
'--force',
'/tmp/a.json',
'oss://agc-dev/agc/global-version.json',
],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',
env: {},
};
const v1 = buildOssutilArgs(base);
assert.equal(v1[v1.indexOf('--sign-version') + 1], 'v1');
assert.ok(!v1.includes('--region'));
assert.equal(v1[v1.indexOf('--access-key-id') + 1], 'id');
assert.equal(v1[v1.indexOf('--access-key-secret') + 1], 'secret');
const v4 = buildOssutilArgs({
...base,
env: { AGC_OSS_SIGN_VERSION: 'v4', AGC_OSS_REGION: 'cn-beijing' },
});
assert.equal(v4[v4.indexOf('--region') + 1], 'cn-beijing');
assert.equal(v4[v4.indexOf('--sign-version') + 1], 'v4');
});
@@ -676,11 +676,11 @@ async function runSelfTest() {
designFoundationAssetCall?.arguments?.input?.outputPath ===
'assets/ui-prototype.png' &&
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' &&
artAssetPlanAssetCall?.arguments?.input?.outputPath ===
'assets/art-spritesheet.png' &&
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet',
'self-test-visual-assets-invalid',
);
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { inflateSync } from 'node:zlib';
export const appIdentifier = 'world.genarrative.ai-game-creator';
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
export const appIdentifier = AGC_APP_IDENTIFIER;
export const configFileName = 'game-creator.config.json';
export const localConfigFileName = 'game-creator.config.local.json';
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
@@ -70,7 +73,7 @@ const requiredFormalArtifactSpecs = [
{ path: 'game/balance.json', kind: 'json' },
{ path: 'assets/manifest.art.json', kind: 'json' },
{ path: 'assets/manifest.audio.json', kind: 'json' },
{ path: 'game/index.html', kind: 'game-entry' },
{ path: 'game/index.html', kind: 'file' },
{ path: 'exports/README.md', kind: 'file' },
];
const editorImageArtifactSpecs = [
@@ -957,9 +960,6 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
return result;
}
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;

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