Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c05428ec | |||
| 91f1554ac7 | |||
| 6a0b75779b |
@@ -174,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
**DirectProject 专属聊天模块**:
|
|
||||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
|
||||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
|
||||||
|
|
||||||
**项目工作台布局**:
|
|
||||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
|
||||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
createAdminAccount,
|
createAdminAccount,
|
||||||
executeAdminRechargeRefund,
|
executeAdminRechargeRefund,
|
||||||
getAdminAgcTemplates,
|
|
||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
importAdminAgcTemplates,
|
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
updateAdminAccount,
|
updateAdminAccount,
|
||||||
updateAdminAgcTemplate,
|
|
||||||
uploadAdminEditorShowcaseCampaignImage,
|
uploadAdminEditorShowcaseCampaignImage,
|
||||||
upsertAdminFeatureGateConfig,
|
upsertAdminFeatureGateConfig,
|
||||||
upsertProfileWalletConfig,
|
upsertProfileWalletConfig,
|
||||||
@@ -21,93 +18,6 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
|
||||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ ok: true, data: library }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
|
|
||||||
library,
|
|
||||||
);
|
|
||||||
const update = {
|
|
||||||
expectedRevision: 'revision-old',
|
|
||||||
title: '空白模板',
|
|
||||||
summary: '简介',
|
|
||||||
tags: ['2D'],
|
|
||||||
enabled: true,
|
|
||||||
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
await updateAdminAgcTemplate('admin-token', 'template/1', update),
|
|
||||||
).toEqual(library);
|
|
||||||
expect(fetchMock.mock.calls[0]).toEqual([
|
|
||||||
'/admin/api/agc-templates',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(fetchMock.mock.calls[1]).toEqual([
|
|
||||||
'/admin/api/agc-templates/template%2F1',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'PUT',
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}),
|
|
||||||
body: JSON.stringify(update),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
|
||||||
const imported = {
|
|
||||||
revision: 'rev-2',
|
|
||||||
writable: true,
|
|
||||||
templates: [],
|
|
||||||
imported: [
|
|
||||||
{
|
|
||||||
id: 'alpha',
|
|
||||||
templateVersion: '0.1.0',
|
|
||||||
zipSizeBytes: 4,
|
|
||||||
zipSha256: 'a'.repeat(64),
|
|
||||||
reusedObjects: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append(
|
|
||||||
'manifest',
|
|
||||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
|
||||||
);
|
|
||||||
form.append(
|
|
||||||
'zip_0',
|
|
||||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
|
||||||
const [url, init] = fetchMock.mock.calls[0]!;
|
|
||||||
expect(url).toBe('/admin/api/agc-templates/import');
|
|
||||||
expect(init.method).toBe('POST');
|
|
||||||
expect(init.body).toBe(form);
|
|
||||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
|
||||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
const fetchMock = vi.fn().mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAccountListResponse,
|
AdminAccountListResponse,
|
||||||
AdminAgcTemplateLibraryResponse,
|
|
||||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||||
AdminCreateAccountRequest,
|
AdminCreateAccountRequest,
|
||||||
AdminCreateAccountResponse,
|
AdminCreateAccountResponse,
|
||||||
@@ -30,11 +29,9 @@ import type {
|
|||||||
AdminExternalApiKeyListQuery,
|
AdminExternalApiKeyListQuery,
|
||||||
AdminExternalApiKeyListResponse,
|
AdminExternalApiKeyListResponse,
|
||||||
AdminFeatureGateConfigResponse,
|
AdminFeatureGateConfigResponse,
|
||||||
AdminImportAgcTemplatesResponse,
|
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
AdminProjectSnapshotChannelsResponse,
|
|
||||||
AdminProjectSnapshotListQuery,
|
AdminProjectSnapshotListQuery,
|
||||||
AdminProjectSnapshotListResponse,
|
AdminProjectSnapshotListResponse,
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
@@ -50,7 +47,6 @@ import type {
|
|||||||
AdminTrackingEventListResponse,
|
AdminTrackingEventListResponse,
|
||||||
AdminUpdateAccountRequest,
|
AdminUpdateAccountRequest,
|
||||||
AdminUpdateAccountResponse,
|
AdminUpdateAccountResponse,
|
||||||
AdminUpdateAgcTemplateRequest,
|
|
||||||
AdminUploadedEditorShowcaseCampaignImage,
|
AdminUploadedEditorShowcaseCampaignImage,
|
||||||
AdminUpsertEditorShowcaseCampaignRequest,
|
AdminUpsertEditorShowcaseCampaignRequest,
|
||||||
AdminUpsertFeatureGateConfigRequest,
|
AdminUpsertFeatureGateConfigRequest,
|
||||||
@@ -89,8 +85,6 @@ interface AdminRequestOptions {
|
|||||||
method?: string;
|
method?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
|
||||||
formData?: FormData;
|
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
@@ -178,8 +172,6 @@ export async function request<T>(
|
|||||||
if (typeof options.body !== 'undefined') {
|
if (typeof options.body !== 'undefined') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
init.body = JSON.stringify(options.body);
|
init.body = JSON.stringify(options.body);
|
||||||
} else if (options.formData) {
|
|
||||||
init.body = options.formData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(buildRequestUrl(path), init);
|
const response = await fetch(buildRequestUrl(path), init);
|
||||||
@@ -215,7 +207,6 @@ export function listAdminProjectSnapshots(
|
|||||||
) {
|
) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query.cursor) params.set('cursor', query.cursor);
|
if (query.cursor) params.set('cursor', query.cursor);
|
||||||
if (query.channel) params.set('channel', query.channel);
|
|
||||||
params.set('limit', String(query.limit ?? 20));
|
params.set('limit', String(query.limit ?? 20));
|
||||||
return request<AdminProjectSnapshotListResponse>(
|
return request<AdminProjectSnapshotListResponse>(
|
||||||
`/admin/api/project-snapshots?${params.toString()}`,
|
`/admin/api/project-snapshots?${params.toString()}`,
|
||||||
@@ -223,27 +214,13 @@ export function listAdminProjectSnapshots(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminProjectSnapshotChannels(
|
|
||||||
token: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
return request<AdminProjectSnapshotChannelsResponse>(
|
|
||||||
'/admin/api/project-snapshots/channels',
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAdminProjectSnapshot(
|
export async function downloadAdminProjectSnapshot(
|
||||||
token: string,
|
token: string,
|
||||||
channel: string,
|
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams();
|
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||||
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), {
|
const response = await fetch(buildRequestUrl(path), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
@@ -1199,33 +1176,3 @@ export function saveAgcModelCatalog(
|
|||||||
{ token, method: 'PUT', body },
|
{ token, method: 'PUT', body },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
|
||||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
|
||||||
token,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAdminAgcTemplate(
|
|
||||||
token: string,
|
|
||||||
id: string,
|
|
||||||
body: AdminUpdateAgcTemplateRequest,
|
|
||||||
) {
|
|
||||||
return request<AdminAgcTemplateLibraryResponse>(
|
|
||||||
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
|
|
||||||
{ token, method: 'PUT', body },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
|
||||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
|
||||||
return request<AdminImportAgcTemplatesResponse>(
|
|
||||||
'/admin/api/agc-templates/import',
|
|
||||||
{
|
|
||||||
token,
|
|
||||||
method: 'POST',
|
|
||||||
formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,15 +105,11 @@ export interface AdminProjectSnapshotEntry {
|
|||||||
fileCount: number;
|
fileCount: number;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
status: 'ready' | 'partial' | 'unverified';
|
status: 'ready' | 'partial' | 'unverified';
|
||||||
channel: string;
|
|
||||||
authorDisplayName?: string | null;
|
|
||||||
authorPublicUserCode?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListQuery {
|
export interface AdminProjectSnapshotListQuery {
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
channel?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListResponse {
|
export interface AdminProjectSnapshotListResponse {
|
||||||
@@ -121,11 +117,6 @@ export interface AdminProjectSnapshotListResponse {
|
|||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotChannelsResponse {
|
|
||||||
defaultChannel: string;
|
|
||||||
channels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -1042,67 +1033,3 @@ export interface AdminAgcModelCatalog {
|
|||||||
defaultModelId: string;
|
defaultModelId: string;
|
||||||
models: AdminAgcModel[];
|
models: AdminAgcModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAgcTemplatePayload {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
runtime: string;
|
|
||||||
engine: string;
|
|
||||||
engineVersion: string;
|
|
||||||
templateVersion: string;
|
|
||||||
enabled: boolean;
|
|
||||||
coverUrl: string;
|
|
||||||
zipSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAgcTemplateLibraryResponse {
|
|
||||||
revision: string;
|
|
||||||
writable: boolean;
|
|
||||||
templates: AdminAgcTemplatePayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUpdateAgcTemplateRequest {
|
|
||||||
expectedRevision: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
cover?: {
|
|
||||||
contentType: string;
|
|
||||||
dataBase64: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplateItemPayload {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
tags: string[];
|
|
||||||
runtime: string;
|
|
||||||
engine: string;
|
|
||||||
engineVersion: string;
|
|
||||||
templateVersion: string;
|
|
||||||
entry: string;
|
|
||||||
zipField: string;
|
|
||||||
coverField: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplatesManifest {
|
|
||||||
expectedRevision: string;
|
|
||||||
templates: AdminImportAgcTemplateItemPayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplateResult {
|
|
||||||
id: string;
|
|
||||||
templateVersion: string;
|
|
||||||
zipSizeBytes: number;
|
|
||||||
zipSha256: string;
|
|
||||||
reusedObjects: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminImportAgcTemplatesResponse
|
|
||||||
extends AdminAgcTemplateLibraryResponse {
|
|
||||||
imported: AdminImportAgcTemplateResult[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from './adminApiClient';
|
} from './adminApiClient';
|
||||||
|
|
||||||
@@ -22,12 +21,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
|||||||
expect(
|
expect(
|
||||||
await listAdminProjectSnapshots(
|
await listAdminProjectSnapshots(
|
||||||
'admin-token',
|
'admin-token',
|
||||||
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
{ cursor: 'user/a+项目', limit: 20 },
|
||||||
controller.signal,
|
controller.signal,
|
||||||
),
|
),
|
||||||
).toEqual(payload);
|
).toEqual(payload);
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -35,23 +34,6 @@ 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 () => {
|
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response('PK\u0003\u0004', {
|
new Response('PK\u0003\u0004', {
|
||||||
@@ -66,7 +48,6 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
'admin-token',
|
'admin-token',
|
||||||
'release',
|
|
||||||
'user/a',
|
'user/a',
|
||||||
'project/b',
|
'project/b',
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -74,7 +55,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
|||||||
expect(archive.filename).toBe('三消-r2.zip');
|
expect(archive.filename).toBe('三消-r2.zip');
|
||||||
expect(archive.blob.type).toBe('application/zip');
|
expect(archive.blob.type).toBe('application/zip');
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
Authorization: 'Bearer admin-token',
|
Authorization: 'Bearer admin-token',
|
||||||
@@ -100,8 +81,7 @@ test.each([
|
|||||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||||
.filename,
|
|
||||||
).toBe(expected.replace('工程', 'project'));
|
).toBe(expected.replace('工程', 'project'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +104,7 @@ test.each([401, 403, 409, 500])(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status,
|
status,
|
||||||
code: 'SNAPSHOT_FAILURE',
|
code: 'SNAPSHOT_FAILURE',
|
||||||
@@ -143,6 +123,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from '../auth/adminAuthStore';
|
} from '../auth/adminAuthStore';
|
||||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
|
||||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||||
@@ -295,12 +294,6 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'agc-models' ? (
|
{activeRouteId === 'agc-models' ? (
|
||||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'agc-templates' ? (
|
|
||||||
<AdminAgcTemplatesPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeRouteId === 'editor-showcase' ? (
|
{activeRouteId === 'editor-showcase' ? (
|
||||||
<AdminEditorShowcaseReviewPage
|
<AdminEditorShowcaseReviewPage
|
||||||
token={token}
|
token={token}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const routeIcons = {
|
|||||||
'project-snapshots': FolderArchive,
|
'project-snapshots': FolderArchive,
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
'agc-templates': Images,
|
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|
||||||
export function AdminShell({
|
export function AdminShell({
|
||||||
|
|||||||
@@ -147,30 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
|
|||||||
}),
|
}),
|
||||||
).not.toContainEqual(route);
|
).not.toContainEqual(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
|
||||||
expect(adminRoutes).toContainEqual({
|
|
||||||
id: 'agc-templates',
|
|
||||||
label: '模板管理',
|
|
||||||
hash: '#agc-templates',
|
|
||||||
});
|
|
||||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
|
||||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
|
||||||
(route) => route.id === 'agc-templates',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['agc-templates'],
|
|
||||||
}).map((route) => route.id),
|
|
||||||
).toEqual(['agc-templates']);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['editor-assets'],
|
|
||||||
}).some((route) => route.id === 'agc-templates'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export type AdminRouteId =
|
|||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
| 'project-snapshots'
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'agc-templates'
|
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
export type AdminTabPermission = Exclude<
|
export type AdminTabPermission = Exclude<
|
||||||
@@ -54,7 +53,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
hash: '#editor-generation-pricing',
|
hash: '#editor-generation-pricing',
|
||||||
},
|
},
|
||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
|
||||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import '@genarrative/shared/styles.css';
|
|
||||||
import './styles/admin.css';
|
import './styles/admin.css';
|
||||||
|
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
AdminApiError,
|
AdminApiError,
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from '../api/adminApiClient';
|
} from '../api/adminApiClient';
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
@@ -24,7 +23,6 @@ vi.mock('../api/adminApiClient', async () => ({
|
|||||||
'../api/adminApiClient',
|
'../api/adminApiClient',
|
||||||
)),
|
)),
|
||||||
downloadAdminProjectSnapshot: vi.fn(),
|
downloadAdminProjectSnapshot: vi.fn(),
|
||||||
getAdminProjectSnapshotChannels: vi.fn(),
|
|
||||||
listAdminProjectSnapshots: vi.fn(),
|
listAdminProjectSnapshots: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -37,18 +35,12 @@ const entry: AdminProjectSnapshotEntry = {
|
|||||||
fileCount: 12,
|
fileCount: 12,
|
||||||
totalBytes: 2048,
|
totalBytes: 2048,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
channel: 'dev',
|
|
||||||
authorDisplayName: '陶泥作者',
|
|
||||||
authorPublicUserCode: 'SY-00000007',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||||
vi.mocked(getAdminProjectSnapshotChannels)
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,166 +88,36 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('按游标翻页回到上一页时复用已取得的游标', async () => {
|
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||||
nextCursor: 'page-3',
|
nextCursor: null,
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
|
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
||||||
|
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||||
await screen.findByText('三消工程');
|
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||||
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('第二工程');
|
await screen.findByText('第二工程');
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
'token',
|
'token',
|
||||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
{ cursor: 'page-2', limit: 20 },
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('三消工程')).toBeNull();
|
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||||
expect(pagination.textContent).toContain('第 2 页');
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
|
||||||
await screen.findByText('三消工程');
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
'token',
|
|
||||||
{ cursor: null, limit: 20, channel: 'dev' },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
expect(pagination.textContent).toContain('第 1 页');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('切换每页条数从第一页按新条数重新加载', async () => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
||||||
nextCursor: 'page-3',
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
await screen.findByText('三消工程');
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
||||||
await screen.findByText('第二工程');
|
|
||||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
|
||||||
expect(pagination.textContent).toContain('第 2 页');
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
|
||||||
target: { value: '50' },
|
|
||||||
});
|
|
||||||
await screen.findByText('三消工程');
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
'token',
|
|
||||||
{ cursor: null, limit: 50, channel: 'dev' },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
// 重新加载时页脚会重建,必须重新取节点再断言。
|
|
||||||
expect(
|
|
||||||
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
|
|
||||||
).toContain('第 1 页');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
||||||
nextCursor: null,
|
|
||||||
})
|
|
||||||
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
await screen.findByText('三消工程');
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
||||||
await screen.findByText('第二工程');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
await screen.findByRole('alert');
|
await screen.findByRole('alert');
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
'token',
|
|
||||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
|
||||||
expect(pagination.textContent).toContain('第 2 页');
|
|
||||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||||
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
await screen.findByText('暂无已上传项目');
|
await screen.findByText('暂无已上传项目');
|
||||||
expect(screen.queryByRole('alert')).toBeNull();
|
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
'token',
|
||||||
{ cursor: null, limit: 20, channel: 'release' },
|
{ cursor: null, limit: 20 },
|
||||||
expect.any(AbortSignal),
|
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 () => {
|
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||||
@@ -292,7 +154,6 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
|||||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||||
'token',
|
'token',
|
||||||
'dev',
|
|
||||||
'user-1',
|
'user-1',
|
||||||
'project-1',
|
'project-1',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
@@ -303,7 +164,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
|||||||
|
|
||||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||||
(_token, _channel, _user, _project, signal) =>
|
(_token, _user, _project, signal) =>
|
||||||
new Promise((_resolve, reject) => {
|
new Promise((_resolve, reject) => {
|
||||||
signal?.addEventListener('abort', () =>
|
signal?.addEventListener('abort', () =>
|
||||||
reject(new DOMException('Aborted', 'AbortError')),
|
reject(new DOMException('Aborted', 'AbortError')),
|
||||||
@@ -316,7 +177,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
|
|||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||||
expect(
|
expect(
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||||
@@ -387,10 +248,6 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
|||||||
onUnauthorized={onUnauthorized}
|
onUnauthorized={onUnauthorized}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
|
||||||
);
|
|
||||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<AdminProjectSnapshotsPage
|
<AdminProjectSnapshotsPage
|
||||||
@@ -421,7 +278,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
|
|||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
|
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||||
view.unmount();
|
view.unmount();
|
||||||
expect(signal?.aborted).toBe(true);
|
expect(signal?.aborted).toBe(true);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import {
|
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
|
||||||
RefreshCcw,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from '../api/adminApiClient';
|
} from '../api/adminApiClient';
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
|
||||||
import { handlePageError } from './pageUtils';
|
import { handlePageError } from './pageUtils';
|
||||||
|
|
||||||
interface AdminProjectSnapshotsPageProps {
|
interface AdminProjectSnapshotsPageProps {
|
||||||
@@ -39,32 +31,21 @@ const snapshotStatuses = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
|
||||||
|
|
||||||
export function AdminProjectSnapshotsPage({
|
export function AdminProjectSnapshotsPage({
|
||||||
token,
|
token,
|
||||||
onUnauthorized,
|
onUnauthorized,
|
||||||
}: AdminProjectSnapshotsPageProps) {
|
}: AdminProjectSnapshotsPageProps) {
|
||||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||||
const listController = useRef<AbortController | null>(null);
|
const listController = useRef<AbortController | null>(null);
|
||||||
const downloadController = useRef<AbortController | null>(null);
|
const downloadController = useRef<AbortController | null>(null);
|
||||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
|
||||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
|
||||||
const pageCursors = useRef<(string | null)[]>([null]);
|
|
||||||
|
|
||||||
const loadPage = useCallback(
|
const loadPage = useCallback(
|
||||||
async (cursor: string | null, limit: number, page: number) => {
|
async (cursor: string | null = null) => {
|
||||||
listController.current?.abort();
|
listController.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
listController.current = controller;
|
listController.current = controller;
|
||||||
@@ -73,16 +54,23 @@ export function AdminProjectSnapshotsPage({
|
|||||||
try {
|
try {
|
||||||
const response = await listAdminProjectSnapshots(
|
const response = await listAdminProjectSnapshots(
|
||||||
token,
|
token,
|
||||||
{ cursor, limit, channel },
|
{ cursor, limit: 20 },
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setItems(response.items);
|
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()];
|
||||||
|
});
|
||||||
setNextCursor(response.nextCursor);
|
setNextCursor(response.nextCursor);
|
||||||
setPageIndex(page);
|
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
|
||||||
if (!controller.signal.aborted)
|
if (!controller.signal.aborted)
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,70 +80,22 @@ export function AdminProjectSnapshotsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[token, onUnauthorized, channel],
|
[token, onUnauthorized],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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([]);
|
setItems([]);
|
||||||
setNextCursor(null);
|
setNextCursor(null);
|
||||||
setPageIndex(1);
|
|
||||||
setHasLoaded(false);
|
setHasLoaded(false);
|
||||||
setDownloadingKey(null);
|
setDownloadingKey(null);
|
||||||
void loadPage(null, pageSize, 1);
|
void loadPage();
|
||||||
return () => {
|
return () => {
|
||||||
listController.current?.abort();
|
listController.current?.abort();
|
||||||
listController.current = null;
|
listController.current = null;
|
||||||
downloadController.current?.abort();
|
downloadController.current?.abort();
|
||||||
downloadController.current = null;
|
downloadController.current = null;
|
||||||
};
|
};
|
||||||
}, [loadPage, pageSize, channel]);
|
}, [loadPage]);
|
||||||
|
|
||||||
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) {
|
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||||
if (downloadController.current || entry.status === 'partial') return;
|
if (downloadController.current || entry.status === 'partial') return;
|
||||||
@@ -166,7 +106,6 @@ export function AdminProjectSnapshotsPage({
|
|||||||
try {
|
try {
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
token,
|
token,
|
||||||
channel ?? '',
|
|
||||||
entry.userId,
|
entry.userId,
|
||||||
entry.projectId,
|
entry.projectId,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -201,42 +140,19 @@ export function AdminProjectSnapshotsPage({
|
|||||||
setDownloadingKey(null);
|
setDownloadingKey(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
|
||||||
const visibleChannelOptions = channelOptions.length
|
|
||||||
? channelOptions
|
|
||||||
: channel
|
|
||||||
? [channel]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-page admin-page-wide">
|
<section className="admin-page admin-page-wide">
|
||||||
<div className="admin-page-heading">
|
<div className="admin-page-heading">
|
||||||
<h2>项目工程</h2>
|
<h2>项目工程</h2>
|
||||||
<div className="admin-action-row">
|
<button
|
||||||
<label className="admin-field admin-field-compact">
|
className="admin-secondary-button"
|
||||||
<span>渠道</span>
|
disabled={isLoading}
|
||||||
<select
|
type="button"
|
||||||
aria-label="项目工程渠道"
|
onClick={() => void loadPage()}
|
||||||
value={channel ?? ''}
|
>
|
||||||
onChange={(event) => setChannel(event.target.value)}
|
<RefreshCcw size={17} aria-hidden="true" />
|
||||||
>
|
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||||
{visibleChannelOptions.map((option) => (
|
</button>
|
||||||
<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>
|
</div>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div className="admin-alert" role="alert">
|
<div className="admin-alert" role="alert">
|
||||||
@@ -253,7 +169,7 @@ export function AdminProjectSnapshotsPage({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>项目</th>
|
<th>项目</th>
|
||||||
<th>用户</th>
|
<th>用户 ID</th>
|
||||||
<th>同步时间</th>
|
<th>同步时间</th>
|
||||||
<th>文件数</th>
|
<th>文件数</th>
|
||||||
<th>体积</th>
|
<th>体积</th>
|
||||||
@@ -271,22 +187,7 @@ export function AdminProjectSnapshotsPage({
|
|||||||
<strong>{entry.projectName || entry.projectId}</strong>
|
<strong>{entry.projectName || entry.projectId}</strong>
|
||||||
<small>{entry.projectId}</small>
|
<small>{entry.projectId}</small>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="用户">
|
<td data-label="用户 ID">{entry.userId}</td>
|
||||||
<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="同步时间">
|
<td data-label="同步时间">
|
||||||
<span>
|
<span>
|
||||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||||
@@ -338,66 +239,23 @@ export function AdminProjectSnapshotsPage({
|
|||||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||||
<p className="admin-muted-text">暂无已上传项目</p>
|
<p className="admin-muted-text">暂无已上传项目</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hasLoaded ? (
|
{nextCursor ? (
|
||||||
<nav
|
<div className="admin-action-row">
|
||||||
className="admin-action-row admin-project-snapshot-pagination"
|
<button
|
||||||
aria-label="项目工程分页"
|
className="admin-secondary-button"
|
||||||
>
|
disabled={isLoading}
|
||||||
<span className="admin-project-snapshot-pagination-info">
|
type="button"
|
||||||
第 {pageIndex} 页
|
onClick={() => void loadPage(nextCursor)}
|
||||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
>
|
||||||
</span>
|
{isLoading ? '加载中' : '加载更多'}
|
||||||
<div className="admin-action-row">
|
</button>
|
||||||
<label className="admin-field admin-field-compact">
|
</div>
|
||||||
<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}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
|
||||||
return (
|
|
||||||
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||||
return `${entry.userId}/${entry.projectId}`;
|
return `${entry.userId}/${entry.projectId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
// @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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -13,137 +13,6 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-agc-templates {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-filters {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-table {
|
|
||||||
min-width: 850px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-table td:nth-child(2) {
|
|
||||||
min-width: 230px;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-cover {
|
|
||||||
display: block;
|
|
||||||
width: 88px;
|
|
||||||
height: 62px;
|
|
||||||
border-radius: 8px;
|
|
||||||
object-fit: cover;
|
|
||||||
background: #f8efe7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-summary {
|
|
||||||
display: -webkit-box;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 8px 0;
|
|
||||||
color: #866954;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-tags,
|
|
||||||
.admin-agc-template-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-tags span {
|
|
||||||
padding: 3px 7px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #f8efe7;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-dialog {
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-editor,
|
|
||||||
.admin-agc-template-conflict {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-cover-preview {
|
|
||||||
display: block;
|
|
||||||
width: min(100%, 300px);
|
|
||||||
max-height: 180px;
|
|
||||||
border-radius: 8px;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #f8efe7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-agc-template-dialog .admin-confirm-backdrop {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -1594,15 +1463,15 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-project-snapshot-table th:first-child {
|
.admin-project-snapshot-table th:first-child {
|
||||||
width: 18%;
|
width: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(2) {
|
.admin-project-snapshot-table th:nth-child(2) {
|
||||||
width: 18%;
|
width: 14%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(3) {
|
.admin-project-snapshot-table th:nth-child(3) {
|
||||||
width: 16%;
|
width: 18%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-project-snapshot-table th:nth-child(4) {
|
.admin-project-snapshot-table th:nth-child(4) {
|
||||||
@@ -1689,21 +1558,6 @@ 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 {
|
.admin-recharge-table {
|
||||||
min-width: 1080px;
|
min-width: 1080px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||||
"build": "node scripts/build-release.mjs",
|
"build": "node scripts/build-release.mjs",
|
||||||
"release:upload": "node scripts/release-upload.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:check": "node scripts/check-skill-pack.mjs",
|
||||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||||
@@ -76,7 +75,6 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.184.1",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vitest": "^0.34.6"
|
"vitest": "^0.34.6"
|
||||||
|
|||||||
@@ -185,12 +185,7 @@ test('nextVersion 只在 patch 位递增', () => {
|
|||||||
|
|
||||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||||
const base = {
|
const base = {
|
||||||
args: [
|
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
'/tmp/a.json',
|
|
||||||
'oss://agc-dev/agc/global-version.json',
|
|
||||||
],
|
|
||||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||||
accessKeyId: 'id',
|
accessKeyId: 'id',
|
||||||
accessKeySecret: 'secret',
|
accessKeySecret: 'secret',
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ import { createInterface } from 'node:readline/promises';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { inflateSync } from 'node:zlib';
|
import { inflateSync } from 'node:zlib';
|
||||||
|
|
||||||
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
|
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||||||
|
|
||||||
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
|
|
||||||
export const appIdentifier = AGC_APP_IDENTIFIER;
|
|
||||||
export const configFileName = 'game-creator.config.json';
|
export const configFileName = 'game-creator.config.json';
|
||||||
export const localConfigFileName = 'game-creator.config.local.json';
|
export const localConfigFileName = 'game-creator.config.local.json';
|
||||||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import {
|
import {
|
||||||
generateUpdateManifest,
|
generateUpdateManifest,
|
||||||
prepareReleaseVersion,
|
prepareReleaseVersion,
|
||||||
resolveManifestPlatformKeys,
|
|
||||||
resolveReleaseContext,
|
resolveReleaseContext,
|
||||||
resolveReleasePartition,
|
resolveReleasePartition,
|
||||||
runTauriBuild,
|
runTauriBuild,
|
||||||
} from './build-release.mjs';
|
} from './build-release.mjs';
|
||||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
|
||||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||||
import {
|
import {
|
||||||
readUpdaterPubkey,
|
readUpdaterPubkey,
|
||||||
@@ -22,13 +20,10 @@ import {
|
|||||||
} from './verify-updater-signature.mjs';
|
} from './verify-updater-signature.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
|
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 universal 包 → 双架构 smoke → 生成 universal DMG
|
||||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||||
*
|
*
|
||||||
* 边界:
|
* 边界:
|
||||||
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
|
|
||||||
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
|
|
||||||
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
|
|
||||||
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||||
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||||
* 未签名 + 未公证必须显式记录而非静默通过;
|
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||||
@@ -40,19 +35,27 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
|||||||
const repoRoot = path.resolve(appRoot, '../..');
|
const repoRoot = path.resolve(appRoot, '../..');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
|
||||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
|
||||||
*/
|
*/
|
||||||
function resolveProductName(channel) {
|
function readProductName() {
|
||||||
const { productName } = resolveChannelInstallIdentity(channel);
|
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;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
typeof productName === 'string' && productName.trim().length > 0,
|
typeof productName === 'string' && productName.trim().length > 0,
|
||||||
'渠道安装身份缺少 productName',
|
'Tauri 配置缺少 productName',
|
||||||
);
|
);
|
||||||
return 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.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
process.env.JENKINS_URL?.length > 0,
|
process.env.JENKINS_URL?.length > 0,
|
||||||
@@ -91,17 +94,11 @@ process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
|||||||
const dryRun = readReleaseDryRun();
|
const dryRun = readReleaseDryRun();
|
||||||
|
|
||||||
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||||
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
|
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
|
||||||
const macTarget = 'aarch64-apple-darwin';
|
|
||||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
|
||||||
const partition = resolveReleasePartition(context.channel, context.target);
|
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);
|
const version = await prepareReleaseVersion(context);
|
||||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
// 首装包名必须保持 `<产品名>_<版本>_universal.dmg`:清单侧按该后缀唯一匹配本次产物。
|
||||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
const firstInstallName = `${productName}_${version}_universal.dmg`;
|
||||||
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
|
|
||||||
|
|
||||||
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||||
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||||
@@ -120,7 +117,7 @@ for (const stale of [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
`--target=${macTarget}`,
|
'--target=universal-apple-darwin',
|
||||||
'--bundles',
|
'--bundles',
|
||||||
'app',
|
'app',
|
||||||
'--ci',
|
'--ci',
|
||||||
@@ -135,13 +132,16 @@ const command = (binary, argv, options = {}) =>
|
|||||||
runTauriBuild(args, context);
|
runTauriBuild(args, context);
|
||||||
|
|
||||||
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||||
command(process.execPath, [
|
for (const architecture of ['arm64', 'x86_64']) {
|
||||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
command(process.execPath, [
|
||||||
app,
|
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||||
'arm64',
|
app,
|
||||||
]);
|
architecture,
|
||||||
|
'--universal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
|
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_universal.dmg`。
|
||||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||||
const dmg = path.join(dmgDirectory, firstInstallName);
|
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||||
@@ -170,7 +170,7 @@ const release = await generateUpdateManifest(context);
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
path.resolve(release.downloadArtifact),
|
path.resolve(release.downloadArtifact),
|
||||||
path.resolve(dmg),
|
path.resolve(dmg),
|
||||||
'首装包必须锁定本次生成的 arm64 DMG',
|
'首装包必须锁定本次生成的 universal DMG',
|
||||||
);
|
);
|
||||||
|
|
||||||
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||||
@@ -266,9 +266,8 @@ fs.writeFileSync(
|
|||||||
firstInstallSha256: dmgHash,
|
firstInstallSha256: dmgHash,
|
||||||
manifest: 'latest.json',
|
manifest: 'latest.json',
|
||||||
},
|
},
|
||||||
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
|
smokes: ['arm64', 'x86_64'],
|
||||||
smokes: ['arm64'],
|
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
|
||||||
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
|
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ import {
|
|||||||
defaultEditorFeatures,
|
defaultEditorFeatures,
|
||||||
withDefaultCargoFeatures,
|
withDefaultCargoFeatures,
|
||||||
} from './cargo-features.mjs';
|
} from './cargo-features.mjs';
|
||||||
import {
|
import { stageNodeRuntimeForTarget } from './stage-node-runtime.mjs';
|
||||||
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));
|
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||||
@@ -93,7 +88,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
|||||||
const defaultOssBaseUrl =
|
const defaultOssBaseUrl =
|
||||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||||
|
|
||||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
const reservedChannelNames = new Set([
|
||||||
|
'win',
|
||||||
|
'mac',
|
||||||
|
'windows',
|
||||||
|
'macos',
|
||||||
|
'darwin',
|
||||||
|
'linux',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||||
@@ -162,6 +164,21 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
|||||||
throw new Error(`不支持的发布目标:${target}`);
|
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(
|
export function resolveReleasePartition(
|
||||||
channel = resolveReleaseChannel(),
|
channel = resolveReleaseChannel(),
|
||||||
@@ -410,19 +427,12 @@ export function buildTauriBuildArguments(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
|
||||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
|
||||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
|
||||||
*/
|
|
||||||
export function createChannelConfig(
|
export function createChannelConfig(
|
||||||
channel = resolveReleaseChannel(),
|
channel = resolveReleaseChannel(),
|
||||||
target = defaultTarget(),
|
target = defaultTarget(),
|
||||||
) {
|
) {
|
||||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
|
||||||
return {
|
return {
|
||||||
productName,
|
|
||||||
identifier,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
updater: {
|
updater: {
|
||||||
endpoints: [updateManifestUrl(channel, target)],
|
endpoints: [updateManifestUrl(channel, target)],
|
||||||
@@ -449,7 +459,8 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
|||||||
export function runTauriBuild(
|
export function runTauriBuild(
|
||||||
args = [],
|
args = [],
|
||||||
context = resolveReleaseContext(args),
|
context = resolveReleaseContext(args),
|
||||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
// 默认按发布目标 stage:universal 需要两份架构运行时,单架构目标行为不变。
|
||||||
|
{ spawn = spawnSync, stageRuntime = stageNodeRuntimeForTarget } = {},
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
explicitBuildTarget(args) &&
|
explicitBuildTarget(args) &&
|
||||||
@@ -867,19 +878,14 @@ export async function buildRelease(
|
|||||||
args = [],
|
args = [],
|
||||||
{
|
{
|
||||||
prepareVersion = prepareReleaseVersion,
|
prepareVersion = prepareReleaseVersion,
|
||||||
prepareToolset = prepareNsisToolsetForRelease,
|
|
||||||
build = runTauriBuild,
|
build = runTauriBuild,
|
||||||
generateManifest = generateUpdateManifest,
|
generateManifest = generateUpdateManifest,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const context = resolveReleaseContext(args);
|
const context = resolveReleaseContext(args);
|
||||||
const bundling = !args.includes('--no-bundle');
|
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||||||
if (bundling) await prepareVersion(context);
|
|
||||||
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
|
|
||||||
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
|
|
||||||
if (bundling) await prepareToolset(context, { bundling });
|
|
||||||
build(args, context);
|
build(args, context);
|
||||||
if (bundling) return generateManifest(context);
|
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ import {
|
|||||||
selectReleaseArtifact,
|
selectReleaseArtifact,
|
||||||
updateManifestUrl,
|
updateManifestUrl,
|
||||||
} from './build-release.mjs';
|
} from './build-release.mjs';
|
||||||
import {
|
|
||||||
AGC_APP_IDENTIFIER,
|
|
||||||
AGC_PRODUCT_NAME,
|
|
||||||
resolveChannelInstallIdentity,
|
|
||||||
} from './channel-identity.mjs';
|
|
||||||
|
|
||||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||||
const universalTarget = 'universal-apple-darwin';
|
const universalTarget = 'universal-apple-darwin';
|
||||||
@@ -189,8 +184,6 @@ 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',
|
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||||
);
|
);
|
||||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||||
productName: AGC_PRODUCT_NAME,
|
|
||||||
identifier: AGC_APP_IDENTIFIER,
|
|
||||||
plugins: {
|
plugins: {
|
||||||
updater: {
|
updater: {
|
||||||
endpoints: [
|
endpoints: [
|
||||||
@@ -211,68 +204,6 @@ 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', () => {
|
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||||
const context = resolveReleaseContext([], {
|
const context = resolveReleaseContext([], {
|
||||||
AGC_BUILD_TARGET: windowsTarget,
|
AGC_BUILD_TARGET: windowsTarget,
|
||||||
@@ -659,71 +590,6 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
|||||||
assert.deepEqual(steps, ['dev']);
|
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', () => {
|
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||||
const context = resolveReleaseContext(['--target', windowsTarget]);
|
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||||
const events = [];
|
const events = [];
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,11 +27,6 @@ import {
|
|||||||
appIdentifier,
|
appIdentifier,
|
||||||
defaultRealSwarmTestTask,
|
defaultRealSwarmTestTask,
|
||||||
} from './agent-swarm-test-chat.mjs';
|
} from './agent-swarm-test-chat.mjs';
|
||||||
import {
|
|
||||||
AGC_APP_IDENTIFIER,
|
|
||||||
AGC_PRODUCT_NAME,
|
|
||||||
resolveChannelInstallIdentity,
|
|
||||||
} from './channel-identity.mjs';
|
|
||||||
import {
|
import {
|
||||||
askHidden,
|
askHidden,
|
||||||
assertSafeGameCreatorConfigDestination,
|
assertSafeGameCreatorConfigDestination,
|
||||||
@@ -107,6 +102,10 @@ const appInvokeSources = readSourceFiles(
|
|||||||
new URL('../src/', import.meta.url),
|
new URL('../src/', import.meta.url),
|
||||||
new Set(['.ts', '.tsx']),
|
new Set(['.ts', '.tsx']),
|
||||||
);
|
);
|
||||||
|
const appEntrypointSource = fs.readFileSync(
|
||||||
|
new URL('../src/main.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
const tauriHandlerSource = fs.readFileSync(
|
const tauriHandlerSource = fs.readFileSync(
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -131,45 +130,6 @@ const rustSharedContractSource = fs.readFileSync(
|
|||||||
);
|
);
|
||||||
const allowedUncalledTauriCommands = [
|
const allowedUncalledTauriCommands = [
|
||||||
'append_direct_project_conversation_message',
|
'append_direct_project_conversation_message',
|
||||||
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
|
||||||
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
|
||||||
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
|
||||||
'answer_game_creator_agent_runtime_user_input',
|
|
||||||
'cancel_game_creator_agent_runtime_task',
|
|
||||||
'chat_with_game_creator_role_agent',
|
|
||||||
'chat_with_game_creator_role_agent_stream',
|
|
||||||
'check_game_creator_llm_config',
|
|
||||||
'confirm_game_creator_agent_runtime_task',
|
|
||||||
'diff_local_project_checkpoint',
|
|
||||||
'get_game_creation_agent_capabilities',
|
|
||||||
'get_limited_local_commands',
|
|
||||||
'list_local_project_export_packages',
|
|
||||||
'read_game_creator_agent_runtime',
|
|
||||||
'read_local_agent_memory',
|
|
||||||
'read_local_game_memory',
|
|
||||||
'reject_game_creator_agent_runtime_task',
|
|
||||||
'retry_game_creator_agent_runtime_task',
|
|
||||||
'schedule_game_creator_agent_ready_tasks',
|
|
||||||
'start_game_creator_agent_runtime_task',
|
|
||||||
'steer_game_creator_agent_runtime_task',
|
|
||||||
'write_local_agent_memory',
|
|
||||||
'write_local_game_memory',
|
|
||||||
'write_local_project_file',
|
|
||||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
|
||||||
'archive_game_creator_agent_session',
|
|
||||||
'clear_game_creator_agent_goal',
|
|
||||||
'compact_game_creator_agent_runtime_context',
|
|
||||||
'confirm_retry_game_creator_agent_runtime_task',
|
|
||||||
'create_game_creator_agent_session',
|
|
||||||
'edit_game_creator_agent_goal',
|
|
||||||
'fork_game_creator_agent_session',
|
|
||||||
'list_game_creator_agent_sessions',
|
|
||||||
'pause_game_creator_agent_goal',
|
|
||||||
'read_game_creator_agent_goal',
|
|
||||||
'resume_game_creator_agent_goal',
|
|
||||||
'set_active_game_creator_agent_session',
|
|
||||||
'start_game_creator_agent_goal',
|
|
||||||
'start_game_creator_supervisor_runtime_task',
|
|
||||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||||
'bind_components',
|
'bind_components',
|
||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
@@ -199,26 +159,6 @@ const allowedUncalledTauriCommands = [
|
|||||||
'call_agc_plugin',
|
'call_agc_plugin',
|
||||||
'read_agc_plugin_panel',
|
'read_agc_plugin_panel',
|
||||||
'set_agc_plugin_enabled',
|
'set_agc_plugin_enabled',
|
||||||
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
|
||||||
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
|
|
||||||
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
|
||||||
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
|
||||||
// native-only 登记,避免孤儿检查一直报错。
|
|
||||||
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
|
||||||
'build_local_project_index',
|
|
||||||
'control_agent_run',
|
|
||||||
'create_local_project_checkpoint',
|
|
||||||
'export_local_project_package',
|
|
||||||
'generate_local_game_draft',
|
|
||||||
'generate_platform_art_asset',
|
|
||||||
'import_canvas_asset',
|
|
||||||
'import_canvas_export',
|
|
||||||
'open_canvas_project',
|
|
||||||
'register_local_asset',
|
|
||||||
'restore_local_project_checkpoint',
|
|
||||||
'run_limited_local_command',
|
|
||||||
'sync_canvas_project_assets',
|
|
||||||
'write_project_permission_policy',
|
|
||||||
];
|
];
|
||||||
const sourceExtensions = new Set([
|
const sourceExtensions = new Set([
|
||||||
'.json',
|
'.json',
|
||||||
@@ -1368,8 +1308,7 @@ if (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const requiredSource of [
|
for (const requiredSource of [
|
||||||
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
|
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
||||||
'export const appIdentifier = AGC_APP_IDENTIFIER',
|
|
||||||
"'--swarm-chat'",
|
"'--swarm-chat'",
|
||||||
"'--autonomous-game-build'",
|
"'--autonomous-game-build'",
|
||||||
"'--preview-serve'",
|
"'--preview-serve'",
|
||||||
@@ -1380,38 +1319,14 @@ 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');
|
throw new Error('AI game creator shell productName drifted');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
|
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||||
throw new Error('AI game creator shell identifier drifted');
|
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 = {
|
const expectedBundledDesignAgentResources = {
|
||||||
'design-agent': 'design-agent',
|
'design-agent': 'design-agent',
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
@@ -1525,7 +1440,13 @@ const eventCapability = JSON.parse(
|
|||||||
);
|
);
|
||||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||||
for (const windowLabel of ['client', 'main', 'launcher']) {
|
for (const windowLabel of [
|
||||||
|
'client',
|
||||||
|
'developer',
|
||||||
|
'main',
|
||||||
|
'launcher',
|
||||||
|
'supervisor-chat',
|
||||||
|
]) {
|
||||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||||
@@ -1908,6 +1829,20 @@ if (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const snippet of [
|
||||||
|
'import.meta.env.DEV',
|
||||||
|
'supervisorChatMode',
|
||||||
|
'supervisorChatOnly',
|
||||||
|
'open_project_supervisor_chat_window',
|
||||||
|
'index.html?supervisor-chat&projectPath=',
|
||||||
|
]) {
|
||||||
|
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
||||||
|
throw new Error(
|
||||||
|
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator normal startup must not automatically open the developer window',
|
'AI game creator normal startup must not automatically open the developer window',
|
||||||
@@ -1940,17 +1875,31 @@ for (const snippet of [
|
|||||||
'官方账号服务(固定)',
|
'官方账号服务(固定)',
|
||||||
'runtime_config.save',
|
'runtime_config.save',
|
||||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||||
|
"'activate_local_game_preview'",
|
||||||
|
'已切换到客户端运行视图',
|
||||||
'async function executeRunLocal',
|
'async function executeRunLocal',
|
||||||
'function needsInitializedChatProject',
|
'function needsInitializedChatProject',
|
||||||
|
'function resolvePendingCommandProjectPath',
|
||||||
|
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
||||||
|
'`permission.cancel ${command.id} missing-project`',
|
||||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||||
'function parseRememberInput',
|
'function parseRememberInput',
|
||||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||||
|
'async function executeAgentTraceChat',
|
||||||
|
"relativePath: '.agent/logs/command.log'",
|
||||||
"'permission.pending'",
|
"'permission.pending'",
|
||||||
"'permission.confirm'",
|
"'permission.confirm'",
|
||||||
"'permission.cancel'",
|
"'permission.cancel'",
|
||||||
"'command.auto'",
|
"'command.auto'",
|
||||||
|
"'agent.run_status'",
|
||||||
'function summarizeAgentRunTrace',
|
'function summarizeAgentRunTrace',
|
||||||
|
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
||||||
|
'agentRunTrace.error ?',
|
||||||
|
'className="trace-error"',
|
||||||
|
'agentRunTrace.taskGraph.repairRoutes.map',
|
||||||
|
"in: ${step.inputPaths.join(', ') || 'none'}",
|
||||||
|
"out: ${step.outputPaths.join(', ') || 'none'}",
|
||||||
]) {
|
]) {
|
||||||
if (!appSource.includes(snippet)) {
|
if (!appSource.includes(snippet)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user