forked from EduCraft/curriculum-project-hub
feat(admin-web): skill zip import and folder-grouped role picker
- parse skill package zips client-side, mirroring backend ingestion limits (ADR-0018) - add zip upload flow on skills page and zip replace in SkillEditor - group role skill bindings by the shared management folder tree (ADR-0028) - add fflate dependency
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { parseSkillZip } from '$lib/skillZip';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import SelectField from '$lib/components/SelectField.svelte';
|
||||
import { toastError, toastSuccess } from '$lib/toast';
|
||||
@@ -36,6 +37,8 @@
|
||||
let showNewFile = $state(false);
|
||||
let folderValue = $state(skill.folderId ?? '');
|
||||
let savingFolder = $state(false);
|
||||
let zipInputEl = $state<HTMLInputElement | null>(null);
|
||||
let zipImporting = $state(false);
|
||||
|
||||
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
|
||||
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
|
||||
@@ -178,6 +181,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function importZip(fileList: FileList | null) {
|
||||
const file = fileList?.[0];
|
||||
if (!file) return;
|
||||
const trimmedVersion = version.trim();
|
||||
if (trimmedVersion === '') {
|
||||
toastError('请先填写版本号再导入 zip');
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
return;
|
||||
}
|
||||
zipImporting = true;
|
||||
try {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const parsed = parseSkillZip(buf);
|
||||
if (parsed.name !== skill.name) {
|
||||
throw new Error(`zip 内技能名 "${parsed.name}" 与当前技能 "${skill.name}" 不一致`);
|
||||
}
|
||||
files = parsed.files.map((f) => ({ path: f.path, content: f.content }));
|
||||
selectedPath = files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null;
|
||||
if (parsed.description !== null) description = parsed.description;
|
||||
dirty = true;
|
||||
toastSuccess(`已载入 zip(${files.length} 个文件),请保存以写入`);
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
zipImporting = false;
|
||||
if (zipInputEl) zipInputEl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFrontmatter(content: string, key: string, value: string): string {
|
||||
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
@@ -324,9 +356,20 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
|
||||
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty || zipImporting}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<label class="saas-btn-ghost cursor-pointer {zipImporting ? 'pointer-events-none opacity-50' : ''}">
|
||||
{zipImporting ? '导入中…' : '从 zip 替换'}
|
||||
<input
|
||||
bind:this={zipInputEl}
|
||||
type="file"
|
||||
accept=".zip,application/zip,application/x-zip-compressed"
|
||||
class="hidden"
|
||||
disabled={zipImporting || saving}
|
||||
onchange={(e) => importZip(e.currentTarget.files)}
|
||||
/>
|
||||
</label>
|
||||
{#if !skill.disabledAt}
|
||||
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
|
||||
禁用
|
||||
|
||||
Reference in New Issue
Block a user