Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0704e4da | |||
| 4e41bd3e56 | |||
| 9d060de185 | |||
| 8d83b14ae5 | |||
| 085181c795 | |||
| f983663caf | |||
| 9f225b7a41 | |||
| 2d61a4c057 | |||
| e2bdb1105b | |||
| 71f5f36764 | |||
| f71361c292 | |||
| 6e9e7edcf8 | |||
| 480fe995ed | |||
| afd5b8d91f | |||
| c60d116af0 | |||
| 3eb99f5124 | |||
| 34b1ad5d5c | |||
| c52e0814d6 | |||
| 4951b71d71 | |||
| 8520b50c81 | |||
| c5afc02e94 | |||
| b5e1c0f1d6 | |||
| 15e7d6065e | |||
| 2e23a54211 | |||
| 4863c8c066 | |||
| 5087b600a2 | |||
| 33036b65fc | |||
| f4ebc45612 | |||
| 19ed1776f1 | |||
| 8fd85d0426 | |||
| b840973d82 | |||
| ca82e6b745 | |||
| 3e657e6a6a |
@@ -6,6 +6,8 @@ import {
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
@@ -20,6 +22,30 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(
|
||||
await listAdminAgcTrackingEvents('token', {
|
||||
userId: 'user+1',
|
||||
projectId: 'project-1',
|
||||
cursor: 'page/2',
|
||||
limit: 50,
|
||||
}),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
@@ -65,6 +91,48 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面
|
||||
]);
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -30,9 +32,11 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
@@ -87,6 +91,8 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -174,6 +180,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);
|
||||
@@ -209,6 +217,7 @@ export function listAdminProjectSnapshots(
|
||||
) {
|
||||
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()}`,
|
||||
@@ -216,13 +225,27 @@ export function listAdminProjectSnapshots(
|
||||
);
|
||||
}
|
||||
|
||||
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 path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||
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()}`,
|
||||
@@ -385,6 +408,31 @@ export function listAdminTrackingEventKeys(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminAgcTrackingEvents(
|
||||
token: string,
|
||||
query: AdminAgcTrackingEventQuery = {},
|
||||
) {
|
||||
return request<AdminAgcTrackingEventListResponse>(
|
||||
`/admin/api/agc/tracking-events${buildQueryString((params) => {
|
||||
for (const key of [
|
||||
'userId',
|
||||
'projectId',
|
||||
'creativeTaskId',
|
||||
'agentRunId',
|
||||
'eventName',
|
||||
'clientVersion',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'cursor',
|
||||
] as const) {
|
||||
appendQueryParam(params, key, query[key]);
|
||||
}
|
||||
appendNumericQueryParam(params, 'limit', query.limit);
|
||||
})}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminErrorReports(
|
||||
token: string,
|
||||
query: {
|
||||
@@ -1196,3 +1244,15 @@ export function updateAdminAgcTemplate(
|
||||
{ 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry {
|
||||
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 {
|
||||
@@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotChannelsResponse {
|
||||
defaultChannel: string;
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
@@ -811,6 +820,44 @@ export interface AdminTrackingEventListResponse {
|
||||
entries: AdminTrackingEventEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventQuery {
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
creativeTaskId?: string;
|
||||
agentRunId?: string;
|
||||
eventName?: string;
|
||||
clientVersion?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventEntry {
|
||||
eventId: string;
|
||||
schemaVersion: number;
|
||||
eventName: string;
|
||||
eventTime: string;
|
||||
userId: string;
|
||||
editorSessionId: string;
|
||||
projectId: string | null;
|
||||
creativeTaskId: string | null;
|
||||
agentRunId: string | null;
|
||||
agentTurnId: string | null;
|
||||
status: string | null;
|
||||
errorCode: string | null;
|
||||
source: string;
|
||||
clientVersion: string;
|
||||
properties: Record<string, unknown>;
|
||||
batchId: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventListResponse {
|
||||
entries: AdminAgcTrackingEventEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventKeyPayload {
|
||||
eventKey: string;
|
||||
eventTitle: string;
|
||||
@@ -1065,3 +1112,35 @@ export interface AdminUpdateAgcTemplateRequest {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from './adminApiClient';
|
||||
|
||||
@@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
expect(
|
||||
await listAdminProjectSnapshots(
|
||||
'admin-token',
|
||||
{ cursor: 'user/a+项目', limit: 20 },
|
||||
{ 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&limit=20',
|
||||
'/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,
|
||||
@@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
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', {
|
||||
@@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
const controller = new AbortController();
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
'admin-token',
|
||||
'release',
|
||||
'user/a',
|
||||
'project/b',
|
||||
controller.signal,
|
||||
@@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
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',
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
@@ -81,7 +100,8 @@ test.each([
|
||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||
);
|
||||
expect(
|
||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
||||
.filename,
|
||||
).toBe(expected.replace('工程', 'project'));
|
||||
});
|
||||
|
||||
@@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])(
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({
|
||||
status,
|
||||
code: 'SNAPSHOT_FAILURE',
|
||||
@@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -232,6 +233,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-tracking' ? (
|
||||
<AdminAgcTrackingPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'error-reports' ? (
|
||||
<AdminErrorReportsPage
|
||||
token={token}
|
||||
|
||||
@@ -39,6 +39,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'agc-tracking': Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['agc-tracking'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['agc-tracking']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['tracking'],
|
||||
}).some((route) => route.id === 'agc-tracking'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('后台默认进入 Dashboard', () => {
|
||||
expect(adminRoutes[0]).toEqual({
|
||||
id: 'dashboard',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'agc-tracking'
|
||||
| 'error-reports'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
@@ -40,6 +41,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
|
||||
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||||
getAdminAgcTemplates: vi.fn(),
|
||||
importAdminAgcTemplates: vi.fn(),
|
||||
updateAdminAgcTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,21 @@ beforeEach(() => {
|
||||
: entry,
|
||||
),
|
||||
}));
|
||||
vi.mocked(importAdminAgcTemplates)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => ({
|
||||
...structuredClone(library),
|
||||
revision: 'rev-imported',
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(class extends URL {}, {
|
||||
@@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库'
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
|
||||
function uploadZipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function uploadCoverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
async function openUploadDialog() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '上传模板' }));
|
||||
return screen.getByRole('dialog', { name: '上传模板' });
|
||||
}
|
||||
|
||||
function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||||
const input = dialog.querySelector<HTMLInputElement>(
|
||||
`input[accept="${accept}"]`,
|
||||
);
|
||||
if (!input) throw new Error(`missing file input for ${accept}`);
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [
|
||||
uploadZipFile('alpha.zip'),
|
||||
uploadZipFile('beta.zip'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.zip')).not.toBeNull();
|
||||
expect(within(dialog).getByText('beta.zip')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2);
|
||||
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
uploadCoverFile('beta.png'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.png')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
|
||||
await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1));
|
||||
const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1];
|
||||
const manifest = JSON.parse(String(formData.get('manifest')));
|
||||
expect(manifest.expectedRevision).toBe('rev-1');
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect((formData.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((formData.get('cover_0') as File).name).toBe('alpha.png');
|
||||
expect(
|
||||
(await screen.findAllByText(/已导入 1 个模板/)).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('批量上传:冲突给出刷新引导', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 409,
|
||||
message: '模板库已更新,请刷新后重新编辑',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 1 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('批量上传:服务端拒绝时展示真实原因', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 400,
|
||||
message: 'alpha:模板包缺少清单声明的 entry:index.html',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TableRow,
|
||||
TextField,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { RefreshCcw, Upload } from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
@@ -21,16 +21,28 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
isAdminApiError,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTemplatePayload,
|
||||
AdminImportAgcTemplateResult,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
TEMPLATE_UPLOAD_RUNTIMES,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
type PageProps = { token: string; onUnauthorized: (message?: string) => void };
|
||||
@@ -39,6 +51,14 @@ type EditingTemplate = {
|
||||
revision: string;
|
||||
key: number;
|
||||
};
|
||||
|
||||
type TemplateUploadState = {
|
||||
rows: TemplateUploadRow[];
|
||||
errors: Record<string, string>;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
results: AdminImportAgcTemplateResult[] | null;
|
||||
};
|
||||
const runtimeLabels: Record<string, string> = {
|
||||
html: 'HTML',
|
||||
cocos: 'Cocos',
|
||||
@@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
const [notice, setNotice] = useState('');
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingTemplate | null>(null);
|
||||
const [upload, setUpload] = useState<TemplateUploadState | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const readGeneration = useRef(0);
|
||||
const readController = useRef<AbortController | null>(null);
|
||||
@@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function openUpload() {
|
||||
if (writesDisabled || !snapshot) return;
|
||||
setUpload({
|
||||
rows: [],
|
||||
errors: {},
|
||||
submitting: false,
|
||||
error: '',
|
||||
results: null,
|
||||
});
|
||||
}
|
||||
|
||||
function closeUpload() {
|
||||
if (upload?.submitting) return;
|
||||
setUpload(null);
|
||||
}
|
||||
|
||||
async function submitUpload(rows: TemplateUploadRow[]) {
|
||||
const revision = snapshot?.revision;
|
||||
if (
|
||||
!revision ||
|
||||
writing.current ||
|
||||
loading ||
|
||||
conflict ||
|
||||
!snapshot?.writable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setUpload((current) => (current ? { ...current, errors } : current));
|
||||
return;
|
||||
}
|
||||
writing.current = true;
|
||||
setUpload((current) =>
|
||||
current
|
||||
? { ...current, submitting: true, errors: {}, error: '', results: null }
|
||||
: current,
|
||||
);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const confirmed = await confirmWrite({
|
||||
action: '上传模板',
|
||||
target: `${rows.length} 个模板(发布后不可删除,只能下架)`,
|
||||
});
|
||||
if (!confirmed || !mounted.current) return;
|
||||
const response = await importAdminAgcTemplates(
|
||||
token,
|
||||
buildTemplateImportFormData(revision, rows),
|
||||
);
|
||||
if (!mounted.current) return;
|
||||
readGeneration.current += 1;
|
||||
readController.current?.abort();
|
||||
setSnapshot({
|
||||
revision: response.revision,
|
||||
writable: response.writable,
|
||||
templates: response.templates,
|
||||
});
|
||||
setConflict(false);
|
||||
setNotice(`已导入 ${response.imported.length} 个模板`);
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
submitting: false,
|
||||
rows: [],
|
||||
results: response.imported,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
const conflictNow = isAdminApiError(error) && error.status === 409;
|
||||
setConflict(conflictNow);
|
||||
const message = formatUploadError(error);
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false, error: message } : current,
|
||||
);
|
||||
// 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
handlePageError(error, unauthorized.current, setError);
|
||||
}
|
||||
} finally {
|
||||
writing.current = false;
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false } : current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
|
||||
const entries = (snapshot?.templates ?? []).filter((entry) => {
|
||||
const searchable = [entry.id, entry.title, ...entry.tags]
|
||||
@@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
|
||||
<div className="admin-page-heading">
|
||||
<h2>模板管理</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
|
||||
@@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
) : (
|
||||
confirmDialog
|
||||
)}
|
||||
{upload ? (
|
||||
<Modal
|
||||
open
|
||||
title="上传模板"
|
||||
description={`一批最多 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板;发布后不可删除,只能下架`}
|
||||
closeLabel="关闭模板上传"
|
||||
onClose={closeUpload}
|
||||
closeOnEscape={!upload.submitting}
|
||||
closeOnBackdrop={!upload.submitting}
|
||||
className="admin-agc-template-upload-dialog genarrative-ui"
|
||||
>
|
||||
<TemplateUploadDialog
|
||||
state={upload}
|
||||
disabled={writesDisabled}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
conflict={conflict}
|
||||
onRowsChange={(rows) =>
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
rows,
|
||||
errors: validateTemplateUploadRows(rows),
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onSubmit={() => void submitUpload(upload.rows)}
|
||||
onClose={closeUpload}
|
||||
onRefresh={() => void refresh()}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatUploadError(error: unknown) {
|
||||
const message = formatAdminApiError(error);
|
||||
if (isAdminApiError(error) && error.status === 409) {
|
||||
return `${message}(模板库已更新,请刷新列表后重试)`;
|
||||
}
|
||||
if (isAdminApiError(error) && error.status === 503) {
|
||||
return `${message}(发布锁可能仍被占用,请联系运维核对)`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function TemplateUploadDialog({
|
||||
state,
|
||||
disabled,
|
||||
readOnly,
|
||||
busy,
|
||||
conflict,
|
||||
onRowsChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
state: TemplateUploadState;
|
||||
disabled: boolean;
|
||||
readOnly: boolean;
|
||||
busy: boolean;
|
||||
conflict: boolean;
|
||||
onRowsChange: (rows: TemplateUploadRow[]) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const uploadBusy = state.submitting;
|
||||
const rowErrorCount = Object.keys(state.errors).length;
|
||||
|
||||
function appendZipFiles(files: File[]) {
|
||||
const known = new Set(state.rows.map((row) => row.key));
|
||||
const next = [...state.rows];
|
||||
for (const file of files) {
|
||||
if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue;
|
||||
known.add(file.name);
|
||||
next.push(deriveTemplateUploadRow(file));
|
||||
}
|
||||
onRowsChange(next);
|
||||
}
|
||||
|
||||
function updateRow(key: string, patch: Partial<TemplateUploadRow>) {
|
||||
onRowsChange(
|
||||
state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法上传</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-upload-pickers">
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>模板包(可多选 .zip)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
appendZipFiles([...(event.currentTarget.files ?? [])]);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>封面(可多选,按同名模板 ID 匹配 PNG / JPEG / WebP)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
onRowsChange(
|
||||
attachCoverFiles(state.rows, [
|
||||
...(event.currentTarget.files ?? []),
|
||||
]),
|
||||
);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{state.rows.length === 0 ? (
|
||||
<Status tone="info">
|
||||
还没有选择模板包。简介、标签与引擎可在上传后用「编辑」补齐。
|
||||
</Status>
|
||||
) : (
|
||||
<div className="admin-agc-template-upload-scroll">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>模板包</TableHeader>
|
||||
<TableHeader>ID</TableHeader>
|
||||
<TableHeader>名称</TableHeader>
|
||||
<TableHeader>版本</TableHeader>
|
||||
<TableHeader>运行时</TableHeader>
|
||||
<TableHeader>entry</TableHeader>
|
||||
<TableHeader>封面</TableHeader>
|
||||
<TableHeader>操作</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{state.rows.map((row) => {
|
||||
const rowError = state.errors[row.key];
|
||||
const rowDisabled = disabled || uploadBusy;
|
||||
return (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<div className="admin-agc-template-upload-file">
|
||||
{row.zipFile.name}
|
||||
</div>
|
||||
{rowError ? (
|
||||
<div
|
||||
className="admin-agc-template-upload-row-error"
|
||||
role="alert"
|
||||
>
|
||||
{rowError}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="ID"
|
||||
value={row.id}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, { id: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="名称"
|
||||
value={row.title}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
title: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="版本"
|
||||
value={row.templateVersion}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
templateVersion: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={row.runtime}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
runtime: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TEMPLATE_UPLOAD_RUNTIMES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="entry"
|
||||
value={row.entry}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
entry: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.coverFile ? (
|
||||
<span className="admin-agc-template-upload-file">
|
||||
{row.coverFile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Status tone="warning">未匹配封面</Status>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={rowDisabled}
|
||||
onClick={() =>
|
||||
onRowsChange(
|
||||
state.rows.filter((entry) => entry.key !== row.key),
|
||||
)
|
||||
}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{state.error ? (
|
||||
<Status tone="error" role="alert">
|
||||
{state.error}
|
||||
</Status>
|
||||
) : null}
|
||||
{conflict ? (
|
||||
<Button variant="secondary" disabled={busy} onClick={onRefresh}>
|
||||
刷新列表后重试
|
||||
</Button>
|
||||
) : null}
|
||||
{state.results ? (
|
||||
<Status tone="success">
|
||||
{`已导入 ${state.results.length} 个模板:${state.results
|
||||
.map((result) => `${result.id}@${result.templateVersion}`)
|
||||
.join('、')}`}
|
||||
</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-dialog-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploadBusy}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
disabled ||
|
||||
uploadBusy ||
|
||||
state.rows.length === 0 ||
|
||||
rowErrorCount > 0
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{uploadBusy ? '上传中…' : `上传 ${state.rows.length} 个模板`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AdminApiError,
|
||||
listAdminAgcTrackingEvents,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
|
||||
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async (original) => ({
|
||||
...(await original<typeof import('../api/adminApiClient')>()),
|
||||
listAdminAgcTrackingEvents: vi.fn(),
|
||||
}));
|
||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||
AdminUserReferenceButton: () => null,
|
||||
}));
|
||||
|
||||
const entry: AdminAgcTrackingEventEntry = {
|
||||
eventId: 'event-1',
|
||||
schemaVersion: 1,
|
||||
eventName: 'project_save',
|
||||
eventTime: '2026-09-21T12:00:00.123Z',
|
||||
userId: 'user-1',
|
||||
editorSessionId: 'session-1',
|
||||
projectId: 'project-1',
|
||||
creativeTaskId: 'project-1',
|
||||
agentRunId: null,
|
||||
agentTurnId: null,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
source: 'gui',
|
||||
clientVersion: '1.0',
|
||||
properties: { reason: 'manual' },
|
||||
batchId: 'batch-1',
|
||||
receivedAt: '2026-09-21T12:15:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminAgcTrackingEvents).mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
|
||||
vi.mocked(listAdminAgcTrackingEvents)
|
||||
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
entries: [{ ...entry, eventId: 'event-2' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
|
||||
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('保存项目', { selector: 'td' });
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
fireEvent.click(screen.getByText('查看详情'));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getAllByText('—').length).toBe(3);
|
||||
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
|
||||
fireEvent.click(screen.getByText('下一页'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||
cursor: 'cursor-page-2',
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByText('上一页'));
|
||||
await screen.findByText('第 1 页');
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
|
||||
fireEvent.change(screen.getByLabelText('项目 ID'), {
|
||||
target: { value: 'project-2' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('查询'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
projectId: 'project-2',
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('第 1 页')).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
});
|
||||
|
||||
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
|
||||
const unauthorized = vi.fn();
|
||||
vi.mocked(listAdminAgcTrackingEvents)
|
||||
.mockResolvedValueOnce({ entries: [], nextCursor: null })
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '登录失效' }),
|
||||
);
|
||||
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
|
||||
await screen.findByText('暂无客户端埋点数据');
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'无权访问客户端埋点',
|
||||
);
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
await waitFor(() =>
|
||||
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { Modal } from '@genarrative/shared/components';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTrackingEventEntry,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
editor_session_start: '编辑器会话开始',
|
||||
editor_session_end: '编辑器会话结束',
|
||||
editor_focus_start: '编辑器获得焦点',
|
||||
editor_focus_end: '编辑器失去焦点',
|
||||
project_create_success: '项目创建成功',
|
||||
project_open: '打开项目',
|
||||
creative_task_submit: '首次提交创作目标',
|
||||
agent_run_completed: 'Agent 运行完成',
|
||||
agent_run_failed: 'Agent 运行失败',
|
||||
project_revision_created: '项目产生修改',
|
||||
preview_ready: '预览就绪',
|
||||
project_save: '保存项目',
|
||||
};
|
||||
|
||||
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
||||
eventId: '事件 ID',
|
||||
schemaVersion: '事件版本',
|
||||
eventName: '事件类型',
|
||||
eventTime: '发生时间',
|
||||
userId: '用户 ID',
|
||||
editorSessionId: '编辑器会话 ID',
|
||||
projectId: '项目 ID',
|
||||
creativeTaskId: '创作目标 ID',
|
||||
agentRunId: 'Agent run ID',
|
||||
agentTurnId: 'Agent turn ID',
|
||||
status: '结果',
|
||||
errorCode: '错误码',
|
||||
source: '来源',
|
||||
clientVersion: '客户端版本',
|
||||
properties: '事件属性',
|
||||
batchId: '批次 ID',
|
||||
receivedAt: '入库时间',
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function AdminAgcTrackingPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}) {
|
||||
const [filters, setFilters] = useState({
|
||||
userId: '',
|
||||
projectId: '',
|
||||
eventName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
|
||||
const [cursors, setCursors] = useState<Array<string | undefined>>([
|
||||
undefined,
|
||||
]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
|
||||
const cursor = cursors[page];
|
||||
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
|
||||
const firstPage = useRef<{
|
||||
token: string;
|
||||
query: AdminAgcTrackingEventQuery;
|
||||
response: AdminAgcTrackingEventListResponse;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = firstPage.current;
|
||||
if (!cursor && saved?.token === token && saved.query === query) {
|
||||
setEntries(saved.response.entries);
|
||||
setNextCursor(saved.response.nextCursor);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setEntries([]);
|
||||
setNextCursor(null);
|
||||
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
if (!cursor) firstPage.current = { token, query, response };
|
||||
setEntries(response.entries);
|
||||
setNextCursor(response.nextCursor);
|
||||
})
|
||||
.catch((failure: unknown) => {
|
||||
if (active) handlePageError(failure, onUnauthorized, setError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, query, cursor, refresh, onUnauthorized]);
|
||||
|
||||
function resetPages() {
|
||||
firstPage.current = null;
|
||||
setCursors([undefined]);
|
||||
setPage(0);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
function search(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
filters.startTime &&
|
||||
filters.endTime &&
|
||||
new Date(filters.startTime) >= new Date(filters.endTime)
|
||||
) {
|
||||
setError('结束时间必须晚于开始时间');
|
||||
return;
|
||||
}
|
||||
resetPages();
|
||||
setQuery({
|
||||
userId: filters.userId.trim(),
|
||||
projectId: filters.projectId.trim(),
|
||||
eventName: filters.eventName,
|
||||
startTime: filters.startTime
|
||||
? new Date(filters.startTime).toISOString()
|
||||
: undefined,
|
||||
endTime: filters.endTime
|
||||
? new Date(filters.endTime).toISOString()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function related(
|
||||
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
|
||||
value: string,
|
||||
) {
|
||||
resetPages();
|
||||
setFilters({
|
||||
userId: '',
|
||||
projectId: '',
|
||||
eventName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
setQuery({ [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>客户端埋点</h2>
|
||||
<p>按入库时间倒序展示</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
resetPages();
|
||||
setRefresh((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<form className="admin-panel admin-form" onSubmit={search}>
|
||||
<div className="admin-filter-grid">
|
||||
{(['userId', 'projectId'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>{fieldLabels[key]}</span>
|
||||
<input
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="admin-field">
|
||||
<span>事件类型</span>
|
||||
<select
|
||||
value={filters.eventName}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, eventName: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{Object.entries(eventLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(['startTime', 'endTime'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>
|
||||
{key === 'startTime'
|
||||
? '发生时间起点(含)'
|
||||
: '发生时间终点(不含)'}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-primary-button"
|
||||
disabled={loading}
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||||
.filter((key) => query[key])
|
||||
.map((key) => (
|
||||
<p key={key}>
|
||||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
{error ? (
|
||||
<p role="alert" className="admin-error-message">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'入库时间',
|
||||
'发生时间',
|
||||
'用户',
|
||||
'事件名称',
|
||||
'项目',
|
||||
'来源',
|
||||
'结果',
|
||||
'客户端版本',
|
||||
'详情',
|
||||
].map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.eventId}>
|
||||
<td>{formatTime(entry.receivedAt)}</td>
|
||||
<td>{formatTime(entry.eventTime)}</td>
|
||||
<td>
|
||||
{entry.userId}
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</td>
|
||||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||||
<td>{entry.projectId ?? '—'}</td>
|
||||
<td>{entry.source}</td>
|
||||
<td>{entry.status ?? '—'}</td>
|
||||
<td>{entry.clientVersion}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-ghost-button"
|
||||
onClick={() => setDetail(entry)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status">加载中…</p>
|
||||
) : !error && entries.length === 0 ? (
|
||||
<p>暂无客户端埋点数据</p>
|
||||
) : null}
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || page === 0}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {page + 1} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || !nextCursor}
|
||||
onClick={() => {
|
||||
if (!nextCursor) return;
|
||||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||||
setPage(page + 1);
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{detail ? (
|
||||
<Modal
|
||||
open
|
||||
title="客户端埋点详情"
|
||||
closeLabel="关闭详情"
|
||||
onClose={() => setDetail(null)}
|
||||
className="genarrative-ui"
|
||||
>
|
||||
<dl>
|
||||
{(
|
||||
Object.keys(fieldLabels) as Array<
|
||||
keyof AdminAgcTrackingEventEntry
|
||||
>
|
||||
)
|
||||
.filter((key) => key !== 'properties')
|
||||
.map((key) => (
|
||||
<div key={key}>
|
||||
<dt>{fieldLabels[key]}</dt>
|
||||
<dd style={{ overflowWrap: 'anywhere' }}>
|
||||
{detail[key] == null ? '—' : String(detail[key])}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="admin-action-row">
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||||
(key) =>
|
||||
detail[key] ? (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className="admin-secondary-button"
|
||||
onClick={() => related(key, detail[key]!)}
|
||||
>
|
||||
查询同一{fieldLabels[key]}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<h3>事件属性</h3>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(detail.properties, null, 2)}
|
||||
</pre>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
@@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({
|
||||
'../api/adminApiClient',
|
||||
)),
|
||||
downloadAdminProjectSnapshot: vi.fn(),
|
||||
getAdminProjectSnapshotChannels: vi.fn(),
|
||||
listAdminProjectSnapshots: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = {
|
||||
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(() => {
|
||||
@@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||
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('远端清单读取失败'))
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20 },
|
||||
{ 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 () => {
|
||||
@@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'dev',
|
||||
'user-1',
|
||||
'project-1',
|
||||
expect.any(AbortSignal),
|
||||
@@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
|
||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||
(_token, _user, _project, signal) =>
|
||||
(_token, _channel, _user, _project, signal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
@@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||
expect(
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
||||
).toBe(true);
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||
@@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
||||
await waitFor(() =>
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||
view.rerender(
|
||||
<AdminProjectSnapshotsPage
|
||||
@@ -278,7 +421,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
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 {
|
||||
@@ -31,21 +39,32 @@ const snapshotStatuses = {
|
||||
},
|
||||
};
|
||||
|
||||
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 = null) => {
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
@@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 20 },
|
||||
{ cursor, limit, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setItems((current) => {
|
||||
if (!cursor) return response.items;
|
||||
const entries = new Map(
|
||||
current.map((entry) => [snapshotKey(entry), entry]),
|
||||
);
|
||||
response.items.forEach((entry) =>
|
||||
entries.set(snapshotKey(entry), entry),
|
||||
);
|
||||
return [...entries.values()];
|
||||
});
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, onUnauthorized],
|
||||
[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();
|
||||
void loadPage(null, pageSize, 1);
|
||||
return () => {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
};
|
||||
}, [loadPage]);
|
||||
}, [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;
|
||||
@@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
token,
|
||||
channel ?? '',
|
||||
entry.userId,
|
||||
entry.projectId,
|
||||
controller.signal,
|
||||
@@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
|
||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
||||
const visibleChannelOptions = channelOptions.length
|
||||
? channelOptions
|
||||
: channel
|
||||
? [channel]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h2>项目工程</h2>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
<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">
|
||||
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>用户 ID</th>
|
||||
<th>用户</th>
|
||||
<th>同步时间</th>
|
||||
<th>文件数</th>
|
||||
<th>体积</th>
|
||||
@@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({
|
||||
<strong>{entry.projectName || entry.projectId}</strong>
|
||||
<small>{entry.projectId}</small>
|
||||
</td>
|
||||
<td data-label="用户 ID">{entry.userId}</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', {
|
||||
@@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({
|
||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||
<p className="admin-muted-text">暂无已上传项目</p>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage(nextCursor)}
|
||||
>
|
||||
{isLoading ? '加载中' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
{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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -88,10 +88,60 @@
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
border-radius: 10px;
|
||||
max-width: min(1180px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-pickers {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker input[type='file'] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
|
||||
.admin-agc-template-upload-scroll {
|
||||
max-height: min(52vh, 520px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-file {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-row-error {
|
||||
margin-top: 4px;
|
||||
color: #b3261e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-template-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
max-width: calc(100vw - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -1544,15 +1594,15 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:first-child {
|
||||
width: 20%;
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(2) {
|
||||
width: 14%;
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(3) {
|
||||
width: 18%;
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-table th:nth-child(4) {
|
||||
@@ -1639,6 +1689,21 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination-info {
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-project-snapshot-pagination .admin-field {
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.admin-recharge-table {
|
||||
min-width: 1080px;
|
||||
table-layout: fixed;
|
||||
|
||||
@@ -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",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
@@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
/**
|
||||
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
|
||||
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
*/
|
||||
function readProductName() {
|
||||
const read = (file) =>
|
||||
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
|
||||
const base = read('tauri.conf.json');
|
||||
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
|
||||
const productName = fs.existsSync(macosPath)
|
||||
? (read('tauri.macos.conf.json').productName ?? base.productName)
|
||||
: base.productName;
|
||||
function resolveProductName(channel) {
|
||||
const { productName } = resolveChannelInstallIdentity(channel);
|
||||
assert.ok(
|
||||
typeof productName === 'string' && productName.trim().length > 0,
|
||||
'Tauri 配置缺少 productName',
|
||||
'渠道安装身份缺少 productName',
|
||||
);
|
||||
return productName;
|
||||
}
|
||||
|
||||
const productName = readProductName();
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||
assert.equal(
|
||||
process.env.JENKINS_URL?.length > 0,
|
||||
@@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const productName = resolveProductName(context.channel);
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
resolveChannelInstallIdentity,
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -88,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -164,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||
if (
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
@@ -427,12 +410,19 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
/**
|
||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
||||
*/
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
@@ -877,14 +867,19 @@ export async function buildRelease(
|
||||
args = [],
|
||||
{
|
||||
prepareVersion = prepareReleaseVersion,
|
||||
prepareToolset = prepareNsisToolsetForRelease,
|
||||
build = runTauriBuild,
|
||||
generateManifest = generateUpdateManifest,
|
||||
} = {},
|
||||
) {
|
||||
const context = resolveReleaseContext(args);
|
||||
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||||
const bundling = !args.includes('--no-bundle');
|
||||
if (bundling) await prepareVersion(context);
|
||||
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
|
||||
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
|
||||
if (bundling) await prepareToolset(context, { bundling });
|
||||
build(args, context);
|
||||
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||||
if (bundling) return generateManifest(context);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
productName: '陶泥儿 Release',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.release`,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
|
||||
productName: '陶泥儿 Beta-2',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
|
||||
});
|
||||
|
||||
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
|
||||
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
|
||||
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
|
||||
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
|
||||
}
|
||||
|
||||
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
|
||||
assert.throws(
|
||||
() => resolveChannelInstallIdentity(channel),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel install identity is baked into the same build-time config as the endpoint', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
const config = createChannelConfig('release', windowsTarget);
|
||||
assert.equal(config.productName, '陶泥儿 Release');
|
||||
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
|
||||
assert.match(
|
||||
config.plugins.updater.endpoints[0],
|
||||
/\/release-win\/latest\.json$/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('channel products keep first-install selection working under the channel product name', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
|
||||
try {
|
||||
const { productName } = resolveChannelInstallIdentity('release');
|
||||
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
|
||||
writeFileSync(dmg, 'channel first installation disk image');
|
||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||
assert.equal(
|
||||
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: packageVersion,
|
||||
}),
|
||||
dmg,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||
const context = resolveReleaseContext([], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
@@ -590,6 +659,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
|
||||
const events = [];
|
||||
await buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: (context, options) => {
|
||||
events.push(`toolset:${context.target}:${options.bundling}`);
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'version',
|
||||
`toolset:${windowsTarget}:true`,
|
||||
'build',
|
||||
'manifest',
|
||||
]);
|
||||
});
|
||||
|
||||
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
}),
|
||||
/NSIS 工具链预置失败/u,
|
||||
);
|
||||
assert.deepEqual(events, ['version']);
|
||||
});
|
||||
|
||||
test('--no-bundle 不预置 NSIS 工具链', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
steps.push('toolset');
|
||||
},
|
||||
build: () => {
|
||||
steps.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(steps, ['build']);
|
||||
});
|
||||
|
||||
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||
const events = [];
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AGC 渠道 → 安装身份。
|
||||
*
|
||||
* 渠道同时决定两件事:
|
||||
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
|
||||
* - 安装身份:`productName` 与 `identifier`。
|
||||
*
|
||||
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
|
||||
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
export const AGC_PRODUCT_NAME = '陶泥儿';
|
||||
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
|
||||
export function validateReleaseChannel(channel) {
|
||||
if (
|
||||
typeof channel !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
appIdentifier,
|
||||
defaultRealSwarmTestTask,
|
||||
} from './agent-swarm-test-chat.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import {
|
||||
askHidden,
|
||||
assertSafeGameCreatorConfigDestination,
|
||||
@@ -1308,7 +1313,8 @@ if (
|
||||
}
|
||||
|
||||
for (const requiredSource of [
|
||||
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
||||
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
|
||||
'export const appIdentifier = AGC_APP_IDENTIFIER',
|
||||
"'--swarm-chat'",
|
||||
"'--autonomous-game-build'",
|
||||
"'--preview-serve'",
|
||||
@@ -1319,14 +1325,38 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
|
||||
// 的升级链路与既有安装目录都会断开。
|
||||
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
|
||||
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
|
||||
throw new Error('AI game creator shell identifier drifted');
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.productName !== defaultChannelIdentity.productName ||
|
||||
tauriConfig.identifier !== defaultChannelIdentity.identifier
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell baseline config must match the default channel identity',
|
||||
);
|
||||
}
|
||||
|
||||
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
if (
|
||||
identity.productName === defaultChannelIdentity.productName ||
|
||||
identity.identifier === defaultChannelIdentity.identifier ||
|
||||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
|
||||
) {
|
||||
throw new Error(`channel install identity not isolated: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
...Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
|
||||
//
|
||||
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
|
||||
// 把网络问题伪装成打包问题。
|
||||
|
||||
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
|
||||
|
||||
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
|
||||
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
|
||||
// 最终失败才写 stderr 并以退出码 1 失败关闭。
|
||||
const logger = {
|
||||
log: (message) => console.log(message),
|
||||
warn: (message) => console.log(`${message}(将重试)`),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ensureNsisToolset({ logger });
|
||||
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
|
||||
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
|
||||
console.log(
|
||||
result.reused
|
||||
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
|
||||
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user