Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 623e007fae | |||
| fc0ce4ee5f | |||
| c1d26f11df | |||
| 15774a1c02 | |||
| e3682fd06f |
@@ -235,6 +235,10 @@ VITE_DEBUG_MODE=""
|
|||||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||||
|
|
||||||
|
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||||
|
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||||
|
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||||
|
|
||||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||||
# the fixed `bge-large-zh` embedding model.
|
# the fixed `bge-large-zh` embedding model.
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import type {
|
|||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
|
AdminProjectSnapshotListQuery,
|
||||||
|
AdminProjectSnapshotListResponse,
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
AdminRechargeOrderListResponse,
|
AdminRechargeOrderListResponse,
|
||||||
AdminRechargeRefundActionResponse,
|
AdminRechargeRefundActionResponse,
|
||||||
@@ -198,6 +200,92 @@ export function listAdminAccounts(token: string) {
|
|||||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAdminProjectSnapshots(
|
||||||
|
token: string,
|
||||||
|
query: AdminProjectSnapshotListQuery = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.cursor) params.set('cursor', query.cursor);
|
||||||
|
params.set('limit', String(query.limit ?? 20));
|
||||||
|
return request<AdminProjectSnapshotListResponse>(
|
||||||
|
`/admin/api/project-snapshots?${params.toString()}`,
|
||||||
|
{ token, signal },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadAdminProjectSnapshot(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
projectId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||||
|
const response = await fetch(buildRequestUrl(path), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
|
Accept: 'application/zip',
|
||||||
|
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
throw buildAdminApiError(
|
||||||
|
response,
|
||||||
|
parseJsonResponse(responseText),
|
||||||
|
responseText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const contentType = response.headers
|
||||||
|
.get('content-type')
|
||||||
|
?.split(';')[0]
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (contentType !== 'application/zip') {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new AdminApiError({
|
||||||
|
message: '下载失败:服务端未返回 ZIP 工程文件',
|
||||||
|
status: response.status,
|
||||||
|
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
blob: await response.blob(),
|
||||||
|
filename: projectArchiveFilename(
|
||||||
|
response.headers.get('content-disposition'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectArchiveFilename(contentDisposition: string | null): string {
|
||||||
|
const extended = contentDisposition?.match(
|
||||||
|
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
|
||||||
|
);
|
||||||
|
const ordinary = contentDisposition?.match(
|
||||||
|
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
|
||||||
|
);
|
||||||
|
let filename =
|
||||||
|
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
|
||||||
|
if (extended?.[1]) {
|
||||||
|
try {
|
||||||
|
filename = decodeURIComponent(extended[1].trim());
|
||||||
|
} catch {
|
||||||
|
// 非法扩展编码继续使用普通文件名。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const safeName = Array.from(filename, (character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code < 32 || code === 127 ? '_' : character;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/, '');
|
||||||
|
if (!safeName || safeName.length > 240) return 'project.zip';
|
||||||
|
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAdminAccount(
|
export function createAdminAccount(
|
||||||
token: string,
|
token: string,
|
||||||
payload: AdminCreateAccountRequest,
|
payload: AdminCreateAccountRequest,
|
||||||
|
|||||||
@@ -96,6 +96,27 @@ export interface AdminMeResponse {
|
|||||||
admin: AdminSessionPayload;
|
admin: AdminSessionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminProjectSnapshotEntry {
|
||||||
|
userId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string | null;
|
||||||
|
syncRevision: number;
|
||||||
|
syncedAtMs: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
status: 'ready' | 'partial' | 'unverified';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProjectSnapshotListQuery {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProjectSnapshotListResponse {
|
||||||
|
items: AdminProjectSnapshotEntry[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
downloadAdminProjectSnapshot,
|
||||||
|
listAdminProjectSnapshots,
|
||||||
|
} from './adminApiClient';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
||||||
|
const payload = { items: [], nextCursor: 'next' };
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ ok: true, data: payload })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const controller = new AbortController();
|
||||||
|
expect(
|
||||||
|
await listAdminProjectSnapshots(
|
||||||
|
'admin-token',
|
||||||
|
{ cursor: 'user/a+项目', limit: 20 },
|
||||||
|
controller.signal,
|
||||||
|
),
|
||||||
|
).toEqual(payload);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response('PK\u0003\u0004', {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/zip',
|
||||||
|
'content-disposition':
|
||||||
|
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
|
'admin-token',
|
||||||
|
'user/a',
|
||||||
|
'project/b',
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
expect(archive.filename).toBe('三消-r2.zip');
|
||||||
|
expect(archive.blob.type).toBe('application/zip');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer admin-token',
|
||||||
|
Accept: 'application/zip',
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
||||||
|
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
||||||
|
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
||||||
|
[null, 'project.zip'],
|
||||||
|
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
||||||
|
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
||||||
|
if (header)
|
||||||
|
headers['content-disposition'] = header.replace('工程', 'project');
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||||
|
).toBe(expected.replace('工程', 'project'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([401, 403, 409, 500])(
|
||||||
|
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
||||||
|
async (status) => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status,
|
||||||
|
code: 'SNAPSHOT_FAILURE',
|
||||||
|
message: '工程尚未同步完成',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response('{"ok":false}', {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
|
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
|||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||||
|
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
||||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||||
@@ -308,6 +309,12 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'accounts' ? (
|
{activeRouteId === 'accounts' ? (
|
||||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeRouteId === 'project-snapshots' ? (
|
||||||
|
<AdminProjectSnapshotsPage
|
||||||
|
token={token}
|
||||||
|
onUnauthorized={handleUnauthorized}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
|
FolderArchive,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Images,
|
Images,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -49,6 +50,7 @@ const routeIcons = {
|
|||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
|
'project-snapshots': FolderArchive,
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|||||||
@@ -122,3 +122,28 @@ test('零权限 member 不回落到 Dashboard', () => {
|
|||||||
expect(routes).toEqual([]);
|
expect(routes).toEqual([]);
|
||||||
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
|
||||||
|
const route = {
|
||||||
|
id: 'project-snapshots',
|
||||||
|
label: '项目工程',
|
||||||
|
hash: '#project-snapshots',
|
||||||
|
};
|
||||||
|
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
|
||||||
|
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
|
||||||
|
expect(
|
||||||
|
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
|
||||||
|
).toContainEqual(route);
|
||||||
|
expect(
|
||||||
|
getAccessibleAdminRoutes({
|
||||||
|
accountRole: 'member',
|
||||||
|
tabPermissions: ['project-snapshots'],
|
||||||
|
}),
|
||||||
|
).toEqual([route]);
|
||||||
|
expect(
|
||||||
|
getAccessibleAdminRoutes({
|
||||||
|
accountRole: 'member',
|
||||||
|
tabPermissions: ['tracking'],
|
||||||
|
}),
|
||||||
|
).not.toContainEqual(route);
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type AdminRouteId =
|
|||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
|
| 'project-snapshots'
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||||
{ 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: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||||
|
);
|
||||||
|
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||||
|
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
||||||
|
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||||
|
'agc:template-library',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||||
|
).toBe('template-library');
|
||||||
|
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||||
|
'0',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -27,9 +27,17 @@ interface GateTargetOption {
|
|||||||
|
|
||||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||||
'image-editor': '画布',
|
'image-editor': '画布',
|
||||||
|
agc: '客户端',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||||
|
{
|
||||||
|
prefix: 'agc',
|
||||||
|
suffix: 'template-library',
|
||||||
|
key: 'agc:template-library',
|
||||||
|
label: '模板库',
|
||||||
|
description: '客户端模板库灰度',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: 'image-editor',
|
prefix: 'image-editor',
|
||||||
suffix: 'agent-sidebar',
|
suffix: 'agent-sidebar',
|
||||||
@@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({
|
|||||||
setSelectedGateKey('');
|
setSelectedGateKey('');
|
||||||
setGatePrefix(option.prefix);
|
setGatePrefix(option.prefix);
|
||||||
setGateKey(option.key);
|
setGateKey(option.key);
|
||||||
setEnabled(false);
|
setEnabled(option.key === 'agc:template-library');
|
||||||
setRolloutPercent('0');
|
setRolloutPercent('0');
|
||||||
setAllowUserIds('');
|
setAllowUserIds('');
|
||||||
setAllowUserTags('');
|
setAllowUserTags('');
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminApiError,
|
||||||
|
downloadAdminProjectSnapshot,
|
||||||
|
listAdminProjectSnapshots,
|
||||||
|
} from '../api/adminApiClient';
|
||||||
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
|
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
||||||
|
|
||||||
|
vi.mock('../api/adminApiClient', async () => ({
|
||||||
|
...(await vi.importActual<typeof import('../api/adminApiClient')>(
|
||||||
|
'../api/adminApiClient',
|
||||||
|
)),
|
||||||
|
downloadAdminProjectSnapshot: vi.fn(),
|
||||||
|
listAdminProjectSnapshots: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const entry: AdminProjectSnapshotEntry = {
|
||||||
|
userId: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
projectName: '三消工程',
|
||||||
|
syncRevision: 3,
|
||||||
|
syncedAtMs: 1_700_000_000_000,
|
||||||
|
fileCount: 12,
|
||||||
|
totalBytes: 2048,
|
||||||
|
status: 'ready',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||||
|
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
entry,
|
||||||
|
{
|
||||||
|
...entry,
|
||||||
|
projectId: 'partial-project',
|
||||||
|
projectName: '未完成工程',
|
||||||
|
status: 'partial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry,
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
projectName: null,
|
||||||
|
status: 'unverified',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||||
|
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
|
||||||
|
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(completeRow)
|
||||||
|
.getByRole('button', { name: '下载完整工程' })
|
||||||
|
.hasAttribute('disabled'),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(screen.getByText('完整性未知')).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole('button', { name: '下载已存文件' })
|
||||||
|
.hasAttribute('disabled'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||||
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||||
|
nextCursor: null,
|
||||||
|
})
|
||||||
|
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
||||||
|
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||||
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||||
|
await screen.findByText('第二工程');
|
||||||
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'token',
|
||||||
|
{ cursor: 'page-2', limit: 20 },
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
|
await screen.findByRole('alert');
|
||||||
|
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
|
await screen.findByText('暂无已上传项目');
|
||||||
|
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||||
|
'token',
|
||||||
|
{ cursor: null, limit: 20 },
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||||
|
const createObjectURL = vi.fn(() => 'blob:archive');
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'URL',
|
||||||
|
class extends URL {
|
||||||
|
static createObjectURL = createObjectURL;
|
||||||
|
static revokeObjectURL = revokeObjectURL;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let savedFilename = '';
|
||||||
|
let savedHref = '';
|
||||||
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||||
|
this: HTMLAnchorElement,
|
||||||
|
) {
|
||||||
|
savedFilename = this.download;
|
||||||
|
savedHref = this.href;
|
||||||
|
});
|
||||||
|
const blob = new Blob(['PK'], { type: 'application/zip' });
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
|
||||||
|
blob,
|
||||||
|
filename: '三消工程-r3.zip',
|
||||||
|
});
|
||||||
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||||
|
const button = await screen.findByRole('button', { name: '下载完整工程' });
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
expect(savedFilename).toBe('三消工程-r3.zip');
|
||||||
|
expect(savedHref).toBe('blob:archive');
|
||||||
|
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||||
|
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||||
|
'token',
|
||||||
|
'user-1',
|
||||||
|
'project-1',
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
act(() => vi.advanceTimersByTime(1000));
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||||
|
(_token, _user, _project, signal) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
signal?.addEventListener('abort', () =>
|
||||||
|
reject(new DOMException('Aborted', 'AbortError')),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = render(
|
||||||
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||||
|
expect(
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||||
|
).toBe(true);
|
||||||
|
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||||
|
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
|
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
|
||||||
|
view.unmount();
|
||||||
|
expect(signal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AdminApiError({ status: 403, message: '无项目工程权限' }),
|
||||||
|
)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AdminApiError({ status: 401, message: '已过期' }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||||
|
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
|
||||||
|
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
|
.mockRejectedValueOnce(new Error('清单存储不可用'))
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AdminApiError({ status: 401, message: '已过期' }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
||||||
|
);
|
||||||
|
await screen.findByText('清单存储不可用');
|
||||||
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
||||||
|
let finishOldRequest!: (value: {
|
||||||
|
items: AdminProjectSnapshotEntry[];
|
||||||
|
nextCursor: null;
|
||||||
|
}) => void;
|
||||||
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishOldRequest = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
items: [{ ...entry, projectName: '新账号工程' }],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<AdminProjectSnapshotsPage
|
||||||
|
token="old-token"
|
||||||
|
onUnauthorized={onUnauthorized}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||||
|
view.rerender(
|
||||||
|
<AdminProjectSnapshotsPage
|
||||||
|
token="new-token"
|
||||||
|
onUnauthorized={onUnauthorized}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await screen.findByText('新账号工程');
|
||||||
|
expect(oldSignal?.aborted).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
finishOldRequest({ items: [entry], nextCursor: null });
|
||||||
|
});
|
||||||
|
expect(screen.queryByText('三消工程')).toBeNull();
|
||||||
|
expect(screen.getByText('新账号工程')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('卸载后完成的下载不会创建浏览器文件', async () => {
|
||||||
|
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
|
||||||
|
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishDownload = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const click = vi
|
||||||
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
const view = render(
|
||||||
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||||
|
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||||
|
view.unmount();
|
||||||
|
expect(signal?.aborted).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
||||||
|
});
|
||||||
|
expect(click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
downloadAdminProjectSnapshot,
|
||||||
|
listAdminProjectSnapshots,
|
||||||
|
} from '../api/adminApiClient';
|
||||||
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
|
import { handlePageError } from './pageUtils';
|
||||||
|
|
||||||
|
interface AdminProjectSnapshotsPageProps {
|
||||||
|
token: string;
|
||||||
|
onUnauthorized: (message?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshotStatuses = {
|
||||||
|
ready: {
|
||||||
|
label: '已同步',
|
||||||
|
className: 'admin-status-ok',
|
||||||
|
action: '下载完整工程',
|
||||||
|
},
|
||||||
|
partial: {
|
||||||
|
label: '同步未完成',
|
||||||
|
className: 'admin-status-pending',
|
||||||
|
action: '同步未完成',
|
||||||
|
},
|
||||||
|
unverified: {
|
||||||
|
label: '完整性未知',
|
||||||
|
className: 'admin-status-pending',
|
||||||
|
action: '下载已存文件',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminProjectSnapshotsPage({
|
||||||
|
token,
|
||||||
|
onUnauthorized,
|
||||||
|
}: AdminProjectSnapshotsPageProps) {
|
||||||
|
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||||
|
const listController = useRef<AbortController | null>(null);
|
||||||
|
const downloadController = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const loadPage = useCallback(
|
||||||
|
async (cursor: string | null = null) => {
|
||||||
|
listController.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
listController.current = controller;
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
try {
|
||||||
|
const response = await listAdminProjectSnapshots(
|
||||||
|
token,
|
||||||
|
{ cursor, limit: 20 },
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setItems((current) => {
|
||||||
|
if (!cursor) return response.items;
|
||||||
|
const entries = new Map(
|
||||||
|
current.map((entry) => [snapshotKey(entry), entry]),
|
||||||
|
);
|
||||||
|
response.items.forEach((entry) =>
|
||||||
|
entries.set(snapshotKey(entry), entry),
|
||||||
|
);
|
||||||
|
return [...entries.values()];
|
||||||
|
});
|
||||||
|
setNextCursor(response.nextCursor);
|
||||||
|
setHasLoaded(true);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!controller.signal.aborted)
|
||||||
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||||
|
} finally {
|
||||||
|
if (listController.current === controller) {
|
||||||
|
listController.current = null;
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, onUnauthorized],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems([]);
|
||||||
|
setNextCursor(null);
|
||||||
|
setHasLoaded(false);
|
||||||
|
setDownloadingKey(null);
|
||||||
|
void loadPage();
|
||||||
|
return () => {
|
||||||
|
listController.current?.abort();
|
||||||
|
listController.current = null;
|
||||||
|
downloadController.current?.abort();
|
||||||
|
downloadController.current = null;
|
||||||
|
};
|
||||||
|
}, [loadPage]);
|
||||||
|
|
||||||
|
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||||
|
if (downloadController.current || entry.status === 'partial') return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
downloadController.current = controller;
|
||||||
|
setDownloadingKey(snapshotKey(entry));
|
||||||
|
setErrorMessage('');
|
||||||
|
try {
|
||||||
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
|
token,
|
||||||
|
entry.userId,
|
||||||
|
entry.projectId,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const objectUrl = URL.createObjectURL(archive.blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = archive.filename;
|
||||||
|
document.body.append(link);
|
||||||
|
try {
|
||||||
|
link.click();
|
||||||
|
} finally {
|
||||||
|
link.remove();
|
||||||
|
// 给浏览器时间接管下载,随后释放临时 URL。
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!controller.signal.aborted)
|
||||||
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||||
|
} finally {
|
||||||
|
if (downloadController.current === controller) {
|
||||||
|
downloadController.current = null;
|
||||||
|
setDownloadingKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDownload() {
|
||||||
|
downloadController.current?.abort();
|
||||||
|
downloadController.current = null;
|
||||||
|
setDownloadingKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-page admin-page-wide">
|
||||||
|
<div className="admin-page-heading">
|
||||||
|
<h2>项目工程</h2>
|
||||||
|
<button
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadPage()}
|
||||||
|
>
|
||||||
|
<RefreshCcw size={17} aria-hidden="true" />
|
||||||
|
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="admin-alert" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<section
|
||||||
|
className="admin-panel admin-stack"
|
||||||
|
aria-label="项目工程列表"
|
||||||
|
aria-busy={isLoading}
|
||||||
|
>
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table admin-project-snapshot-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>项目</th>
|
||||||
|
<th>用户 ID</th>
|
||||||
|
<th>同步时间</th>
|
||||||
|
<th>文件数</th>
|
||||||
|
<th>体积</th>
|
||||||
|
<th>完整性</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((entry) => {
|
||||||
|
const status = snapshotStatuses[entry.status];
|
||||||
|
const isDownloading = downloadingKey === snapshotKey(entry);
|
||||||
|
return (
|
||||||
|
<tr key={snapshotKey(entry)}>
|
||||||
|
<td data-label="项目">
|
||||||
|
<strong>{entry.projectName || entry.projectId}</strong>
|
||||||
|
<small>{entry.projectId}</small>
|
||||||
|
</td>
|
||||||
|
<td data-label="用户 ID">{entry.userId}</td>
|
||||||
|
<td data-label="同步时间">
|
||||||
|
<span>
|
||||||
|
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||||
|
hour12: false,
|
||||||
|
})}
|
||||||
|
<small>版本 {entry.syncRevision}</small>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="文件数">
|
||||||
|
{entry.fileCount.toLocaleString('zh-CN')}
|
||||||
|
</td>
|
||||||
|
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
|
||||||
|
<td data-label="完整性">
|
||||||
|
<span className={`admin-status ${status.className}`}>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label="操作">
|
||||||
|
{isDownloading ? (
|
||||||
|
<button
|
||||||
|
className="admin-secondary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={cancelDownload}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
<span>取消下载</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={
|
||||||
|
entry.status === 'partial' ||
|
||||||
|
downloadingKey !== null
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void downloadProject(entry)}
|
||||||
|
>
|
||||||
|
<Download size={16} aria-hidden="true" />
|
||||||
|
<span>{status.action}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||||
|
<p className="admin-muted-text">暂无已上传项目</p>
|
||||||
|
) : null}
|
||||||
|
{nextCursor ? (
|
||||||
|
<div className="admin-action-row">
|
||||||
|
<button
|
||||||
|
className="admin-secondary-button"
|
||||||
|
disabled={isLoading}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadPage(nextCursor)}
|
||||||
|
>
|
||||||
|
{isLoading ? '加载中' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||||
|
return `${entry.userId}/${entry.projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||||
|
const unit = Math.min(
|
||||||
|
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
|
||||||
|
units.length - 1,
|
||||||
|
);
|
||||||
|
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
|
||||||
|
}
|
||||||
@@ -1452,6 +1452,112 @@ button:disabled {
|
|||||||
min-width: 1180px;
|
min-width: 1180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table {
|
||||||
|
min-width: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th,
|
||||||
|
.admin-project-snapshot-table td {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:first-child {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:nth-child(2) {
|
||||||
|
width: 14%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:nth-child(3) {
|
||||||
|
width: 18%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:nth-child(4) {
|
||||||
|
width: 7%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:nth-child(5) {
|
||||||
|
width: 9%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:nth-child(6) {
|
||||||
|
width: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table th:last-child {
|
||||||
|
width: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.admin-project-snapshot-table,
|
||||||
|
.admin-project-snapshot-table tbody {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table tr {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 10px 16px;
|
||||||
|
border-bottom: 1px solid #eaded2;
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table tr:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td::before {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #8f7868;
|
||||||
|
font-size: 12px;
|
||||||
|
content: attr(data-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td:first-child,
|
||||||
|
.admin-project-snapshot-table td:nth-child(2),
|
||||||
|
.admin-project-snapshot-table td:nth-child(3),
|
||||||
|
.admin-project-snapshot-table td:nth-child(6),
|
||||||
|
.admin-project-snapshot-table td:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td:first-child {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td:first-child::before,
|
||||||
|
.admin-project-snapshot-table td:last-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-project-snapshot-table td:last-child button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-recharge-table {
|
.admin-recharge-table {
|
||||||
min-width: 1080px;
|
min-width: 1080px;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function resolveReleaseContext(args = [], env = process.env) {
|
|||||||
);
|
);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
target,
|
target,
|
||||||
channel: resolveReleaseChannel(env, target),
|
channel: resolveReleaseChannel(env),
|
||||||
bundleRoot: path.join(
|
bundleRoot: path.join(
|
||||||
appRoot,
|
appRoot,
|
||||||
'src-tauri',
|
'src-tauri',
|
||||||
@@ -87,14 +87,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';
|
||||||
|
|
||||||
/**
|
const reservedChannelNames = new Set([
|
||||||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
'win',
|
||||||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
'mac',
|
||||||
*/
|
'windows',
|
||||||
const releaseChannels = {
|
'macos',
|
||||||
'dev-win': 'windows',
|
'darwin',
|
||||||
'dev-mac': 'darwin',
|
'linux',
|
||||||
};
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||||
@@ -162,39 +162,36 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
|||||||
throw new Error(`不支持的发布目标:${target}`);
|
throw new Error(`不支持的发布目标:${target}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveReleaseChannel(
|
export function resolveReleaseChannel(env = process.env) {
|
||||||
env = process.env,
|
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||||
target = defaultTarget(),
|
if (
|
||||||
) {
|
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||||
const platform = resolveReleasePlatform(target);
|
channel.endsWith('-') ||
|
||||||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
reservedChannelNames.has(channel) ||
|
||||||
if (requested) {
|
/-(win|mac)$/u.test(channel)
|
||||||
const channelPlatform = releaseChannels[requested];
|
) {
|
||||||
if (!channelPlatform) {
|
|
||||||
throw new Error(
|
|
||||||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (channelPlatform !== platform) {
|
|
||||||
throw new Error(
|
|
||||||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return requested;
|
|
||||||
}
|
|
||||||
const defaultChannel = Object.entries(releaseChannels).find(
|
|
||||||
([, channelPlatform]) => channelPlatform === platform,
|
|
||||||
)?.[0];
|
|
||||||
if (!defaultChannel) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return defaultChannel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
export function resolveReleasePartition(
|
||||||
|
channel = resolveReleaseChannel(),
|
||||||
|
target = defaultTarget(),
|
||||||
|
) {
|
||||||
|
channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel });
|
||||||
|
validateReleaseTarget(target);
|
||||||
|
return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManifestUrl(
|
||||||
|
channel = resolveReleaseChannel(),
|
||||||
|
target = defaultTarget(),
|
||||||
|
) {
|
||||||
|
return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,8 +242,8 @@ async function readManifestVersion(manifestUrl, label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||||||
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
async function readRemoteChannelManifest(channel, target) {
|
||||||
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
|
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,14 +255,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
|||||||
*/
|
*/
|
||||||
export async function resolvePreviousReleaseCommit(
|
export async function resolvePreviousReleaseCommit(
|
||||||
channel = resolveReleaseChannel(),
|
channel = resolveReleaseChannel(),
|
||||||
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
|
{
|
||||||
|
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
|
||||||
|
target = defaultTarget(),
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const explicit = override?.trim();
|
const explicit = override?.trim();
|
||||||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const manifest = await readRemoteChannelManifest(channel);
|
const manifest = await readRemoteChannelManifest(channel, target);
|
||||||
const commit =
|
const commit =
|
||||||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||||||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||||||
@@ -287,12 +287,14 @@ export async function resolvePreviousReleaseCommit(
|
|||||||
*/
|
*/
|
||||||
export async function resolveRemoteHighWaterVersion(
|
export async function resolveRemoteHighWaterVersion(
|
||||||
channel = resolveReleaseChannel(),
|
channel = resolveReleaseChannel(),
|
||||||
|
target = defaultTarget(),
|
||||||
) {
|
) {
|
||||||
const channelVersion = await readManifestVersion(
|
const channelVersion = await readManifestVersion(
|
||||||
updateManifestUrl(channel),
|
updateManifestUrl(channel, target),
|
||||||
'OSS 渠道清单',
|
'OSS 渠道清单',
|
||||||
);
|
);
|
||||||
if (channel !== 'dev-win') return channelVersion;
|
if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows')
|
||||||
|
return channelVersion;
|
||||||
const legacyVersion = await readManifestVersion(
|
const legacyVersion = await readManifestVersion(
|
||||||
legacyBridgeManifestUrl(),
|
legacyBridgeManifestUrl(),
|
||||||
'OSS 迁移指针',
|
'OSS 迁移指针',
|
||||||
@@ -310,9 +312,9 @@ function replaceVersionLine(source, version, pattern, label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
|
||||||
const { channel } = context;
|
const { channel, target } = context;
|
||||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||||
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
|
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
|
||||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||||
const nextVersion = requestedVersion
|
const nextVersion = requestedVersion
|
||||||
? parseVersion(requestedVersion, '指定版本')
|
? parseVersion(requestedVersion, '指定版本')
|
||||||
@@ -401,24 +403,27 @@ export function buildTauriBuildArguments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
export function createChannelConfig(
|
||||||
|
channel = resolveReleaseChannel(),
|
||||||
|
target = defaultTarget(),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
plugins: {
|
plugins: {
|
||||||
updater: {
|
updater: {
|
||||||
endpoints: [updateManifestUrl(channel)],
|
endpoints: [updateManifestUrl(channel, target)],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeChannelConfigFile(channel) {
|
function writeChannelConfigFile(channel, target) {
|
||||||
const configPath = path.join(
|
const configPath = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
`agc-tauri-channel-${channel}.json`,
|
`agc-tauri-channel-${channel}-${target}.json`,
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
configPath,
|
configPath,
|
||||||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
|
||||||
);
|
);
|
||||||
return configPath;
|
return configPath;
|
||||||
}
|
}
|
||||||
@@ -435,8 +440,8 @@ export function runTauriBuild(
|
|||||||
throw new Error('构建参数与发布上下文目标不一致');
|
throw new Error('构建参数与发布上下文目标不一致');
|
||||||
}
|
}
|
||||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||||
const { channel } = context;
|
const { channel, target } = context;
|
||||||
const configPath = writeChannelConfigFile(channel);
|
const configPath = writeChannelConfigFile(channel, target);
|
||||||
console.log(
|
console.log(
|
||||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||||
);
|
);
|
||||||
@@ -501,6 +506,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectFirstInstallArtifact(
|
||||||
|
files,
|
||||||
|
{ target, version, artifact },
|
||||||
|
) {
|
||||||
|
validateReleaseTarget(target);
|
||||||
|
let selected;
|
||||||
|
if (target.includes('windows')) {
|
||||||
|
selected = artifact;
|
||||||
|
if (!selected?.endsWith('.exe')) {
|
||||||
|
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||||||
|
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||||
|
const suffix = `_${version}_${architecture}.dmg`;
|
||||||
|
const candidates = files.filter((file) =>
|
||||||
|
path.basename(file).endsWith(suffix),
|
||||||
|
);
|
||||||
|
if (candidates.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length} 个`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selected = candidates[0];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!fs.existsSync(selected) ||
|
||||||
|
!fs.statSync(selected).isFile() ||
|
||||||
|
fs.statSync(selected).size === 0
|
||||||
|
) {
|
||||||
|
throw new Error(`首装包不存在或为空:${selected}`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
function readUpdaterSignature(artifactPath) {
|
function readUpdaterSignature(artifactPath) {
|
||||||
const signaturePath = `${artifactPath}.sig`;
|
const signaturePath = `${artifactPath}.sig`;
|
||||||
if (!fs.existsSync(signaturePath)) {
|
if (!fs.existsSync(signaturePath)) {
|
||||||
@@ -517,27 +557,36 @@ export function createUpdateManifest(
|
|||||||
artifactPath,
|
artifactPath,
|
||||||
{
|
{
|
||||||
target = defaultTarget(),
|
target = defaultTarget(),
|
||||||
channel = resolveReleaseChannel(process.env, target),
|
channel = resolveReleaseChannel(),
|
||||||
publishedAt = new Date().toISOString(),
|
publishedAt = new Date().toISOString(),
|
||||||
notes = readReleaseNotes(),
|
notes = readReleaseNotes(),
|
||||||
commit = readHeadCommit(),
|
commit = readHeadCommit(),
|
||||||
|
downloadArtifact,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
validateReleaseTarget(target);
|
validateReleaseTarget(target);
|
||||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
|
const partition = resolveReleasePartition(channel, target);
|
||||||
const signature = readUpdaterSignature(artifactPath);
|
const signature = readUpdaterSignature(artifactPath);
|
||||||
const version = readPackageJson().version;
|
const version = readPackageJson().version;
|
||||||
|
const firstInstallArtifact = selectFirstInstallArtifact(
|
||||||
|
downloadArtifact ? [downloadArtifact] : [],
|
||||||
|
{ target, version, artifact: artifactPath },
|
||||||
|
);
|
||||||
const fileName = path.basename(artifactPath);
|
const fileName = path.basename(artifactPath);
|
||||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||||
|
const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||||||
const platforms = {};
|
const platforms = {};
|
||||||
|
const downloads = {};
|
||||||
for (const key of resolveManifestPlatformKeys(target)) {
|
for (const key of resolveManifestPlatformKeys(target)) {
|
||||||
platforms[key] = { signature, url };
|
platforms[key] = { signature, url };
|
||||||
|
downloads[key] = { url: downloadUrl };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
pub_date: publishedAt,
|
pub_date: publishedAt,
|
||||||
platforms,
|
platforms,
|
||||||
|
downloads,
|
||||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||||
...(commit ? { commit } : {}),
|
...(commit ? { commit } : {}),
|
||||||
};
|
};
|
||||||
@@ -658,14 +707,22 @@ export function formatRecentReleaseNotes(commits) {
|
|||||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||||
export function createLegacyUpdateManifest(
|
export function createLegacyUpdateManifest(
|
||||||
artifactPath,
|
artifactPath,
|
||||||
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
|
{
|
||||||
|
channel = resolveReleaseChannel(),
|
||||||
|
target = defaultTarget(),
|
||||||
|
notes = readReleaseNotes(),
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
|
const partition = resolveReleasePartition(channel, target);
|
||||||
|
if (partition !== 'dev-win') {
|
||||||
|
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
|
||||||
|
}
|
||||||
const bytes = fs.readFileSync(artifactPath);
|
const bytes = fs.readFileSync(artifactPath);
|
||||||
const version = readPackageJson().version;
|
const version = readPackageJson().version;
|
||||||
const fileName = path.basename(artifactPath);
|
const fileName = path.basename(artifactPath);
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||||
size: bytes.length,
|
size: bytes.length,
|
||||||
...(notes ? { releaseNotes: notes } : {}),
|
...(notes ? { releaseNotes: notes } : {}),
|
||||||
@@ -676,12 +733,20 @@ export async function generateUpdateManifest(
|
|||||||
context = resolveReleaseContext(),
|
context = resolveReleaseContext(),
|
||||||
) {
|
) {
|
||||||
const { channel, target, bundleRoot } = context;
|
const { channel, target, bundleRoot } = context;
|
||||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
|
const files = listFiles(bundleRoot);
|
||||||
|
const artifact = selectReleaseArtifact(files, target);
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||||
}
|
}
|
||||||
|
const downloadArtifact = selectFirstInstallArtifact(files, {
|
||||||
|
target,
|
||||||
|
version: readPackageJson().version,
|
||||||
|
artifact,
|
||||||
|
});
|
||||||
const manualNotes = readReleaseNotes();
|
const manualNotes = readReleaseNotes();
|
||||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
const previousCommit = await resolvePreviousReleaseCommit(channel, {
|
||||||
|
target,
|
||||||
|
});
|
||||||
const commits = collectReleaseCommits(previousCommit);
|
const commits = collectReleaseCommits(previousCommit);
|
||||||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||||||
const notes =
|
const notes =
|
||||||
@@ -693,7 +758,12 @@ export async function generateUpdateManifest(
|
|||||||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const manifest = createUpdateManifest(artifact, { channel, target, notes });
|
const manifest = createUpdateManifest(artifact, {
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
notes,
|
||||||
|
downloadArtifact,
|
||||||
|
});
|
||||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||||||
@@ -702,8 +772,8 @@ export async function generateUpdateManifest(
|
|||||||
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
||||||
);
|
);
|
||||||
const legacyManifest =
|
const legacyManifest =
|
||||||
channel === 'dev-win'
|
channel === 'dev' && resolveReleasePlatform(target) === 'windows'
|
||||||
? createLegacyUpdateManifest(artifact, { channel, notes })
|
? createLegacyUpdateManifest(artifact, { channel, target, notes })
|
||||||
: null;
|
: null;
|
||||||
const legacyManifestPath = legacyManifest
|
const legacyManifestPath = legacyManifest
|
||||||
? path.join(bundleRoot, 'legacy-latest.json')
|
? path.join(bundleRoot, 'legacy-latest.json')
|
||||||
@@ -718,6 +788,7 @@ export async function generateUpdateManifest(
|
|||||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||||
);
|
);
|
||||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||||
|
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
|
||||||
console.log(
|
console.log(
|
||||||
manualNotes
|
manualNotes
|
||||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||||
@@ -733,7 +804,9 @@ export async function generateUpdateManifest(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
channel,
|
channel,
|
||||||
|
target,
|
||||||
artifact,
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath,
|
manifestPath,
|
||||||
notes,
|
notes,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -127,8 +127,7 @@ const allowedUncalledTauriCommands = [
|
|||||||
'open_game_creator_launcher_window',
|
'open_game_creator_launcher_window',
|
||||||
'open_game_creator_workspace_window',
|
'open_game_creator_workspace_window',
|
||||||
'read_direct_project_conversation',
|
'read_direct_project_conversation',
|
||||||
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
|
// 前端登记工程生命周期,上传由 Rust 调度;以下两个命令仅供本机排障。
|
||||||
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
|
|
||||||
'read_local_project_snapshot_state',
|
'read_local_project_snapshot_state',
|
||||||
'sync_local_project_snapshot',
|
'sync_local_project_snapshot',
|
||||||
'reset_design_agent_session',
|
'reset_design_agent_session',
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { resolveReleasePartition } from './build-release.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||||
@@ -25,3 +30,101 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
|||||||
}
|
}
|
||||||
return parts.map(quoteArgument).join(' ');
|
return parts.map(quoteArgument).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createReleaseUploadPlan(
|
||||||
|
{
|
||||||
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
manifest,
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
},
|
||||||
|
bucket,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!artifact ||
|
||||||
|
!downloadArtifact ||
|
||||||
|
!manifestPath ||
|
||||||
|
!manifest?.version ||
|
||||||
|
!channel ||
|
||||||
|
!target
|
||||||
|
) {
|
||||||
|
throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单');
|
||||||
|
}
|
||||||
|
const partition = resolveReleasePartition(channel, target);
|
||||||
|
if (legacyManifestPath && partition !== 'dev-win') {
|
||||||
|
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
|
||||||
|
}
|
||||||
|
const prefix = `oss://${bucket}/agc/${partition}`;
|
||||||
|
const artifacts = [
|
||||||
|
...new Set(
|
||||||
|
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
|
||||||
|
path.resolve(file),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const plan = artifacts.map((source) => ({
|
||||||
|
source,
|
||||||
|
destination: `${prefix}/${manifest.version}/${path.basename(source)}`,
|
||||||
|
}));
|
||||||
|
plan.push({ source: manifestPath, destination: `${prefix}/latest.json` });
|
||||||
|
if (legacyManifestPath) {
|
||||||
|
plan.push({
|
||||||
|
source: legacyManifestPath,
|
||||||
|
destination: `oss://${bucket}/agc/latest.json`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadReleaseArtifacts(
|
||||||
|
release,
|
||||||
|
{
|
||||||
|
bucket,
|
||||||
|
endpoint,
|
||||||
|
binary = 'ossutil',
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
dryRun = false,
|
||||||
|
spawn = spawnSync,
|
||||||
|
log = console.log,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||||
|
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||||
|
}
|
||||||
|
const plan = createReleaseUploadPlan(release, bucket);
|
||||||
|
for (const { source, destination } of plan) {
|
||||||
|
// 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。
|
||||||
|
const args = ['cp', '--force', source, destination];
|
||||||
|
if (dryRun) {
|
||||||
|
log(
|
||||||
|
`[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const credentials = accessKeyId
|
||||||
|
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||||
|
: [];
|
||||||
|
const result = spawn(
|
||||||
|
binary,
|
||||||
|
[...args, '--endpoint', endpoint, ...credentials],
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error)
|
||||||
|
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(`[ai-game-creator-shell] 已上传 ${destination}`);
|
||||||
|
}
|
||||||
|
if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|
||||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
import {
|
||||||
|
createReleaseUploadPlan,
|
||||||
|
formatOssutilCommand,
|
||||||
|
readReleaseDryRun,
|
||||||
|
uploadReleaseArtifacts,
|
||||||
|
} from './release-oss.mjs';
|
||||||
|
|
||||||
test('dry run only accepts explicit truthy values', () => {
|
test('dry run only accepts explicit truthy values', () => {
|
||||||
assert.equal(readReleaseDryRun({}), false);
|
assert.equal(readReleaseDryRun({}), false);
|
||||||
@@ -33,12 +40,198 @@ test('printed upload command keeps arguments and hides credentials', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uploader gates every ossutil call behind the dry run switch', () => {
|
function withReleaseFixture(channel, architecture, run, platform = 'macos') {
|
||||||
const source = readFileSync(
|
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
|
||||||
new URL('./release-upload.mjs', import.meta.url),
|
try {
|
||||||
'utf8',
|
const artifact = path.join(
|
||||||
|
root,
|
||||||
|
platform === 'windows'
|
||||||
|
? '陶泥儿_1.2.3_x64-setup.exe'
|
||||||
|
: '陶泥儿.app.tar.gz',
|
||||||
|
);
|
||||||
|
const downloadArtifact =
|
||||||
|
platform === 'windows'
|
||||||
|
? artifact
|
||||||
|
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
|
||||||
|
const manifestPath = path.join(root, 'latest.json');
|
||||||
|
const legacyManifestPath =
|
||||||
|
channel === 'dev' && platform === 'windows'
|
||||||
|
? path.join(root, 'legacy-latest.json')
|
||||||
|
: null;
|
||||||
|
for (const file of [
|
||||||
|
artifact,
|
||||||
|
`${artifact}.sig`,
|
||||||
|
downloadArtifact,
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
].filter(Boolean)) {
|
||||||
|
writeFileSync(file, 'fixture');
|
||||||
|
}
|
||||||
|
return run({
|
||||||
|
artifact,
|
||||||
|
downloadArtifact,
|
||||||
|
channel,
|
||||||
|
target:
|
||||||
|
platform === 'windows'
|
||||||
|
? 'x86_64-pc-windows-msvc'
|
||||||
|
: `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`,
|
||||||
|
manifest: { version: '1.2.3' },
|
||||||
|
manifestPath,
|
||||||
|
legacyManifestPath,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadOptions = {
|
||||||
|
bucket: 'agc-dev',
|
||||||
|
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||||
|
log: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const architecture of ['aarch64', 'x64']) {
|
||||||
|
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
|
||||||
|
withReleaseFixture('dev', architecture, (release) => {
|
||||||
|
const calls = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (binary, args, options) => {
|
||||||
|
assert.equal(binary, 'ossutil');
|
||||||
|
assert.equal(options.shell, false);
|
||||||
|
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||||
|
calls.push({ source: args[2], destination: args[3] });
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map(({ source }) => source),
|
||||||
|
[
|
||||||
|
release.artifact,
|
||||||
|
`${release.artifact}.sig`,
|
||||||
|
release.downloadArtifact,
|
||||||
|
release.manifestPath,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calls[2].destination,
|
||||||
|
`oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
calls[3].destination,
|
||||||
|
'oss://agc-dev/agc/dev-mac/latest.json',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
|
||||||
|
withReleaseFixture(
|
||||||
|
'dev',
|
||||||
|
'x64',
|
||||||
|
(release) => {
|
||||||
|
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||||
|
assert.deepEqual(
|
||||||
|
plan.map(({ source }) => source),
|
||||||
|
[
|
||||||
|
release.artifact,
|
||||||
|
`${release.artifact}.sig`,
|
||||||
|
release.manifestPath,
|
||||||
|
release.legacyManifestPath,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
|
||||||
|
const calls = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (_binary, args) => {
|
||||||
|
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
|
||||||
|
calls.push(args[3]);
|
||||||
|
return { status: 0 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
calls,
|
||||||
|
plan.map(({ destination }) => destination),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'windows',
|
||||||
);
|
);
|
||||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
|
||||||
assert.match(source, /if \(dryRun\) \{/u);
|
|
||||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const failedArtifactIndex of [0, 1, 2]) {
|
||||||
|
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
|
||||||
|
withReleaseFixture('dev', 'aarch64', (release) => {
|
||||||
|
const destinations = [];
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
spawn: (_binary, args) => {
|
||||||
|
destinations.push(args[3]);
|
||||||
|
return {
|
||||||
|
status: destinations.length - 1 === failedArtifactIndex ? 1 : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/OSS 上传失败/u,
|
||||||
|
);
|
||||||
|
assert.equal(destinations.length, failedArtifactIndex + 1);
|
||||||
|
assert.ok(
|
||||||
|
destinations.every(
|
||||||
|
(destination) => !destination.endsWith('/latest.json'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
|
||||||
|
withReleaseFixture('dev', 'aarch64', (release) => {
|
||||||
|
const output = [];
|
||||||
|
uploadReleaseArtifacts(release, {
|
||||||
|
...uploadOptions,
|
||||||
|
dryRun: true,
|
||||||
|
accessKeyId: 'fixture-id',
|
||||||
|
accessKeySecret: 'fixture-secret',
|
||||||
|
spawn: () => assert.fail('dry run must never execute ossutil'),
|
||||||
|
log: (line) => output.push(line),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
output.filter((line) => line.startsWith('[dry-run]')).length,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
assert.match(output.join('\n'), /\.dmg/u);
|
||||||
|
assert.match(output.at(-1), /未写入任何 OSS 对象/u);
|
||||||
|
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const channel of ['release', 'beta-2']) {
|
||||||
|
for (const platform of ['windows', 'macos']) {
|
||||||
|
test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => {
|
||||||
|
withReleaseFixture(
|
||||||
|
channel,
|
||||||
|
'x64',
|
||||||
|
(release) => {
|
||||||
|
const plan = createReleaseUploadPlan(release, 'agc-dev');
|
||||||
|
const suffix = platform === 'windows' ? 'win' : 'mac';
|
||||||
|
const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`;
|
||||||
|
assert.ok(
|
||||||
|
plan.every(({ destination }) => destination.startsWith(prefix)),
|
||||||
|
);
|
||||||
|
assert.equal(plan.at(-1).destination, `${prefix}latest.json`);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createReleaseUploadPlan(
|
||||||
|
{ ...release, legacyManifestPath: release.manifestPath },
|
||||||
|
'agc-dev',
|
||||||
|
),
|
||||||
|
/只属于 dev 渠道/u,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
|
||||||
|
|
||||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||||
const endpoint =
|
const endpoint =
|
||||||
@@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun();
|
|||||||
|
|
||||||
const { buildRelease } = await import('./build-release.mjs');
|
const { buildRelease } = await import('./build-release.mjs');
|
||||||
|
|
||||||
function runOssutil(args) {
|
const release = await buildRelease(process.argv.slice(2));
|
||||||
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
|
uploadReleaseArtifacts(release, {
|
||||||
const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim();
|
bucket,
|
||||||
const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET;
|
endpoint,
|
||||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||||
}
|
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||||
if (dryRun) {
|
dryRun,
|
||||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
});
|
||||||
console.log(
|
|
||||||
`[dry-run] ${formatOssutilCommand({
|
|
||||||
binary,
|
|
||||||
args,
|
|
||||||
endpoint,
|
|
||||||
credentials: Boolean(accessKeyId),
|
|
||||||
})}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const credentialArgs = accessKeyId
|
|
||||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
|
||||||
: [];
|
|
||||||
const result = spawnSync(
|
|
||||||
binary,
|
|
||||||
[...args, '--endpoint', endpoint, ...credentialArgs],
|
|
||||||
{
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
|
|
||||||
}
|
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
|
||||||
await buildRelease(process.argv.slice(2));
|
|
||||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
|
||||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
|
||||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
|
||||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
`${artifact}.sig`,
|
|
||||||
`oss://${bucket}/${artifactKey}.sig`,
|
|
||||||
]);
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
manifestPath,
|
|
||||||
`oss://${bucket}/agc/${channel}/latest.json`,
|
|
||||||
]);
|
|
||||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
|
||||||
console.log(
|
|
||||||
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
|
|
||||||
);
|
|
||||||
if (legacyManifestPath) {
|
|
||||||
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
|
|
||||||
runOssutil([
|
|
||||||
'cp',
|
|
||||||
'--force',
|
|
||||||
legacyManifestPath,
|
|
||||||
`oss://${bucket}/agc/latest.json`,
|
|
||||||
]);
|
|
||||||
console.log(
|
|
||||||
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dryRun) {
|
|
||||||
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2611,8 +2611,8 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
|
|||||||
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
|
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
|
||||||
async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||||
let prepared = (|| {
|
let prepared = (|| {
|
||||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) {
|
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||||
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
|
return Err("当前 Unity 插件不可用".to_string());
|
||||||
}
|
}
|
||||||
enforce_project_permission_policy(&state.root, "unity.editor.execute")?;
|
enforce_project_permission_policy(&state.root, "unity.editor.execute")?;
|
||||||
bridge_reject_unknown_fields(arguments, &["code"])?;
|
bridge_reject_unknown_fields(arguments, &["code"])?;
|
||||||
@@ -2637,7 +2637,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value)
|
|||||||
};
|
};
|
||||||
let root = state.root.clone();
|
let root = state.root.clone();
|
||||||
let result = tokio::task::spawn_blocking(move || {
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) {
|
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||||
return Err("当前 Unity 插件不可用".to_string());
|
return Err("当前 Unity 插件不可用".to_string());
|
||||||
}
|
}
|
||||||
crate::editor_adapters::execute_unity_editor_code(&root, &code)
|
crate::editor_adapters::execute_unity_editor_code(&root, &code)
|
||||||
@@ -2667,10 +2667,9 @@ async fn bridge_cocos_call(
|
|||||||
if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) {
|
if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) {
|
||||||
return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true);
|
return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true);
|
||||||
}
|
}
|
||||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) {
|
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||||
return bridge_tool_result(
|
return bridge_tool_result(
|
||||||
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用"
|
"当前 Cocos 插件不可用,agc_cocos_execute 不可用".to_string(),
|
||||||
.to_string(),
|
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -2735,9 +2734,9 @@ async fn bridge_cocos_call(
|
|||||||
// validated Inspector/pipe bridge. It does not mutate AGC's project
|
// validated Inspector/pipe bridge. It does not mutate AGC's project
|
||||||
// files or manifest, so it must not wait on `.agent/project.lock`.
|
// files or manifest, so it must not wait on `.agent/project.lock`.
|
||||||
// File-writing tools keep their own project lock separately.
|
// File-writing tools keep their own project lock separately.
|
||||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) {
|
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||||
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
|
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
|
||||||
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(),
|
"当前 Cocos 插件不可用".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
cocos_editor_bridge::execute_cocos_editor_code_for_project(
|
cocos_editor_bridge::execute_cocos_editor_code_for_project(
|
||||||
@@ -2840,7 +2839,7 @@ async fn handle_direct_tool_bridge(
|
|||||||
let result = match request.tool.as_str() {
|
let result = match request.tool.as_str() {
|
||||||
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
|
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
|
||||||
"builtin.plugins.tools" => bridge_tool_result(
|
"builtin.plugins.tools" => bridge_tool_result(
|
||||||
json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(),
|
json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(),
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2057,6 +2057,110 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn builtin_editor_tools_follow_independent_switches_for_non_engine_projects() {
|
||||||
|
let _guard = crate::builtin_plugins::test_lock();
|
||||||
|
let config = tempfile::tempdir().unwrap();
|
||||||
|
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||||
|
let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-");
|
||||||
|
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||||
|
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||||
|
let bridge =
|
||||||
|
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (cocos_enabled, unity_enabled) in
|
||||||
|
[(false, false), (true, false), (false, true), (true, true)]
|
||||||
|
{
|
||||||
|
crate::builtin_plugins::set_enabled(
|
||||||
|
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
|
||||||
|
cocos_enabled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
crate::builtin_plugins::set_enabled(
|
||||||
|
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
|
||||||
|
unity_enabled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||||
|
.scope(
|
||||||
|
bridge.url().to_string(),
|
||||||
|
call_client_tool_bridge("builtin.plugins.tools", &json!({})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["isError"], false);
|
||||||
|
let available: Value =
|
||||||
|
serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||||
|
let specs = EXTERNAL_MCP_BRIDGE_URL
|
||||||
|
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
|
||||||
|
.await;
|
||||||
|
let cocos_expected =
|
||||||
|
cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute"));
|
||||||
|
for (runtime_tool, mcp_tool, expected) in [
|
||||||
|
(
|
||||||
|
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME,
|
||||||
|
"agc_cocos_execute",
|
||||||
|
cocos_expected,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME,
|
||||||
|
"agc_unity_execute",
|
||||||
|
unity_enabled
|
||||||
|
&& cfg!(all(
|
||||||
|
windows,
|
||||||
|
target_arch = "x86_64",
|
||||||
|
feature = "unity-editor-execute"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
available["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool == runtime_tool),
|
||||||
|
expected,
|
||||||
|
"{runtime_tool}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
specs["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|tool| tool["name"] == mcp_tool),
|
||||||
|
expected,
|
||||||
|
"{mcp_tool}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
specs["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|tool| tool["name"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(cocos_editor_bridge::is_cocos_operation))
|
||||||
|
.count(),
|
||||||
|
if cocos_expected {
|
||||||
|
cocos_editor_bridge::cocos_operation_catalog().len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap();
|
||||||
|
let specs = EXTERNAL_MCP_BRIDGE_URL
|
||||||
|
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
|
||||||
|
.await;
|
||||||
|
for tool in ["agc_cocos_execute", "agc_unity_execute"] {
|
||||||
|
assert!(!specs["tools"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry["name"] == tool));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_mcp_process_probe() {
|
fn builtin_mcp_process_probe() {
|
||||||
@@ -2097,13 +2201,6 @@ mod tests {
|
|||||||
let config = tempfile::tempdir().unwrap();
|
let config = tempfile::tempdir().unwrap();
|
||||||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||||
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
|
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
|
||||||
// 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构。
|
|
||||||
std::fs::write(
|
|
||||||
project.path().join("package.json"),
|
|
||||||
r#"{"creator":{"version":"3.8.8"}}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::create_dir(project.path().join("assets")).unwrap();
|
|
||||||
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||||
let bridge =
|
let bridge =
|
||||||
|
|||||||
+3
-5
@@ -248,8 +248,8 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
|||||||
"你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}"
|
"你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}"
|
||||||
);
|
);
|
||||||
let mut function_tools =
|
let mut function_tools =
|
||||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
|
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(
|
||||||
root, agent_id,
|
agent_id,
|
||||||
)?;
|
)?;
|
||||||
remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?;
|
remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?;
|
||||||
// Platform-backed generation remains an optional capability. A
|
// Platform-backed generation remains an optional capability. A
|
||||||
@@ -487,9 +487,7 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
|||||||
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
||||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
||||||
.with_function_tools(
|
.with_function_tools(
|
||||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
|
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?,
|
||||||
root, agent_id,
|
|
||||||
)?,
|
|
||||||
)
|
)
|
||||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||||
if runtime_owner_artifact_validation_available {
|
if runtime_owner_artifact_validation_available {
|
||||||
|
|||||||
+1
-1
@@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
|||||||
|| force_autonomous_pre_mutation
|
|| force_autonomous_pre_mutation
|
||||||
{
|
{
|
||||||
request.function_tools =
|
request.function_tools =
|
||||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?;
|
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?;
|
||||||
if runtime_owner_artifact_validation_available {
|
if runtime_owner_artifact_validation_available {
|
||||||
remove_autonomous_owner_manual_verification_tools(
|
remove_autonomous_owner_manual_verification_tools(
|
||||||
&mut request.function_tools,
|
&mut request.function_tools,
|
||||||
|
|||||||
+61
-9
@@ -162,11 +162,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
|||||||
let mut confirm_tools = Vec::new();
|
let mut confirm_tools = Vec::new();
|
||||||
let mut denied_tools = Vec::new();
|
let mut denied_tools = Vec::new();
|
||||||
for tool in agent_runtime_executable_tools() {
|
for tool in agent_runtime_executable_tools() {
|
||||||
if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|
|
||||||
&& !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
|
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
|
||||||
denied_tools.push(tool.to_string());
|
denied_tools.push(tool.to_string());
|
||||||
continue;
|
continue;
|
||||||
@@ -209,10 +204,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
|||||||
run_profile_binding_fingerprint: String::new(),
|
run_profile_binding_fingerprint: String::new(),
|
||||||
allowed_tools: agent_runtime_executable_tools()
|
allowed_tools: agent_runtime_executable_tools()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|tool| {
|
|
||||||
*tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|
|
||||||
|| crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
|
|
||||||
})
|
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect(),
|
.collect(),
|
||||||
auto_tools,
|
auto_tools,
|
||||||
@@ -222,6 +213,67 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod builtin_editor_policy_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_editor_tools_follow_switches_for_non_engine_projects() {
|
||||||
|
let _guard = crate::builtin_plugins::test_lock();
|
||||||
|
let config = tempfile::tempdir().unwrap();
|
||||||
|
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||||
|
let project = crate::tests::canonical_test_tempdir("builtin-editor-policy-");
|
||||||
|
for (cocos_enabled, unity_enabled) in
|
||||||
|
[(false, false), (true, false), (false, true), (true, true)]
|
||||||
|
{
|
||||||
|
crate::builtin_plugins::set_enabled(
|
||||||
|
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
|
||||||
|
cocos_enabled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
crate::builtin_plugins::set_enabled(
|
||||||
|
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
|
||||||
|
unity_enabled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snapshot =
|
||||||
|
agent_runtime_tool_policy_snapshot_at(project.path(), "project-supervisor")
|
||||||
|
.unwrap();
|
||||||
|
for (tool, expected) in [
|
||||||
|
(
|
||||||
|
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME,
|
||||||
|
cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME,
|
||||||
|
unity_enabled
|
||||||
|
&& cfg!(all(
|
||||||
|
windows,
|
||||||
|
target_arch = "x86_64",
|
||||||
|
feature = "unity-editor-execute"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.allowed_tools.iter().any(|entry| entry == tool),
|
||||||
|
expected,
|
||||||
|
"{tool}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot
|
||||||
|
.auto_tools
|
||||||
|
.iter()
|
||||||
|
.chain(&snapshot.confirm_tools)
|
||||||
|
.chain(&snapshot.denied_tools)
|
||||||
|
.any(|entry| entry == tool),
|
||||||
|
expected,
|
||||||
|
"{tool}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
|
pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute(
|
|||||||
detail: None,
|
detail: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) {
|
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||||
return AgentRuntimeToolObservation {
|
return AgentRuntimeToolObservation {
|
||||||
tool: "cocos.editor.execute".to_string(),
|
tool: "cocos.editor.execute".to_string(),
|
||||||
status: "failed".to_string(),
|
status: "failed".to_string(),
|
||||||
summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(),
|
summary: "当前 Cocos 插件不可用".to_string(),
|
||||||
detail: None,
|
detail: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user