feat(skills): 技能分类 - 后台改为分类树编辑,技能库按一级分类筛选

管理页左侧选择一级分类和子分类,右侧编辑名称、图示、排序和启用;画布技能库与浏览条按平台一级分类和子分类筛选。
This commit is contained in:
ddcat666
2026-10-08 10:54:41 +08:00
parent f0359a6bee
commit 27d7cc6a09
6 changed files with 1660 additions and 287 deletions

View File

@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "antd";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Alert, Button, Input } from "antd";
import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills";
import { SkillCurationBrowser, matchesCuration, effectiveCurationRoot, type useSkillCuration } from "@/components/skills/skill-curation-browser";
import { curationIcon, matchesCuration, effectiveCurationRoot, type useSkillCuration } from "@/components/skills/skill-curation-browser";
type SkillLibraryTab = "enabled" | "installed" | "market";
@@ -72,6 +72,14 @@ export function CanvasAgentSkillLibraryModal({
listRef.current?.scrollTo({ top: 0 });
}, [category, libraryCategoryId, search, tab]);
useEffect(() => {
const data = curationState.curation;
const valid = platformCategory.startsWith("root:")
? data?.roots?.some((root) => root.id === platformCategory.slice(5))
: data?.categories.some((item) => item.id === platformCategory);
if ((!data?.enabled || (platformCategory && platformCategory !== "__uncategorized__" && !valid)) && platformCategory) onPlatformCategoryChange("");
}, [curationState.curation, onPlatformCategoryChange, platformCategory]);
const librarySource = useMemo(() => tab === "enabled"
? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId))
: installedSkills, [installedSkills, selectedSkillIds, tab]);
@@ -188,7 +196,29 @@ export function CanvasAgentSkillLibraryModal({
className="canvas-agent-skill-library-search"
/>
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
<SkillCurationBrowser data={curationState.curation} value={platformCategory} onChange={onPlatformCategoryChange} error={curationState.error} retry={curationState.retry} />
{curationState.error ? <Alert type="warning" title={curationState.error} action={<Button onClick={curationState.retry}>重试</Button>} /> : null}
{curationState.curation?.enabled ? (
<div className="canvas-agent-skill-library-category-group" role="group" aria-label="平台分类">
<div className="canvas-agent-skill-library-category-heading">平台分类</div>
<SkillTab active={!platformCategory} label="全部分类" onClick={() => onPlatformCategoryChange("")} />
<SkillTab active={platformCategory === "__uncategorized__"} label="未细分" onClick={() => onPlatformCategoryChange("__uncategorized__")} />
{(curationState.curation.roots || []).map((root) => {
const Icon = curationIcon(root.iconKey);
const children = curationState.curation?.categories.filter((item) => item.rootTag === root.id) || [];
return (
<div key={root.id} className="canvas-agent-skill-library-platform-branch">
<SkillTab active={platformCategory === `root:${root.id}`} icon={<Icon className="size-4" aria-hidden="true" />} label={root.name} onClick={() => onPlatformCategoryChange(`root:${root.id}`)} />
{children.map((item) => (
<SkillTab key={item.id} active={platformCategory === item.id} label={item.name} onClick={() => onPlatformCategoryChange(item.id)} />
))}
</div>
);
})}
{(curationState.curation.categories || []).filter((item) => !(curationState.curation?.roots || []).some((root) => root.id === item.rootTag)).map((item) => (
<SkillTab key={item.id} active={platformCategory === item.id} label={item.name} onClick={() => onPlatformCategoryChange(item.id)} />
))}
</div>
) : null}
<div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">我的技能分类</div>
<SkillTab active={tab !== "market" && libraryCategoryId === "all"} label="全部分类" count={librarySource.length} onClick={() => selectLibraryCategory("all")} />
@@ -270,9 +300,10 @@ export function CanvasAgentSkillLibraryModal({
);
}
function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) {
function SkillTab({ active, icon, label, count, onClick }: { active: boolean; icon?: ReactNode; label: string; count?: number; onClick: () => void }) {
return (
<button type="button" aria-pressed={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
<button type="button" aria-pressed={active} className={`canvas-agent-skill-library-tab${icon ? " has-icon" : ""}${active ? " is-active" : ""}`} onClick={onClick}>
{icon}
{label}
{typeof count === "number" ? <span>{count}</span> : null}
</button>

View File

@@ -2459,6 +2459,7 @@
.canvas-agent-skill-library-categories {
display: flex;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
@@ -2466,6 +2467,27 @@
overscroll-behavior: contain;
}
.canvas-agent-skill-library-categories > .skill-curation-browser {
display: grid;
min-width: 0;
gap: var(--space-2);
margin-bottom: var(--space-3);
border-bottom: 1px solid var(--border);
padding: 0 var(--space-3) var(--space-4);
}
.canvas-agent-skill-library-categories .skill-curation-browser-label {
color: var(--muted-foreground);
font-size: var(--fs-caption);
font-weight: 650;
letter-spacing: 0.04em;
}
.canvas-agent-skill-library-categories .skill-curation-browser-select {
width: 100%;
min-width: 0;
}
.canvas-agent-skill-library-category-group {
display: grid;
gap: var(--space-1);
@@ -2534,6 +2556,17 @@
opacity: 1;
}
.canvas-agent-skill-library-tab.has-icon::before {
display: none;
}
.canvas-agent-skill-library-platform-branch {
display: grid;
min-width: 0;
gap: var(--space-1);
border: 0;
}
.canvas-agent-skill-library-content {
container: agent-skill-content / inline-size;
display: flex;
@@ -2871,6 +2904,27 @@
overscroll-behavior-x: contain;
}
.canvas-agent-skill-library-categories > .skill-curation-browser {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-bottom: 0;
border-right: 1px solid var(--border);
border-bottom: 0;
padding: 0 var(--space-3) 0 var(--space-2);
}
.canvas-agent-skill-library-categories .skill-curation-browser-label {
flex: 0 0 auto;
white-space: nowrap;
}
.canvas-agent-skill-library-categories .skill-curation-browser-select {
width: 156px;
flex: 0 0 156px;
}
.canvas-agent-skill-library-category-group {
display: flex;
flex: 0 0 auto;

View File

@@ -56,9 +56,25 @@ export function SkillCurationBrowser({ data, value, onChange, error, retry }: {
}, [data, value, onChange]);
if (error) return <Alert type="warning" title={error} action={<Button onClick={retry}>重试</Button>} />;
if (!data?.enabled) return null;
return <Space wrap><span>平台分类</span><Select aria-label="平台分类" value={value} onChange={onChange} popupMatchSelectWidth={false} options={[
{ value: "", label: "全部分类" }, { value: "__uncategorized__", label: "未细分" },
...(data.roots || []).map((root) => { const Icon = curationIcon(root.iconKey); return { value: `root:${root.id}`, label: <Space><Icon size={16} />{root.name}</Space> }; }),
...data.categories.map((item) => ({ value: item.id, label: `${data.roots?.find((root) => root.id === item.rootTag)?.name || "分类"} / ${item.name}` })),
]} /></Space>;
return (
<div className="skill-curation-browser">
<span className="skill-curation-browser-label">平台分类</span>
<Select
aria-label="平台分类"
className="skill-curation-browser-select"
value={value}
onChange={onChange}
popupMatchSelectWidth={false}
options={[
{ value: "", label: "全部分类" },
{ value: "__uncategorized__", label: "未细分" },
...(data.roots || []).map((root) => {
const Icon = curationIcon(root.iconKey);
return { value: `root:${root.id}`, label: <Space><Icon size={16} />{root.name}</Space> };
}),
...data.categories.map((item) => ({ value: item.id, label: `${data.roots?.find((root) => root.id === item.rootTag)?.name || "分类"} / ${item.name}` })),
]}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { Alert, App, Button, Input, InputNumber, Select } from "antd";
import { Check, Edit3, Plus, RefreshCw, Shapes, SlidersHorizontal } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Alert, App, Button, Input, InputNumber } from "antd";
import { Check, CheckCircle2, ChevronRight, FolderTree, Plus, RefreshCw, Search, Shapes, SlidersHorizontal } from "lucide-react";
import { AdminPageFrame } from "./components/admin-shell";
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, SettingsSectionCard } from "./components/admin-ui";
import { AdminStatusBadge, AdminTableEmpty, SettingsSectionCard } from "./components/admin-ui";
import { AdminSwitch } from "./ui/controls";
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type CurationRoot, type SkillCuration } from "@/services/api/skill-curation";
import { curationIcon, curationIconKeys, curationIconLabels } from "@/components/skills/skill-curation-browser";
@@ -12,6 +12,17 @@ import "./skill-curation-page.css";
const emptyRoot = (): CurationRoot => ({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true });
const emptyCategory = (rootTag = "drama"): CurationCategory => ({ id: "", rootTag, name: "", sortOrder: 0, enabled: true });
type EditorTarget =
| { kind: "root"; id: string }
| { kind: "category"; id: string }
| { kind: "new-root" }
| { kind: "new-category"; rootTag: string }
| null;
function normalizeName(value: string) {
return value.replace(/\s+/g, " ").trim();
}
export default function SkillCurationPage() {
const { message } = App.useApp();
const [data, setData] = useState<SkillCuration | null>(null);
@@ -19,15 +30,34 @@ export default function SkillCurationPage() {
const [busy, setBusy] = useState(false);
const [root, setRoot] = useState<CurationRoot>(emptyRoot);
const [category, setCategory] = useState<CurationCategory>(emptyCategory);
const [target, setTarget] = useState<EditorTarget>(null);
const [search, setSearch] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const [skillId, setSkillId] = useState("");
const [assignedRoot, setAssignedRoot] = useState("");
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const sortedRoots = useMemo(() => [...(data?.roots || [])].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)), [data?.roots]);
const sortedCategories = useMemo(() => [...(data?.categories || [])].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)), [data?.categories]);
const categoriesByRoot = useMemo(() => {
const grouped = new Map<string, CurationCategory[]>();
sortedCategories.forEach((item) => {
const list = grouped.get(item.rootTag) || [];
list.push(item);
grouped.set(item.rootTag, list);
});
return grouped;
}, [sortedCategories]);
const orphanCategories = useMemo(() => sortedCategories.filter((item) => !data?.roots?.some((rootItem) => rootItem.id === item.rootTag)), [data?.roots, sortedCategories]);
const selectedSkill = skills.find((skill) => skill.skillId === skillId);
const selectedRoot = data?.roots?.find((item) => item.id === assignedRoot);
const availableCategories = data?.categories.filter((item) => item.enabled && item.rootTag === (assignedRoot || selectedSkill?.tag)) ?? [];
const selectedEditorRootId = target?.kind === "root" ? target.id : target?.kind === "category" ? category.rootTag : target?.kind === "new-category" ? target.rootTag : "";
const editorIsRoot = target?.kind === "root" || target?.kind === "new-root";
const editorIsCategory = target?.kind === "category" || target?.kind === "new-category";
const editorTitle = target?.kind === "new-root" ? "新增一级分类" : target?.kind === "new-category" ? "新增子分类" : target?.kind === "root" ? `编辑一级分类 · ${root.name || "未命名"}` : target?.kind === "category" ? `编辑子分类 · ${category.name || "未命名"}` : "选择一个分类";
const editorDescription = editorIsRoot ? "一级分类用于组织技能库的主导航和图示。" : editorIsCategory ? "子分类必须归属于一个启用中的一级分类。" : "从左侧分类树选择一个节点,或先创建新的一级分类。";
const reload = async () => {
try {
@@ -59,12 +89,37 @@ export default function SkillCurationPage() {
};
}, [search]);
const save = async (change: CurationChange) => {
useEffect(() => {
if (!data || target) return;
const firstRoot = sortedRoots[0];
if (firstRoot) {
setRoot({ ...firstRoot });
setTarget({ kind: "root", id: firstRoot.id });
}
}, [data, sortedRoots, target]);
useEffect(() => {
if (!data || !target) return;
if (target.kind === "root") {
const next = data.roots?.find((item) => item.id === target.id);
if (next) setRoot({ ...next });
else setTarget(null);
}
if (target.kind === "category") {
const next = data.categories.find((item) => item.id === target.id);
if (next) setCategory({ ...next });
else setTarget(null);
}
}, [data, target]);
const save = async (change: CurationChange, onSaved?: (next: SkillCuration) => void) => {
if (!data) return;
setBusy(true);
try {
setData(await updateSkillCuration(data.revision, change));
const next = await updateSkillCuration(data.revision, change);
setData(next);
setError("");
onSaved?.(next);
message.success("已保存");
window.dispatchEvent(new Event("canvas-skills-changed"));
} catch (e) {
@@ -74,6 +129,68 @@ export default function SkillCurationPage() {
}
};
const selectRoot = (item: CurationRoot) => {
setRoot({ ...item });
setTarget({ kind: "root", id: item.id });
};
const selectCategory = (item: CurationCategory) => {
setCategory({ ...item });
setTarget({ kind: "category", id: item.id });
};
const startNewRoot = () => {
setRoot(emptyRoot());
setTarget({ kind: "new-root" });
};
const startNewCategory = (rootId = selectedEditorRootId) => {
const nextRootId = rootId && sortedRoots.some((item) => item.id === rootId && item.enabled) ? rootId : sortedRoots.find((item) => item.enabled)?.id || "";
if (!nextRootId) return;
setCategory(emptyCategory(nextRootId));
setTarget({ kind: "new-category", rootTag: nextRootId });
};
const resetEditor = () => {
if (target?.kind === "root") {
const current = data?.roots?.find((item) => item.id === target.id);
if (current) setRoot({ ...current });
return;
}
if (target?.kind === "category") {
const current = data?.categories.find((item) => item.id === target.id);
if (current) setCategory({ ...current });
return;
}
if (target?.kind === "new-category") {
setCategory(emptyCategory(target.rootTag));
return;
}
setRoot(emptyRoot());
};
const saveRoot = () => {
const payload = { ...root, name: normalizeName(root.name) };
void save({ root: payload }, (next) => {
const saved = next.roots?.find((item) => item.id === payload.id || item.name === payload.name);
if (saved) {
setRoot({ ...saved });
setTarget({ kind: "root", id: saved.id });
}
});
};
const saveCategory = () => {
const payload = { ...category, name: normalizeName(category.name) };
void save({ category: payload }, (next) => {
const saved = next.categories.find((item) => item.id === payload.id || (item.rootTag === payload.rootTag && item.name === payload.name));
if (saved) {
setCategory({ ...saved });
setTarget({ kind: "category", id: saved.id });
}
});
};
const selectSkill = (id: string) => {
const nextRoot = data?.rootAssignments?.find((item) => item.skillId === id)?.rootId || "";
setSkillId(id);
@@ -109,270 +226,376 @@ export default function SkillCurationPage() {
</div>
</section>
<div className="admin-skill-curation-grid">
<div className="admin-skill-curation-workspace">
<SettingsSectionCard
layout="stacked"
className="admin-skill-curation-panel"
icon={<Shapes className="size-4" aria-hidden="true" />}
title="分类维护"
description="先维护一级分类和子分类,再把公开技能归入对应分类。"
status={<AdminStatusBadge label={`${(data?.roots?.length ?? 0) + (data?.categories.length ?? 0)} 个分类`} tone="info" />}
className="admin-skill-curation-tree-panel"
icon={<FolderTree className="size-4" aria-hidden="true" />}
title="分类树"
description="选择分类后,在右侧编辑;也可以从这里直接新增。"
status={<AdminStatusBadge label={`${(data?.roots?.length ?? 0) + (data?.categories.length ?? 0)} 个节点`} tone="info" />}
>
<div className="admin-skill-curation-form" aria-label="一级分类编辑表单">
<div className="admin-skill-curation-section-heading">
<div>
<h3>一级分类</h3>
<p>一级分类用于组织技能库的主导航和图示。</p>
<div className="admin-skill-curation-tree-toolbar">
<span className="admin-skill-curation-tree-toolbar-label">一级分类与子分类</span>
<Button size="small" icon={<Plus className="size-3.5" />} onClick={startNewRoot} disabled={busy}>
新增一级
</Button>
</div>
<div className="admin-skill-curation-tree-list" role="tree" aria-label="技能分类树">
{!data ? (
<div className="admin-skill-curation-tree-loading" role="status">
正在加载分类…
</div>
{root.id ? <AdminStatusBadge label={`正在编辑「${root.name}」`} tone="warning" /> : null}
</div>
<div className="admin-skill-curation-fields admin-skill-curation-fields-root">
<label className="admin-skill-curation-field">
<span>分类名称</span>
<Input aria-label="一级分类名称" placeholder="例如:视频创作" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
</label>
<label className="admin-skill-curation-field">
<span>分类图示</span>
<Select
aria-label="一级分类图示"
className="admin-skill-curation-control"
value={root.iconKey || "shapes"}
options={curationIconKeys.map((key) => {
const Icon = curationIcon(key);
return {
value: key,
label: (
<span className="admin-skill-curation-icon-option">
<Icon className="size-3.5" />
{curationIconLabels[key]}
) : sortedRoots.length ? (
<>
{sortedRoots.map((rootItem) => {
const Icon = curationIcon(rootItem.iconKey);
const children = categoriesByRoot.get(rootItem.id) || [];
const active = (target?.kind === "root" && target.id === rootItem.id) || (target?.kind === "new-category" && target.rootTag === rootItem.id);
return (
<div key={rootItem.id} className="admin-skill-curation-tree-branch">
<div className="admin-skill-curation-tree-node-row">
<button
type="button"
role="treeitem"
aria-selected={active}
className={`admin-skill-curation-tree-node admin-skill-curation-tree-node-root${active ? " is-active" : ""}`}
onClick={() => selectRoot(rootItem)}
>
<span className="admin-skill-curation-tree-node-icon">
<Icon className="size-4" aria-hidden="true" />
</span>
<span className="admin-skill-curation-tree-node-copy">
<span className="admin-skill-curation-tree-node-name">{rootItem.name}</span>
<span className="admin-skill-curation-tree-node-meta">
{children.length ? `${children.length} 个子分类` : "暂无子分类"}
{!rootItem.enabled ? <AdminStatusBadge label="已停用" tone="neutral" /> : null}
</span>
</span>
<ChevronRight className="admin-skill-curation-tree-node-chevron size-4" aria-hidden="true" />
</button>
<Button
type="text"
size="small"
className="admin-skill-curation-tree-node-add"
icon={<Plus className="size-3.5" />}
aria-label={`在${rootItem.name}下新增子分类`}
title={rootItem.enabled ? "新增子分类" : "启用一级分类后才能新增子分类"}
onClick={() => startNewCategory(rootItem.id)}
disabled={busy || !rootItem.enabled}
/>
</div>
{children.length ? (
<div className="admin-skill-curation-tree-children" role="group">
{children.map((item) => {
const childActive = target?.kind === "category" && target.id === item.id;
return (
<button
key={item.id}
type="button"
role="treeitem"
aria-selected={childActive}
className={`admin-skill-curation-tree-node admin-skill-curation-tree-node-child${childActive ? " is-active" : ""}${!item.enabled ? " is-disabled" : ""}`}
onClick={() => selectCategory(item)}
>
<span className="admin-skill-curation-tree-node-marker" aria-hidden="true" />
<span className="admin-skill-curation-tree-node-copy">
<span className="admin-skill-curation-tree-node-name">{item.name}</span>
<span className="admin-skill-curation-tree-node-meta">{item.enabled ? "启用中" : "已停用"}</span>
</span>
<ChevronRight className="admin-skill-curation-tree-node-chevron size-3.5" aria-hidden="true" />
</button>
);
})}
</div>
) : null}
</div>
);
})}
{orphanCategories.length ? (
<div className="admin-skill-curation-tree-orphans">
<div className="admin-skill-curation-tree-orphans-title">未关联一级分类</div>
{orphanCategories.map((item) => (
<button key={item.id} type="button" role="treeitem" aria-selected={target?.kind === "category" && target.id === item.id} className={`admin-skill-curation-tree-node admin-skill-curation-tree-node-child${target?.kind === "category" && target.id === item.id ? " is-active" : ""}`} onClick={() => selectCategory(item)}>
<span className="admin-skill-curation-tree-node-marker" aria-hidden="true" />
<span className="admin-skill-curation-tree-node-copy">
<span className="admin-skill-curation-tree-node-name">{item.name}</span>
<span className="admin-skill-curation-tree-node-meta">{item.enabled ? "启用中" : "已停用"}</span>
</span>
),
};
<ChevronRight className="admin-skill-curation-tree-node-chevron size-3.5" aria-hidden="true" />
</button>
))}
</div>
) : null}
</>
) : (
<AdminTableEmpty
title="还没有一级分类"
description="先创建一个一级分类,再继续添加子分类。"
action={
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={startNewRoot}>
创建一级分类
</Button>
}
/>
)}
</div>
</SettingsSectionCard>
<div className="admin-skill-curation-detail-column">
<SettingsSectionCard
layout="stacked"
className="admin-skill-curation-editor-panel"
icon={<Shapes className="size-4" aria-hidden="true" />}
title={editorTitle}
description={editorDescription}
status={editorIsRoot || editorIsCategory ? <AdminStatusBadge label={target?.kind?.startsWith("new-") ? "新建中" : "已选中"} tone={target?.kind?.startsWith("new-") ? "warning" : "info"} /> : <AdminStatusBadge label="未选择" tone="neutral" />}
>
{!data ? (
<div className="admin-skill-curation-editor-empty" role="status">
正在加载编辑器…
</div>
) : editorIsRoot ? (
<div className="admin-skill-curation-form" aria-label="一级分类编辑表单">
<div className="admin-skill-curation-editor-context">
<span className="admin-skill-curation-editor-kicker">一级分类</span>
<strong>{root.id ? `正在编辑「${root.name || "未命名"}」` : "准备创建一个新的一级分类"}</strong>
<span>一级分类会显示在技能库主导航中,并决定子分类的归属。</span>
</div>
<div className="admin-skill-curation-fields admin-skill-curation-fields-root">
<label className="admin-skill-curation-field">
<span>分类名称</span>
<Input aria-label="一级分类名称" placeholder="例如:视频创作" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
</label>
<label className="admin-skill-curation-field is-order">
<span>排序</span>
<InputNumber aria-label="一级分类排序" className="admin-skill-curation-control" precision={0} min={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
</label>
<div className="admin-skill-curation-field is-switch">
<span>状态</span>
<div className="admin-skill-curation-switch-row">
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
<span>{root.enabled ? "启用" : "停用"}</span>
</div>
</div>
<div className="admin-skill-curation-field admin-skill-curation-icon-field">
<span>分类图示</span>
<div className="admin-skill-curation-icon-grid" role="radiogroup" aria-label="一级分类图示">
{curationIconKeys.map((key) => {
const Icon = curationIcon(key);
const selected = (root.iconKey || "shapes") === key;
return (
<button key={key} type="button" role="radio" aria-checked={selected} className={`admin-skill-curation-icon-choice${selected ? " is-selected" : ""}`} onClick={() => setRoot({ ...root, iconKey: key })}>
<Icon className="size-4" aria-hidden="true" />
<span>{curationIconLabels[key]}</span>
</button>
);
})}
</div>
</div>
</div>
<div className="admin-skill-curation-form-actions">
<span className="admin-skill-curation-hint">停用一级分类后,技能仍保留原有数据,不再作为可选归类。</span>
<div className="admin-skill-curation-action-buttons">
<Button onClick={resetEditor} disabled={busy || (!root.id && !root.name)}>
{root.id ? "还原" : "清空"}
</Button>
<Button type="primary" icon={root.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !root.name.trim()} onClick={saveRoot}>
{root.id ? "保存一级分类" : "新增一级分类"}
</Button>
</div>
</div>
</div>
) : editorIsCategory ? (
<div className="admin-skill-curation-form" aria-label="子分类编辑表单">
<div className="admin-skill-curation-editor-context">
<span className="admin-skill-curation-editor-kicker">子分类</span>
<strong>{category.id ? `正在编辑「${category.name || "未命名"}」` : "准备创建一个新的子分类"}</strong>
<span>子分类只能在创建后归属于当前一级分类,已保存的子分类不能移动到其他一级分类。</span>
</div>
<div className="admin-skill-curation-fields admin-skill-curation-fields-category">
<div className="admin-skill-curation-field admin-skill-curation-parent-field">
<span>所属一级分类</span>
<div className="admin-skill-curation-direct-list" role="radiogroup" aria-label="所属一级分类">
{sortedRoots.length ? (
sortedRoots.map((item) => {
const Icon = curationIcon(item.iconKey);
const selected = category.rootTag === item.id;
return (
<button
key={item.id}
type="button"
role="radio"
aria-checked={selected}
className={`admin-skill-curation-direct-choice${selected ? " is-selected" : ""}`}
disabled={Boolean(category.id) || !item.enabled}
onClick={() => setCategory({ ...category, rootTag: item.id })}
>
<span className="admin-skill-curation-direct-choice-icon">
<Icon className="size-3.5" aria-hidden="true" />
</span>
<span className="admin-skill-curation-direct-choice-copy">
<strong>{item.name}</strong>
<small>{item.enabled ? "可用于新建子分类" : "已停用"}</small>
</span>
{selected ? <CheckCircle2 className="admin-skill-curation-choice-check size-3.5" aria-hidden="true" /> : null}
</button>
);
})
) : (
<div className="admin-skill-curation-direct-empty">还没有可用的一级分类</div>
)}
</div>
</div>
<label className="admin-skill-curation-field">
<span>子分类名称</span>
<Input aria-label="分类名称" placeholder="例如:分镜设计" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
</label>
<label className="admin-skill-curation-field is-order">
<span>排序</span>
<InputNumber aria-label="排序" className="admin-skill-curation-control" value={category.sortOrder} precision={0} min={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
</label>
<div className="admin-skill-curation-field is-switch">
<span>状态</span>
<div className="admin-skill-curation-switch-row">
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
<span>{category.enabled ? "启用" : "停用"}</span>
</div>
</div>
</div>
<div className="admin-skill-curation-form-actions">
<span className="admin-skill-curation-hint">{category.id ? `正在编辑「${category.name}」` : "新增后即可在技能归类中使用"}</span>
<div className="admin-skill-curation-action-buttons">
<Button onClick={resetEditor} disabled={busy || (!category.id && !category.name)}>
{category.id ? "还原" : "清空"}
</Button>
<Button type="primary" icon={category.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !category.name.trim() || !category.rootTag} onClick={saveCategory}>
{category.id ? "保存子分类" : "新增子分类"}
</Button>
</div>
</div>
</div>
) : (
<div className="admin-skill-curation-editor-empty">
<AdminTableEmpty
title="还没有选择分类"
description="从左侧选择一个分类,或先创建一个新的一级分类。"
action={
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={startNewRoot}>
新增一级分类
</Button>
}
/>
</div>
)}
</SettingsSectionCard>
<SettingsSectionCard
layout="stacked"
className="admin-skill-curation-assignment-panel"
icon={<Check className="size-4" aria-hidden="true" />}
title="技能归类"
description="搜索公开技能,选择一级分类和它应该出现的子分类。"
status={<AdminStatusBadge label={skillId ? "已选择技能" : "等待选择"} tone={skillId ? "success" : "neutral"} />}
>
<div className="admin-skill-curation-assignment-form" aria-label="技能归类表单">
<label className="admin-skill-curation-field admin-skill-curation-assignment-search">
<span>搜索技能</span>
<Input prefix={<Search className="size-3.5" aria-hidden="true" />} aria-label="搜索公开技能" placeholder="按名称搜索公开技能" value={search} onChange={(e) => setSearch(e.target.value)} allowClear />
</label>
<div className="admin-skill-curation-field admin-skill-curation-skill-field">
<div className="admin-skill-curation-field-heading">
<span>选择技能</span>
<small>{skills.length ? `${skills.length} 个结果` : search ? "没有匹配结果" : "暂无公开技能"}</small>
</div>
<div className="admin-skill-curation-skill-list" role="listbox" aria-label="选择技能">
{skills.length ? (
skills.map((skill) => {
const selected = skill.skillId === skillId;
return (
<button key={skill.skillId} type="button" role="option" aria-selected={selected} className={`admin-skill-curation-skill-row${selected ? " is-selected" : ""}`} onClick={() => selectSkill(skill.skillId)}>
<strong>{skill.skillName || skill.skillId}</strong>
<em>{skill.tag || "未分类"}</em>
{selected ? <Check className="size-3.5" aria-hidden="true" /> : null}
</button>
);
})
) : (
<div className="admin-skill-curation-direct-empty">{search ? "没有找到匹配的公开技能" : "暂无公开技能"}</div>
)}
</div>
</div>
<div className="admin-skill-curation-field admin-skill-curation-assignment-root-field">
<div className="admin-skill-curation-field-heading">
<span>技能一级分类</span>
<small>{assignedRoot ? selectedRoot?.name || "已指定分类" : "沿用原始分类"}</small>
</div>
<div className="admin-skill-curation-assign-chips" role="radiogroup" aria-label="技能一级分类">
<button
type="button"
role="radio"
aria-checked={!assignedRoot}
className={`admin-skill-curation-assign-chip${!assignedRoot ? " is-selected" : ""}`}
onClick={() => {
setAssignedRoot("");
setCategoryIds([]);
}}
>
<Shapes className="size-4" aria-hidden="true" />
<span>按原始分类{selectedSkill?.tag ? ` · ${selectedSkill.tag}` : ""}</span>
</button>
{sortedRoots.map((item) => {
const Icon = curationIcon(item.iconKey);
const selected = assignedRoot === item.id;
return (
<button
key={item.id}
type="button"
role="radio"
aria-checked={selected}
className={`admin-skill-curation-assign-chip${selected ? " is-selected" : ""}`}
disabled={!item.enabled}
onClick={() => {
setAssignedRoot(item.id);
setCategoryIds([]);
}}
>
<Icon className="size-4" aria-hidden="true" />
<span>{item.name}</span>
</button>
);
})}
onChange={(iconKey) => setRoot({ ...root, iconKey })}
/>
</label>
<label className="admin-skill-curation-field is-order">
<span>排序</span>
<InputNumber aria-label="一级分类排序" className="admin-skill-curation-control" precision={0} min={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
</label>
<div className="admin-skill-curation-field is-switch">
<span>状态</span>
<div className="admin-skill-curation-switch-row">
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
<span>{root.enabled ? "启用" : "停用"}</span>
</div>
</div>
</div>
<div className="admin-skill-curation-form-actions">
<span className="admin-skill-curation-hint">停用一级分类后,技能仍保留原有数据,不再作为可选归类。</span>
<div className="admin-skill-curation-action-buttons">
<Button onClick={() => setRoot(emptyRoot())} disabled={busy || (!root.id && !root.name)}>
清空
</Button>
<Button type="primary" icon={root.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>
{root.id ? "保存一级分类" : "新增一级分类"}
</Button>
</div>
</div>
</div>
<div className="admin-skill-curation-table-wrap">
<AdminDataTable<CurationRoot>
className="admin-skill-curation-table"
table={{
rowKey: "id",
dataSource: data?.roots || [],
pagination: false,
loading: !data,
columns: [
{ title: "一级分类", dataIndex: "name" },
{
title: "图示",
width: 88,
render: (_, item) => {
const Icon = curationIcon(item.iconKey);
return <Icon className="size-4" aria-hidden="true" />;
},
},
{ title: "排序", dataIndex: "sortOrder", width: 78, align: "center" },
{ title: "状态", width: 90, render: (_, item) => <AdminStatusBadge label={item.enabled ? "启用" : "停用"} tone={item.enabled ? "success" : "neutral"} /> },
{
title: "操作",
width: 90,
render: (_, item) => (
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={<Edit3 className="size-3.5" />} onClick={() => setRoot({ ...item })}>
编辑
</Button>
),
},
],
}}
empty={
<AdminTableEmpty
title="还没有一级分类"
description="先创建一个一级分类,再继续添加子分类。"
action={
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={() => document.querySelector<HTMLInputElement>("[aria-label=一级分类名称]")?.focus()}>
创建一级分类
</Button>
}
/>
}
/>
</div>
<div className="admin-skill-curation-subsection">
<div className="admin-skill-curation-section-heading">
<div>
<h3>子分类</h3>
<p>子分类必须归属于一个启用中的一级分类。</p>
</div>
</div>
<div className="admin-skill-curation-fields admin-skill-curation-fields-category">
<label className="admin-skill-curation-field">
<span>所属一级分类</span>
<Select
aria-label="所属一级分类"
className="admin-skill-curation-control"
value={category.rootTag}
disabled={Boolean(category.id)}
options={data?.roots?.map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))}
onChange={(rootTag) => setCategory({ ...category, rootTag })}
/>
</label>
<label className="admin-skill-curation-field">
<span>子分类名称</span>
<Input aria-label="分类名称" placeholder="例如:分镜设计" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
</label>
<label className="admin-skill-curation-field is-order">
<span>排序</span>
<InputNumber aria-label="排序" className="admin-skill-curation-control" value={category.sortOrder} precision={0} min={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
</label>
<div className="admin-skill-curation-field is-switch">
<span>状态</span>
<div className="admin-skill-curation-switch-row">
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
<span>{category.enabled ? "启用" : "停用"}</span>
<div className="admin-skill-curation-field admin-skill-curation-assignment-category-field">
<div className="admin-skill-curation-field-heading">
<span>技能子分类</span>
<small>{selectedSkill ? `${categoryIds.length} 个已选` : "先选择技能"}</small>
</div>
<div className={`admin-skill-curation-assign-chips${!selectedSkill ? " is-disabled" : ""}`} role="listbox" aria-label="技能子分类" aria-multiselectable="true">
{availableCategories.length ? (
availableCategories.map((item) => {
const selected = categoryIds.includes(item.id);
return (
<button key={item.id} type="button" role="option" aria-selected={selected} aria-pressed={selected} className={`admin-skill-curation-assign-chip${selected ? " is-selected" : ""}`} disabled={!selectedSkill} onClick={() => setCategoryIds((current) => (selected ? current.filter((id) => id !== item.id) : [...current, item.id]))}>
<span>{item.name}</span>
</button>
);
})
) : (
<div className="admin-skill-curation-direct-empty">{selectedSkill ? (selectedRoot || assignedRoot ? "该一级分类暂无启用的子分类" : "原始分类下暂无启用的子分类") : "先选择技能"}</div>
)}
</div>
</div>
</div>
<div className="admin-skill-curation-form-actions">
<span className="admin-skill-curation-hint">{category.id ? `正在编辑「${category.name}」` : "新增后即可在技能归类中使用"}</span>
<div className="admin-skill-curation-action-buttons">
<Button onClick={() => setCategory(emptyCategory(data?.roots?.[0]?.id || "drama"))} disabled={busy || (!category.id && !category.name)}>
清空
</Button>
<Button type="primary" icon={category.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>
{category.id ? "保存分类" : "新增子分类"}
<div className="admin-skill-curation-assignment-footer">
<span className="admin-skill-curation-hint">{selectedSkill ? `当前技能:${selectedSkill.skillName}` : "未选择技能时不会修改任何归类"}</span>
<Button type="primary" loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>
保存技能归类
</Button>
</div>
</div>
</div>
<div className="admin-skill-curation-table-wrap">
<AdminDataTable<CurationCategory>
className="admin-skill-curation-table"
table={{
rowKey: "id",
dataSource: data?.categories || [],
pagination: false,
loading: !data,
columns: [
{ title: "一级分类", render: (_, row) => data?.roots?.find((item) => item.id === row.rootTag)?.name || "未归入启用分类" },
{ title: "子分类", dataIndex: "name" },
{ title: "排序", dataIndex: "sortOrder", width: 78, align: "center" },
{ title: "状态", width: 90, render: (_, row) => <AdminStatusBadge label={row.enabled ? "启用" : "停用"} tone={row.enabled ? "success" : "neutral"} /> },
{
title: "操作",
width: 90,
render: (_, row) => (
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={<Edit3 className="size-3.5" />} onClick={() => setCategory({ ...row })}>
编辑
</Button>
),
},
],
}}
empty={
<AdminTableEmpty
title="还没有子分类"
description="先在上方创建一个子分类,技能归类时就能直接选择。"
action={
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={() => document.querySelector<HTMLInputElement>("[aria-label=分类名称]")?.focus()}>
创建子分类
</Button>
}
/>
}
/>
</div>
</SettingsSectionCard>
<SettingsSectionCard
layout="stacked"
className="admin-skill-curation-panel"
icon={<Check className="size-4" aria-hidden="true" />}
title="技能归类"
description="搜索公开技能,选择一级分类和它应该出现的子分类。"
status={<AdminStatusBadge label={skillId ? "已选择技能" : "等待选择"} tone={skillId ? "success" : "neutral"} />}
>
<div className="admin-skill-curation-assignment-form" aria-label="技能归类表单">
<label className="admin-skill-curation-field">
<span>搜索技能</span>
<Input aria-label="搜索公开技能" placeholder="按名称搜索公开技能" value={search} onChange={(e) => setSearch(e.target.value)} allowClear />
</label>
<label className="admin-skill-curation-field">
<span>选择技能</span>
<Select
aria-label="选择技能"
className="admin-skill-curation-control"
value={skillId || undefined}
placeholder="请选择一个技能"
options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))}
onChange={selectSkill}
showSearch
optionFilterProp="label"
notFoundContent={search ? "没有找到匹配的公开技能" : "暂无公开技能"}
/>
</label>
<label className="admin-skill-curation-field">
<span>技能一级分类</span>
<Select
aria-label="技能一级分类"
className="admin-skill-curation-control"
value={assignedRoot || undefined}
placeholder="按原始分类"
options={[{ value: "", label: "按原始分类" }, ...(data?.roots || []).map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))]}
onChange={(id) => {
setAssignedRoot(id);
setCategoryIds([]);
}}
/>
</label>
<label className="admin-skill-curation-field">
<span>技能子分类</span>
<Select
mode="multiple"
aria-label="技能子分类"
className="admin-skill-curation-control admin-skill-curation-multi-select"
placeholder={selectedSkill ? "请选择一个或多个子分类" : "先选择技能"}
value={categoryIds}
disabled={!selectedSkill}
onChange={setCategoryIds}
options={availableCategories.map((item) => ({ value: item.id, label: item.name }))}
maxTagCount="responsive"
notFoundContent={selectedRoot || selectedSkill ? "该一级分类暂无启用的子分类" : "先选择技能"}
/>
</label>
<div className="admin-skill-curation-assignment-footer">
<span className="admin-skill-curation-hint">{selectedSkill ? `当前技能:${selectedSkill.skillName}` : "未选择技能时不会修改任何归类"}</span>
<Button type="primary" loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>
保存技能归类
</Button>
</div>
</div>
</SettingsSectionCard>
</SettingsSectionCard>
</div>
</div>
</div>
</AdminPageFrame>

View File

@@ -417,6 +417,9 @@
}
.app-product-workspace .skills-browse-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
.app-product-workspace .skills-browse-bar .collection-toolbar { margin: 0; max-width: 100%; }
.app-product-workspace .skill-curation-browser { display: flex; min-width: 0; max-width: 100%; align-items: center; gap: var(--space-2); }
.app-product-workspace .skill-curation-browser-label { flex: 0 0 auto; color: var(--user-ink-muted); font-size: var(--fs-label); white-space: nowrap; }
.app-product-workspace .skill-curation-browser-select { width: min(220px, 100%); min-width: 0; }
.app-product-workspace .skills-scope-panel { display: grid; gap: 28px; padding: 0; animation: none; }
.app-product-workspace .skills-library-layout { display: grid; grid-template-columns: minmax(208px, 236px) minmax(0, 1fr); align-items: start; gap: 24px; }