forked from bai/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:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from 'bits-ui';
|
||||
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
|
||||
import { api } from '$lib/api';
|
||||
import { fmtDate } from '$lib/format';
|
||||
import { TOOL_OPTIONS } from '$lib/constants';
|
||||
@@ -15,6 +15,7 @@
|
||||
models,
|
||||
skills,
|
||||
slug,
|
||||
folders,
|
||||
folderItems,
|
||||
onupdated,
|
||||
onskillschanged,
|
||||
@@ -24,6 +25,8 @@
|
||||
models: AgentModelRow[];
|
||||
skills: AgentSkillRow[];
|
||||
slug: string;
|
||||
/** ADR-0028 shared management tree — skill picker groups only; not binding identity */
|
||||
folders: AgentConfigFolderRow[];
|
||||
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
|
||||
folderItems: { value: string; label: string }[];
|
||||
onupdated: (updated: AgentRoleRow) => void;
|
||||
@@ -71,7 +74,59 @@
|
||||
return items;
|
||||
});
|
||||
|
||||
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
|
||||
function folderPathLabel(folderId: string): string {
|
||||
const folder = folders.find((f) => f.id === folderId);
|
||||
if (!folder) return '(未知文件夹)';
|
||||
const parts: string[] = [folder.name];
|
||||
let cur: AgentConfigFolderRow | undefined = folder;
|
||||
while (cur?.parentId) {
|
||||
const parent = folders.find((x) => x.id === cur!.parentId);
|
||||
if (!parent) break;
|
||||
parts.unshift(parent.name);
|
||||
cur = parent;
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
/** Group picker by management folder; bindings remain skill name (ADR-0028). */
|
||||
const skillGroups = $derived.by(() => {
|
||||
type Group = { key: string; label: string; skills: AgentSkillRow[] };
|
||||
const byFolder = new Map<string | null, AgentSkillRow[]>();
|
||||
for (const s of skills) {
|
||||
const key = s.folderId;
|
||||
const list = byFolder.get(key) ?? [];
|
||||
list.push(s);
|
||||
byFolder.set(key, list);
|
||||
}
|
||||
for (const list of byFolder.values()) {
|
||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const filed = [...byFolder.entries()]
|
||||
.filter((e): e is [string, AgentSkillRow[]] => e[0] !== null)
|
||||
.map(([id, list]) => ({ key: id, label: folderPathLabel(id), skills: list }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const unfiled = byFolder.get(null);
|
||||
const groups: Group[] = [...filed];
|
||||
if (unfiled && unfiled.length > 0) {
|
||||
groups.push({ key: 'unfiled', label: '未分类', skills: unfiled });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function groupSelectedCount(groupSkills: AgentSkillRow[]): number {
|
||||
return groupSkills.filter((s) => selectedSkills.includes(s.name)).length;
|
||||
}
|
||||
|
||||
function toggleGroup(groupSkills: AgentSkillRow[], checked: boolean) {
|
||||
const names = new Set(groupSkills.map((s) => s.name));
|
||||
if (checked) {
|
||||
const next = new Set(selectedSkills);
|
||||
for (const n of names) next.add(n);
|
||||
selectedSkills = [...next];
|
||||
} else {
|
||||
selectedSkills = selectedSkills.filter((n) => !names.has(n));
|
||||
}
|
||||
}
|
||||
|
||||
function skillsDirty(): boolean {
|
||||
const a = [...selectedSkills].sort();
|
||||
@@ -209,19 +264,40 @@
|
||||
<div class="mt-4">
|
||||
<span class="saas-label">技能绑定</span>
|
||||
{#if skills.length === 0}
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装(ADR-0018)。</p>
|
||||
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板(ADR-0018)。</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{#each skillItems as s}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.value)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.label}</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
{#each skillGroups as group (group.key)}
|
||||
<div class="border border-surface-300">
|
||||
<label class="flex cursor-pointer items-center gap-2 border-b border-surface-200 bg-surface-100 px-2 py-1.5 text-sm">
|
||||
<CheckboxControl
|
||||
checked={groupSelectedCount(group.skills) === group.skills.length}
|
||||
onchange={(checked) => toggleGroup(group.skills, checked)}
|
||||
/>
|
||||
<span class="text-xs font-semibold text-surface-700">{group.label}</span>
|
||||
<span class="text-[10px] text-surface-500">
|
||||
{groupSelectedCount(group.skills)}/{group.skills.length}
|
||||
</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 gap-0.5 p-1 sm:grid-cols-2">
|
||||
{#each group.skills as s (s.name)}
|
||||
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
|
||||
<CheckboxControl
|
||||
checked={selectedSkills.includes(s.name)}
|
||||
onchange={(checked) => {
|
||||
selectedSkills = checked
|
||||
? [...selectedSkills, s.name]
|
||||
: selectedSkills.filter((x) => x !== s.name);
|
||||
}}
|
||||
/>
|
||||
<span class="font-mono text-xs">{s.name}</span>
|
||||
{#if s.version}
|
||||
<span class="text-[10px] text-surface-500">v{s.version}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user