feat(skills): 技能分类 - 后台改为分类树编辑,技能库按一级分类筛选
管理页左侧选择一级分类和子分类,右侧编辑名称、图示、排序和启用;画布技能库与浏览条按平台一级分类和子分类筛选。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user