Merge branch 'master' into fix/empty-input
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m49s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m49s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
@@ -65,6 +66,50 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面
|
||||
]);
|
||||
});
|
||||
|
||||
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
||||
const imported = {
|
||||
revision: 'rev-2',
|
||||
writable: true,
|
||||
templates: [],
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
||||
);
|
||||
form.append(
|
||||
'zip_0',
|
||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
||||
);
|
||||
|
||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/admin/api/agc-templates/import');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(form);
|
||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -87,6 +88,8 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -174,6 +177,8 @@ export async function request<T>(
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.formData) {
|
||||
init.body = options.formData;
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
@@ -1196,3 +1201,15 @@ export function updateAdminAgcTemplate(
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
return request<AdminImportAgcTemplatesResponse>(
|
||||
'/admin/api/agc-templates/import',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1065,3 +1065,35 @@ export interface AdminUpdateAgcTemplateRequest {
|
||||
dataBase64: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateItemPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
zipField: string;
|
||||
coverField: string;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesManifest {
|
||||
expectedRevision: string;
|
||||
templates: AdminImportAgcTemplateItemPayload[];
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateResult {
|
||||
id: string;
|
||||
templateVersion: string;
|
||||
zipSizeBytes: number;
|
||||
zipSha256: string;
|
||||
reusedObjects: boolean;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesResponse
|
||||
extends AdminAgcTemplateLibraryResponse {
|
||||
imported: AdminImportAgcTemplateResult[];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||||
getAdminAgcTemplates: vi.fn(),
|
||||
importAdminAgcTemplates: vi.fn(),
|
||||
updateAdminAgcTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,21 @@ beforeEach(() => {
|
||||
: entry,
|
||||
),
|
||||
}));
|
||||
vi.mocked(importAdminAgcTemplates)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => ({
|
||||
...structuredClone(library),
|
||||
revision: 'rev-imported',
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(class extends URL {}, {
|
||||
@@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库'
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
|
||||
function uploadZipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function uploadCoverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
async function openUploadDialog() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '上传模板' }));
|
||||
return screen.getByRole('dialog', { name: '上传模板' });
|
||||
}
|
||||
|
||||
function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||||
const input = dialog.querySelector<HTMLInputElement>(
|
||||
`input[accept="${accept}"]`,
|
||||
);
|
||||
if (!input) throw new Error(`missing file input for ${accept}`);
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [
|
||||
uploadZipFile('alpha.zip'),
|
||||
uploadZipFile('beta.zip'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.zip')).not.toBeNull();
|
||||
expect(within(dialog).getByText('beta.zip')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2);
|
||||
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
uploadCoverFile('beta.png'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.png')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
|
||||
await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1));
|
||||
const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1];
|
||||
const manifest = JSON.parse(String(formData.get('manifest')));
|
||||
expect(manifest.expectedRevision).toBe('rev-1');
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect((formData.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((formData.get('cover_0') as File).name).toBe('alpha.png');
|
||||
expect(
|
||||
(await screen.findAllByText(/已导入 1 个模板/)).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('批量上传:冲突给出刷新引导', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 409,
|
||||
message: '模板库已更新,请刷新后重新编辑',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 1 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('批量上传:服务端拒绝时展示真实原因', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 400,
|
||||
message: 'alpha:模板包缺少清单声明的 entry:index.html',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TableRow,
|
||||
TextField,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { RefreshCcw, Upload } from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
@@ -21,16 +21,28 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
isAdminApiError,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTemplatePayload,
|
||||
AdminImportAgcTemplateResult,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
TEMPLATE_UPLOAD_RUNTIMES,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
type PageProps = { token: string; onUnauthorized: (message?: string) => void };
|
||||
@@ -39,6 +51,14 @@ type EditingTemplate = {
|
||||
revision: string;
|
||||
key: number;
|
||||
};
|
||||
|
||||
type TemplateUploadState = {
|
||||
rows: TemplateUploadRow[];
|
||||
errors: Record<string, string>;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
results: AdminImportAgcTemplateResult[] | null;
|
||||
};
|
||||
const runtimeLabels: Record<string, string> = {
|
||||
html: 'HTML',
|
||||
cocos: 'Cocos',
|
||||
@@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
const [notice, setNotice] = useState('');
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingTemplate | null>(null);
|
||||
const [upload, setUpload] = useState<TemplateUploadState | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const readGeneration = useRef(0);
|
||||
const readController = useRef<AbortController | null>(null);
|
||||
@@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function openUpload() {
|
||||
if (writesDisabled || !snapshot) return;
|
||||
setUpload({
|
||||
rows: [],
|
||||
errors: {},
|
||||
submitting: false,
|
||||
error: '',
|
||||
results: null,
|
||||
});
|
||||
}
|
||||
|
||||
function closeUpload() {
|
||||
if (upload?.submitting) return;
|
||||
setUpload(null);
|
||||
}
|
||||
|
||||
async function submitUpload(rows: TemplateUploadRow[]) {
|
||||
const revision = snapshot?.revision;
|
||||
if (
|
||||
!revision ||
|
||||
writing.current ||
|
||||
loading ||
|
||||
conflict ||
|
||||
!snapshot?.writable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setUpload((current) => (current ? { ...current, errors } : current));
|
||||
return;
|
||||
}
|
||||
writing.current = true;
|
||||
setUpload((current) =>
|
||||
current
|
||||
? { ...current, submitting: true, errors: {}, error: '', results: null }
|
||||
: current,
|
||||
);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const confirmed = await confirmWrite({
|
||||
action: '上传模板',
|
||||
target: `${rows.length} 个模板(发布后不可删除,只能下架)`,
|
||||
});
|
||||
if (!confirmed || !mounted.current) return;
|
||||
const response = await importAdminAgcTemplates(
|
||||
token,
|
||||
buildTemplateImportFormData(revision, rows),
|
||||
);
|
||||
if (!mounted.current) return;
|
||||
readGeneration.current += 1;
|
||||
readController.current?.abort();
|
||||
setSnapshot({
|
||||
revision: response.revision,
|
||||
writable: response.writable,
|
||||
templates: response.templates,
|
||||
});
|
||||
setConflict(false);
|
||||
setNotice(`已导入 ${response.imported.length} 个模板`);
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
submitting: false,
|
||||
rows: [],
|
||||
results: response.imported,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
const conflictNow = isAdminApiError(error) && error.status === 409;
|
||||
setConflict(conflictNow);
|
||||
const message = formatUploadError(error);
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false, error: message } : current,
|
||||
);
|
||||
// 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
handlePageError(error, unauthorized.current, setError);
|
||||
}
|
||||
} finally {
|
||||
writing.current = false;
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false } : current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
|
||||
const entries = (snapshot?.templates ?? []).filter((entry) => {
|
||||
const searchable = [entry.id, entry.title, ...entry.tags]
|
||||
@@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
|
||||
<div className="admin-page-heading">
|
||||
<h2>模板管理</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
|
||||
@@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
) : (
|
||||
confirmDialog
|
||||
)}
|
||||
{upload ? (
|
||||
<Modal
|
||||
open
|
||||
title="上传模板"
|
||||
description={`一批最多 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板;发布后不可删除,只能下架`}
|
||||
closeLabel="关闭模板上传"
|
||||
onClose={closeUpload}
|
||||
closeOnEscape={!upload.submitting}
|
||||
closeOnBackdrop={!upload.submitting}
|
||||
className="admin-agc-template-upload-dialog genarrative-ui"
|
||||
>
|
||||
<TemplateUploadDialog
|
||||
state={upload}
|
||||
disabled={writesDisabled}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
conflict={conflict}
|
||||
onRowsChange={(rows) =>
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
rows,
|
||||
errors: validateTemplateUploadRows(rows),
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onSubmit={() => void submitUpload(upload.rows)}
|
||||
onClose={closeUpload}
|
||||
onRefresh={() => void refresh()}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatUploadError(error: unknown) {
|
||||
const message = formatAdminApiError(error);
|
||||
if (isAdminApiError(error) && error.status === 409) {
|
||||
return `${message}(模板库已更新,请刷新列表后重试)`;
|
||||
}
|
||||
if (isAdminApiError(error) && error.status === 503) {
|
||||
return `${message}(发布锁可能仍被占用,请联系运维核对)`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function TemplateUploadDialog({
|
||||
state,
|
||||
disabled,
|
||||
readOnly,
|
||||
busy,
|
||||
conflict,
|
||||
onRowsChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
state: TemplateUploadState;
|
||||
disabled: boolean;
|
||||
readOnly: boolean;
|
||||
busy: boolean;
|
||||
conflict: boolean;
|
||||
onRowsChange: (rows: TemplateUploadRow[]) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const uploadBusy = state.submitting;
|
||||
const rowErrorCount = Object.keys(state.errors).length;
|
||||
|
||||
function appendZipFiles(files: File[]) {
|
||||
const known = new Set(state.rows.map((row) => row.key));
|
||||
const next = [...state.rows];
|
||||
for (const file of files) {
|
||||
if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue;
|
||||
known.add(file.name);
|
||||
next.push(deriveTemplateUploadRow(file));
|
||||
}
|
||||
onRowsChange(next);
|
||||
}
|
||||
|
||||
function updateRow(key: string, patch: Partial<TemplateUploadRow>) {
|
||||
onRowsChange(
|
||||
state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法上传</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-upload-pickers">
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>模板包(可多选 .zip)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
appendZipFiles([...(event.currentTarget.files ?? [])]);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>封面(可多选,按同名模板 ID 匹配 PNG / JPEG / WebP)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
onRowsChange(
|
||||
attachCoverFiles(state.rows, [
|
||||
...(event.currentTarget.files ?? []),
|
||||
]),
|
||||
);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{state.rows.length === 0 ? (
|
||||
<Status tone="info">
|
||||
还没有选择模板包。简介、标签与引擎可在上传后用「编辑」补齐。
|
||||
</Status>
|
||||
) : (
|
||||
<div className="admin-agc-template-upload-scroll">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>模板包</TableHeader>
|
||||
<TableHeader>ID</TableHeader>
|
||||
<TableHeader>名称</TableHeader>
|
||||
<TableHeader>版本</TableHeader>
|
||||
<TableHeader>运行时</TableHeader>
|
||||
<TableHeader>entry</TableHeader>
|
||||
<TableHeader>封面</TableHeader>
|
||||
<TableHeader>操作</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{state.rows.map((row) => {
|
||||
const rowError = state.errors[row.key];
|
||||
const rowDisabled = disabled || uploadBusy;
|
||||
return (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<div className="admin-agc-template-upload-file">
|
||||
{row.zipFile.name}
|
||||
</div>
|
||||
{rowError ? (
|
||||
<div
|
||||
className="admin-agc-template-upload-row-error"
|
||||
role="alert"
|
||||
>
|
||||
{rowError}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="ID"
|
||||
value={row.id}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, { id: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="名称"
|
||||
value={row.title}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
title: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="版本"
|
||||
value={row.templateVersion}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
templateVersion: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={row.runtime}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
runtime: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TEMPLATE_UPLOAD_RUNTIMES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="entry"
|
||||
value={row.entry}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
entry: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.coverFile ? (
|
||||
<span className="admin-agc-template-upload-file">
|
||||
{row.coverFile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Status tone="warning">未匹配封面</Status>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={rowDisabled}
|
||||
onClick={() =>
|
||||
onRowsChange(
|
||||
state.rows.filter((entry) => entry.key !== row.key),
|
||||
)
|
||||
}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{state.error ? (
|
||||
<Status tone="error" role="alert">
|
||||
{state.error}
|
||||
</Status>
|
||||
) : null}
|
||||
{conflict ? (
|
||||
<Button variant="secondary" disabled={busy} onClick={onRefresh}>
|
||||
刷新列表后重试
|
||||
</Button>
|
||||
) : null}
|
||||
{state.results ? (
|
||||
<Status tone="success">
|
||||
{`已导入 ${state.results.length} 个模板:${state.results
|
||||
.map((result) => `${result.id}@${result.templateVersion}`)
|
||||
.join('、')}`}
|
||||
</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-dialog-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploadBusy}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
disabled ||
|
||||
uploadBusy ||
|
||||
state.rows.length === 0 ||
|
||||
rowErrorCount > 0
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{uploadBusy ? '上传中…' : `上传 ${state.rows.length} 个模板`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
parseTemplateTags,
|
||||
sanitizeTemplateId,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
|
||||
function zipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function coverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||||
}
|
||||
|
||||
describe('sanitizeTemplateId', () => {
|
||||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTemplateUploadRow', () => {
|
||||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||||
const derived = row('my-template.zip');
|
||||
expect(derived.id).toBe('my-template');
|
||||
expect(derived.title).toBe('my-template');
|
||||
expect(derived.runtime).toBe('html');
|
||||
expect(derived.templateVersion).toBe('0.1.0');
|
||||
expect(derived.entry).toBe('index.html');
|
||||
expect(derived.coverFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachCoverFiles', () => {
|
||||
it('matches covers by template id and ignores non-image files', () => {
|
||||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||||
const covers = [
|
||||
coverFile('alpha.png'),
|
||||
coverFile('beta.webp'),
|
||||
coverFile('notes.txt'),
|
||||
];
|
||||
|
||||
const next = attachCoverFiles(rows, covers);
|
||||
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||||
});
|
||||
|
||||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplateUploadRows', () => {
|
||||
it('accepts a complete batch', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||||
];
|
||||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip'),
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||||
row('gamma.zip', {
|
||||
coverFile: coverFile('gamma.png'),
|
||||
runtime: 'docker',
|
||||
templateVersion: 'Bad Version',
|
||||
entry: '../escape.js',
|
||||
title: ' ',
|
||||
}),
|
||||
];
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||||
expect(errors.second).toContain('ID 在本批次内重复');
|
||||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||||
});
|
||||
|
||||
it('rejects a batch larger than the server limit', () => {
|
||||
const rows = Array.from(
|
||||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||||
(_, index) =>
|
||||
row(`template-${index}.zip`, {
|
||||
coverFile: coverFile(`template-${index}.png`),
|
||||
}),
|
||||
);
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateImportFormData', () => {
|
||||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', {
|
||||
coverFile: coverFile('alpha.png'),
|
||||
tags: '起步, 起步 2d',
|
||||
title: ' Alpha ',
|
||||
}),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||||
];
|
||||
|
||||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||||
const manifest = JSON.parse(String(form.get('manifest')));
|
||||
|
||||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||||
expect(manifest.templates).toHaveLength(2);
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
title: 'Alpha',
|
||||
tags: ['起步', '2d'],
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect(manifest.templates[1]).toMatchObject({
|
||||
id: 'beta',
|
||||
zipField: 'zip_1',
|
||||
coverField: 'cover_1',
|
||||
});
|
||||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||||
});
|
||||
|
||||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||||
',',
|
||||
);
|
||||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
AdminImportAgcTemplateItemPayload,
|
||||
AdminImportAgcTemplatesManifest,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
|
||||
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
|
||||
export const TEMPLATE_UPLOAD_RUNTIMES = [
|
||||
'html',
|
||||
'unity',
|
||||
'godot',
|
||||
'cocos',
|
||||
] as const;
|
||||
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
|
||||
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
|
||||
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
|
||||
|
||||
export interface TemplateUploadRow {
|
||||
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
|
||||
key: string;
|
||||
zipFile: File;
|
||||
coverFile: File | null;
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string;
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
}
|
||||
|
||||
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
|
||||
export function sanitizeTemplateId(value: string) {
|
||||
return value
|
||||
.replace(/\.zip$/iu, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.{2,}/gu, '.')
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/^[^a-z0-9]+/u, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
|
||||
const id = sanitizeTemplateId(zipFile.name);
|
||||
return {
|
||||
key: zipFile.name,
|
||||
zipFile,
|
||||
coverFile: null,
|
||||
id,
|
||||
title: id,
|
||||
summary: '',
|
||||
tags: '',
|
||||
runtime: 'html',
|
||||
engine: '',
|
||||
engineVersion: '',
|
||||
templateVersion: '0.1.0',
|
||||
entry: 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
|
||||
export function attachCoverFiles(
|
||||
rows: TemplateUploadRow[],
|
||||
coverFiles: File[],
|
||||
): TemplateUploadRow[] {
|
||||
const covers = new Map<string, File>();
|
||||
for (const file of coverFiles) {
|
||||
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
|
||||
const stem = sanitizeTemplateId(
|
||||
file.name.replace(COVER_EXTENSION_PATTERN, ''),
|
||||
);
|
||||
if (!covers.has(stem)) covers.set(stem, file);
|
||||
}
|
||||
return rows.map((row) => {
|
||||
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
|
||||
return matched ? { ...row, coverFile: matched } : row;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTemplateTags(value: string) {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const raw of value.split(/[,,\s]+/u)) {
|
||||
const tag = raw.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags.slice(0, 16);
|
||||
}
|
||||
|
||||
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
|
||||
export function validateTemplateUploadRows(
|
||||
rows: TemplateUploadRow[],
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
const fail = (row: TemplateUploadRow, message: string) => {
|
||||
if (!errors[row.key]) errors[row.key] = message;
|
||||
};
|
||||
if (rows.length === 0) return errors;
|
||||
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
|
||||
for (const row of rows) {
|
||||
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const id = row.id.trim();
|
||||
if (!TEMPLATE_ID_PATTERN.test(id)) {
|
||||
fail(
|
||||
row,
|
||||
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
|
||||
);
|
||||
} else if (seen.has(id)) {
|
||||
fail(row, 'ID 在本批次内重复');
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
if (!row.title.trim() || row.title.trim().length > 80) {
|
||||
fail(row, '名称必须是 1-80 个字符');
|
||||
}
|
||||
if (row.summary.trim().length > 1000) {
|
||||
fail(row, '简介最多 1000 个字符');
|
||||
}
|
||||
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
|
||||
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
|
||||
}
|
||||
if (
|
||||
!TEMPLATE_UPLOAD_RUNTIMES.includes(
|
||||
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
|
||||
)
|
||||
) {
|
||||
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
|
||||
}
|
||||
if (!ENTRY_PATTERN.test(row.entry.trim())) {
|
||||
fail(row, 'entry 必须是模板包内的相对路径');
|
||||
}
|
||||
if (!row.coverFile) {
|
||||
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildTemplateImportManifest(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): AdminImportAgcTemplatesManifest {
|
||||
return {
|
||||
expectedRevision,
|
||||
templates: rows.map((row, index) => {
|
||||
const item: AdminImportAgcTemplateItemPayload = {
|
||||
id: row.id.trim(),
|
||||
title: row.title.trim(),
|
||||
summary: row.summary.trim(),
|
||||
tags: parseTemplateTags(row.tags),
|
||||
runtime: row.runtime,
|
||||
engine: row.engine.trim(),
|
||||
engineVersion: row.engineVersion.trim(),
|
||||
templateVersion: row.templateVersion.trim(),
|
||||
entry: row.entry.trim(),
|
||||
zipField: `zip_${index}`,
|
||||
coverField: `cover_${index}`,
|
||||
};
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTemplateImportFormData(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
|
||||
);
|
||||
rows.forEach((row, index) => {
|
||||
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
|
||||
if (row.coverFile) {
|
||||
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
@@ -88,10 +88,60 @@
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
border-radius: 10px;
|
||||
max-width: min(1180px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-pickers {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker input[type='file'] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
|
||||
.admin-agc-template-upload-scroll {
|
||||
max-height: min(52vh, 520px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-file {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-row-error {
|
||||
margin-top: 4px;
|
||||
color: #b3261e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-template-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
max-width: calc(100vw - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "node scripts/build-release.mjs",
|
||||
"release:upload": "node scripts/release-upload.mjs",
|
||||
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.184.1",
|
||||
"jszip": "^3.10.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -877,14 +878,19 @@ export async function buildRelease(
|
||||
args = [],
|
||||
{
|
||||
prepareVersion = prepareReleaseVersion,
|
||||
prepareToolset = prepareNsisToolsetForRelease,
|
||||
build = runTauriBuild,
|
||||
generateManifest = generateUpdateManifest,
|
||||
} = {},
|
||||
) {
|
||||
const context = resolveReleaseContext(args);
|
||||
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||||
const bundling = !args.includes('--no-bundle');
|
||||
if (bundling) await prepareVersion(context);
|
||||
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
|
||||
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
|
||||
if (bundling) await prepareToolset(context, { bundling });
|
||||
build(args, context);
|
||||
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||||
if (bundling) return generateManifest(context);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -590,6 +590,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
|
||||
const events = [];
|
||||
await buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: (context, options) => {
|
||||
events.push(`toolset:${context.target}:${options.bundling}`);
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'version',
|
||||
`toolset:${windowsTarget}:true`,
|
||||
'build',
|
||||
'manifest',
|
||||
]);
|
||||
});
|
||||
|
||||
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
}),
|
||||
/NSIS 工具链预置失败/u,
|
||||
);
|
||||
assert.deepEqual(events, ['version']);
|
||||
});
|
||||
|
||||
test('--no-bundle 不预置 NSIS 工具链', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
steps.push('toolset');
|
||||
},
|
||||
build: () => {
|
||||
steps.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(steps, ['build']);
|
||||
});
|
||||
|
||||
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||
const events = [];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
|
||||
//
|
||||
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
|
||||
// 把网络问题伪装成打包问题。
|
||||
|
||||
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
|
||||
|
||||
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
|
||||
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
|
||||
// 最终失败才写 stderr 并以退出码 1 失败关闭。
|
||||
const logger = {
|
||||
log: (message) => console.log(message),
|
||||
warn: (message) => console.log(`${message}(将重试)`),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await ensureNsisToolset({ logger });
|
||||
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
|
||||
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
|
||||
console.log(
|
||||
result.reused
|
||||
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
|
||||
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// Tauri Windows bundler 的 NSIS 工具链预置。
|
||||
//
|
||||
// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip`
|
||||
// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。
|
||||
// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次
|
||||
// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`,
|
||||
// 整条流水线在 Rust 编译数分钟之后才失败。
|
||||
//
|
||||
// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全
|
||||
// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件)
|
||||
// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export const LOG_PREFIX = '[ai-game-creator-shell]';
|
||||
|
||||
/** bundler 把工具链解到 `<tools>/.tauri/NSIS`(`bundle.useLocalToolsDir: true`)。 */
|
||||
export const NSIS_TOOLSET_DIR_NAME = 'NSIS';
|
||||
|
||||
export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip';
|
||||
export const NSIS_ARCHIVE_URL =
|
||||
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
|
||||
export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d';
|
||||
export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11';
|
||||
|
||||
export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll';
|
||||
export const NSIS_TAURI_UTILS_URL =
|
||||
'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll';
|
||||
export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860';
|
||||
export const NSIS_TAURI_UTILS_REQUIRED_FILE =
|
||||
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll';
|
||||
|
||||
/**
|
||||
* 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会
|
||||
* 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。
|
||||
*/
|
||||
export const NSIS_REQUIRED_FILES = [
|
||||
'makensis.exe',
|
||||
'Bin/makensis.exe',
|
||||
'Stubs/lzma-x86-unicode',
|
||||
'Stubs/lzma_solid-x86-unicode',
|
||||
NSIS_TAURI_UTILS_REQUIRED_FILE,
|
||||
'Include/MUI2.nsh',
|
||||
'Include/FileFunc.nsh',
|
||||
'Include/x64.nsh',
|
||||
'Include/nsDialogs.nsh',
|
||||
'Include/WinMessages.nsh',
|
||||
'Include/Win/COM.nsh',
|
||||
'Include/Win/Propkey.nsh',
|
||||
'Include/Win/RestartManager.nsh',
|
||||
];
|
||||
|
||||
/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */
|
||||
export const NSIS_ASSETS = [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: NSIS_ARCHIVE_URL,
|
||||
sha1: NSIS_ARCHIVE_SHA1,
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: NSIS_TAURI_UTILS_URL,
|
||||
sha1: NSIS_TAURI_UTILS_SHA1,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_DOWNLOAD_ATTEMPTS = 4;
|
||||
const DEFAULT_RETRY_DELAY_MS = 3000;
|
||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000;
|
||||
|
||||
export function defaultAppRoot() {
|
||||
return fileURLToPath(new URL('..', import.meta.url));
|
||||
}
|
||||
|
||||
export function resolveTauriToolsDir(appRoot = defaultAppRoot()) {
|
||||
// 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true`
|
||||
// 保持一致,否则预置的文件不在 bundler 的查找路径上。
|
||||
return path.join(appRoot, 'src-tauri', 'target', '.tauri');
|
||||
}
|
||||
|
||||
export function resolveNsisCacheDir(
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
) {
|
||||
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
|
||||
if (explicit) return path.resolve(explicit);
|
||||
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
|
||||
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
|
||||
if (platform === 'win32') {
|
||||
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
|
||||
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
|
||||
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
|
||||
export function resolveDownloadUrl(url, env = process.env) {
|
||||
if (!url.startsWith('https://github.com/')) return url;
|
||||
const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim();
|
||||
const match =
|
||||
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec(
|
||||
url,
|
||||
);
|
||||
if (template && match) {
|
||||
return template
|
||||
.replaceAll('<owner>', match[1])
|
||||
.replaceAll('<repo>', match[2])
|
||||
.replaceAll('<version>', match[3])
|
||||
.replaceAll('<asset>', match[4]);
|
||||
}
|
||||
const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim();
|
||||
if (base) return `${base.replace(/\/+$/u, '')}/${url}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function sha1Of(data) {
|
||||
return createHash('sha1').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function sha1OfFile(filePath) {
|
||||
try {
|
||||
return sha1Of(fs.readFileSync(filePath));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyNsisToolset(
|
||||
nsisDir,
|
||||
{ utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {},
|
||||
) {
|
||||
const missing = NSIS_REQUIRED_FILES.filter(
|
||||
(relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)),
|
||||
);
|
||||
const hashMismatch =
|
||||
missing.length === 0 &&
|
||||
sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !==
|
||||
utilsSha1;
|
||||
return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch };
|
||||
}
|
||||
|
||||
/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */
|
||||
export function resolveArchiveEntryTarget(rootDir, entryName) {
|
||||
const root = path.resolve(rootDir);
|
||||
const target = path.resolve(root, entryName);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`NSIS 归档包含越界路径:${entryName}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadBuffer(url, { fetchImpl, timeoutMs }) {
|
||||
const response = await fetchImpl(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
|
||||
}
|
||||
const data = Buffer.from(await response.arrayBuffer());
|
||||
if (data.length === 0) throw new Error('响应为空');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function downloadVerifiedAsset({
|
||||
assetName,
|
||||
url,
|
||||
sha1,
|
||||
env,
|
||||
fetchImpl,
|
||||
attempts,
|
||||
retryDelayMs,
|
||||
timeoutMs,
|
||||
logger,
|
||||
}) {
|
||||
const downloadUrl = resolveDownloadUrl(url, env);
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs });
|
||||
const actual = sha1Of(data);
|
||||
if (actual !== sha1) {
|
||||
throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual})`);
|
||||
}
|
||||
logger.log(
|
||||
`${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}(${data.length} 字节,第 ${attempt} 次尝试)`,
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`,
|
||||
);
|
||||
if (attempt < attempts) await sleep(retryDelayMs * attempt);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` +
|
||||
`下载地址:${downloadUrl}\n` +
|
||||
`可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` +
|
||||
`TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureCachedAsset(options) {
|
||||
const { assetName, sha1, cacheDir, logger } = options;
|
||||
const cachePath = path.join(cacheDir, assetName);
|
||||
if (sha1OfFile(cachePath) === sha1) {
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`);
|
||||
return cachePath;
|
||||
}
|
||||
if (fs.existsSync(cachePath)) {
|
||||
logger.warn(
|
||||
`${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`,
|
||||
);
|
||||
}
|
||||
const data = await downloadVerifiedAsset(options);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const tempPath = `${cachePath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tempPath, data);
|
||||
fs.rmSync(cachePath, { force: true });
|
||||
fs.renameSync(tempPath, cachePath);
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
export async function extractNsisArchive(archivePath, destinationDir) {
|
||||
const archive = await JSZip.loadAsync(fs.readFileSync(archivePath));
|
||||
for (const [entryName, entry] of Object.entries(archive.files)) {
|
||||
if (entry.dir) continue;
|
||||
const target = resolveArchiveEntryTarget(destinationDir, entryName);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, await entry.async('nodebuffer'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。
|
||||
* 返回结构用于测试与日志,不参与发布产物。
|
||||
*/
|
||||
export async function ensureNsisToolset({
|
||||
appRoot = defaultAppRoot(),
|
||||
toolsDir = resolveTauriToolsDir(appRoot),
|
||||
cacheDir = resolveNsisCacheDir(process.env),
|
||||
env = process.env,
|
||||
fetchImpl = globalThis.fetch,
|
||||
assets = NSIS_ASSETS,
|
||||
attempts = DEFAULT_DOWNLOAD_ATTEMPTS,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME);
|
||||
// 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入
|
||||
// 自己的归档描述时,校验口径必须与被注入的资产一致。
|
||||
const missingAsset = [
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
].find((assetName) => !assets.some((asset) => asset.assetName === assetName));
|
||||
if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`);
|
||||
const utilsSha1 =
|
||||
assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME)
|
||||
?.sha1 ?? NSIS_TAURI_UTILS_SHA1;
|
||||
const existing = verifyNsisToolset(nsisDir, { utilsSha1 });
|
||||
if (existing.ok) {
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`);
|
||||
return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] };
|
||||
}
|
||||
logger.log(
|
||||
`${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` +
|
||||
(existing.missing.length > 0
|
||||
? `(缺少 ${existing.missing.length} 个文件)`
|
||||
: '(哈希不符)'),
|
||||
);
|
||||
|
||||
const downloadOptions = {
|
||||
env,
|
||||
fetchImpl,
|
||||
attempts,
|
||||
retryDelayMs,
|
||||
timeoutMs,
|
||||
cacheDir,
|
||||
logger,
|
||||
};
|
||||
const assetPaths = {};
|
||||
for (const asset of assets) {
|
||||
assetPaths[asset.assetName] = await ensureCachedAsset({
|
||||
...asset,
|
||||
...downloadOptions,
|
||||
});
|
||||
}
|
||||
|
||||
fs.rmSync(nsisDir, { recursive: true, force: true });
|
||||
await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir);
|
||||
const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR);
|
||||
if (!fs.existsSync(extractedDir)) {
|
||||
throw new Error(
|
||||
`NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`,
|
||||
);
|
||||
}
|
||||
fs.renameSync(extractedDir, nsisDir);
|
||||
|
||||
const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE);
|
||||
fs.mkdirSync(path.dirname(utilsTarget), { recursive: true });
|
||||
fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget);
|
||||
|
||||
const installed = verifyNsisToolset(nsisDir, { utilsSha1 });
|
||||
if (!installed.ok) {
|
||||
throw new Error(
|
||||
`NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'};` +
|
||||
`哈希不符=${installed.hashMismatch}`,
|
||||
);
|
||||
}
|
||||
logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`);
|
||||
return {
|
||||
nsisDir,
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
reused: false,
|
||||
downloaded: assets.map((asset) => asset.assetName),
|
||||
};
|
||||
}
|
||||
|
||||
/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */
|
||||
export async function prepareNsisToolsetForRelease(
|
||||
context,
|
||||
{ bundling = true, ...deps } = {},
|
||||
) {
|
||||
if (!bundling || !context.target.includes('windows')) return null;
|
||||
return ensureNsisToolset(deps);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
ensureNsisToolset,
|
||||
extractNsisArchive,
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_ARCHIVE_TOP_LEVEL_DIR,
|
||||
NSIS_REQUIRED_FILES,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
NSIS_TOOLSET_DIR_NAME,
|
||||
prepareNsisToolsetForRelease,
|
||||
resolveArchiveEntryTarget,
|
||||
resolveDownloadUrl,
|
||||
resolveNsisCacheDir,
|
||||
resolveTauriToolsDir,
|
||||
sha1Of,
|
||||
verifyNsisToolset,
|
||||
} from './nsis-toolset.mjs';
|
||||
|
||||
const appRoot = path.resolve(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
'..',
|
||||
);
|
||||
const silentLogger = { log() {}, warn() {} };
|
||||
|
||||
function createSandbox() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-'));
|
||||
}
|
||||
|
||||
/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */
|
||||
async function createArchiveFixture(extraEntries = {}) {
|
||||
const zip = new JSZip();
|
||||
for (const relativePath of NSIS_REQUIRED_FILES) {
|
||||
zip.file(
|
||||
`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`,
|
||||
`fixture:${relativePath}`,
|
||||
);
|
||||
}
|
||||
for (const [name, contents] of Object.entries(extraEntries)) {
|
||||
zip.file(name, contents);
|
||||
}
|
||||
return zip.generateAsync({ type: 'nodebuffer' });
|
||||
}
|
||||
|
||||
function fixtureAssets({ archive, utils }) {
|
||||
return [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`,
|
||||
sha1: sha1Of(archive),
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`,
|
||||
sha1: sha1Of(utils),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function fixtureFetch({ archive, utils, failures = 0 }) {
|
||||
let remainingFailures = failures;
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (remainingFailures > 0) {
|
||||
remainingFailures -= 1;
|
||||
throw new Error('network truncated');
|
||||
}
|
||||
const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
arrayBuffer: async () => body,
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => {
|
||||
const config = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(config.bundle.useLocalToolsDir, true);
|
||||
assert.equal(
|
||||
resolveTauriToolsDir(appRoot),
|
||||
path.join(appRoot, 'src-tauri', 'target', '.tauri'),
|
||||
);
|
||||
});
|
||||
|
||||
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
|
||||
assert.equal(
|
||||
resolveNsisCacheDir(
|
||||
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
|
||||
'linux',
|
||||
),
|
||||
'/tmp/agc-cache',
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
|
||||
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
|
||||
);
|
||||
assert.ok(
|
||||
resolveNsisCacheDir({}, 'linux').endsWith(
|
||||
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => {
|
||||
const url =
|
||||
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
|
||||
assert.equal(resolveDownloadUrl(url, {}), url);
|
||||
assert.equal(
|
||||
resolveDownloadUrl(url, {
|
||||
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE:
|
||||
'https://mirror.example.com/<owner>/<repo>/<version>/<asset>',
|
||||
}),
|
||||
'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip',
|
||||
);
|
||||
assert.equal(
|
||||
resolveDownloadUrl(url, {
|
||||
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/',
|
||||
}),
|
||||
`https://mirror.example.com/${url}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('工具链已就绪时零下载复用', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const toolsDir = path.join(sandbox, '.tauri');
|
||||
const cacheDir = path.join(sandbox, 'cache');
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('nsis-tauri-utils-dll');
|
||||
const assets = fixtureAssets({ archive, utils });
|
||||
|
||||
await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: fixtureFetch({ archive, utils }).fetchImpl,
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
let fetchCalls = 0;
|
||||
const reused = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: async () => {
|
||||
fetchCalls += 1;
|
||||
throw new Error('工具链已就绪时不应访问网络');
|
||||
},
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(reused.reused, true);
|
||||
assert.deepEqual(reused.downloaded, []);
|
||||
assert.equal(fetchCalls, 0);
|
||||
assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME));
|
||||
});
|
||||
|
||||
test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const toolsDir = path.join(sandbox, '.tauri');
|
||||
const cacheDir = path.join(sandbox, 'cache');
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('nsis-tauri-utils-dll');
|
||||
const assets = fixtureAssets({ archive, utils });
|
||||
const { fetchImpl, calls } = fixtureFetch({ archive, utils });
|
||||
|
||||
const result = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl,
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.deepEqual(calls.length, 2);
|
||||
assert.deepEqual(result.downloaded, [
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
]);
|
||||
assert.equal(
|
||||
verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
|
||||
utilsSha1: sha1Of(utils),
|
||||
}).ok,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
toolsDir,
|
||||
NSIS_TOOLSET_DIR_NAME,
|
||||
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
'nsis-tauri-utils-dll',
|
||||
);
|
||||
|
||||
// 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。
|
||||
fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const offline = await ensureNsisToolset({
|
||||
toolsDir,
|
||||
cacheDir,
|
||||
assets,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('命中缓存时不应访问网络');
|
||||
},
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(
|
||||
verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('下载失败按次数重试,最终成功', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const archive = await createArchiveFixture();
|
||||
const utils = Buffer.from('dll');
|
||||
const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 });
|
||||
const result = await ensureNsisToolset({
|
||||
toolsDir: path.join(sandbox, '.tauri'),
|
||||
cacheDir: path.join(sandbox, 'cache'),
|
||||
assets: fixtureAssets({ archive, utils }),
|
||||
fetchImpl,
|
||||
attempts: 3,
|
||||
retryDelayMs: 1,
|
||||
logger: silentLogger,
|
||||
});
|
||||
assert.equal(result.downloaded.length, 2);
|
||||
assert.equal(calls.length, 4);
|
||||
});
|
||||
|
||||
test('哈希不匹配时报错并给出可操作提示', async () => {
|
||||
const sandbox = createSandbox();
|
||||
const { fetchImpl } = fixtureFetch({
|
||||
archive: Buffer.from('corrupted'),
|
||||
utils: Buffer.from('corrupted'),
|
||||
});
|
||||
await assert.rejects(
|
||||
ensureNsisToolset({
|
||||
toolsDir: path.join(sandbox, '.tauri'),
|
||||
cacheDir: path.join(sandbox, 'cache'),
|
||||
assets: [
|
||||
{
|
||||
assetName: NSIS_ARCHIVE_ASSET_NAME,
|
||||
url: 'https://github.com/a/b/releases/download/1/n.zip',
|
||||
sha1: 'deadbeef',
|
||||
},
|
||||
{
|
||||
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
|
||||
url: 'https://github.com/a/b/releases/download/1/n.dll',
|
||||
sha1: 'deadbeef',
|
||||
},
|
||||
],
|
||||
fetchImpl,
|
||||
attempts: 2,
|
||||
retryDelayMs: 1,
|
||||
logger: silentLogger,
|
||||
}),
|
||||
/SHA1 不匹配/u,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('归档越界路径与缺失必需文件都会失败关闭', async () => {
|
||||
const sandbox = createSandbox();
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'),
|
||||
/越界路径/u,
|
||||
);
|
||||
assert.equal(
|
||||
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'),
|
||||
path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'),
|
||||
);
|
||||
|
||||
const emptyArchive = new JSZip()
|
||||
.file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one')
|
||||
.generateAsync({ type: 'nodebuffer' });
|
||||
const emptyPath = path.join(sandbox, 'incomplete.zip');
|
||||
fs.writeFileSync(emptyPath, await emptyArchive);
|
||||
const toolsDir = path.join(sandbox, 'incomplete-tools');
|
||||
await extractNsisArchive(emptyPath, toolsDir);
|
||||
const status = verifyNsisToolset(
|
||||
path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR),
|
||||
);
|
||||
assert.equal(status.ok, false);
|
||||
assert.ok(status.missing.includes('Bin/makensis.exe'));
|
||||
});
|
||||
|
||||
test('非 Windows 目标或 --no-bundle 不预置工具链', async () => {
|
||||
let called = 0;
|
||||
const deps = {
|
||||
ensure: async () => {
|
||||
called += 1;
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await prepareNsisToolsetForRelease(
|
||||
{ target: 'x86_64-pc-windows-msvc' },
|
||||
{ bundling: false, ...deps },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await prepareNsisToolsetForRelease(
|
||||
{ target: 'aarch64-apple-darwin' },
|
||||
deps,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
@@ -7,4 +7,5 @@
|
||||
- 客户端只信任「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求;下载后强校验包大小与 SHA-256,安装完成的唯一判据是模板目录里的 `installed.json`。
|
||||
- 这份文件就是线上 `templates/README.md` 的源:内容随每次发布覆盖写,所以改契约要改这里,不要只改线上对象。
|
||||
- 契约与验收以仓库文档 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` 为准;模板源在 `apps/ai-game-creator-shell/template-library/`,发布用 `node scripts/agc-template-library-publish.mjs --source apps/ai-game-creator-shell/template-library [--dry-run] [--only <id,id,...>]`(脚本现场打包 zip、按内容地址上传、逐个回读校验、持锁提交清单)。
|
||||
- 准备新模板(ZIP 里放什么、不能放什么、封面与元数据约束、发布前自检)先读 `docs/【模板规范】AGC模板包组织指南-2026-09-21.md`。
|
||||
- 同一个 `templateVersion` 的 ZIP 字节发生变化时发布会失败关闭,必须递增该模板的 `templateVersion`;发布不删除历史对象,历史版本仍可按旧键下载。
|
||||
|
||||
Reference in New Issue
Block a user