Files
ttstd-infinite-canvas/web/src/pages/admin/skill-curation-page.tsx
ddcat666 27d7cc6a09 feat(skills): 技能分类 - 后台改为分类树编辑,技能库按一级分类筛选
管理页左侧选择一级分类和子分类,右侧编辑名称、图示、排序和启用;画布技能库与浏览条按平台一级分类和子分类筛选。
2026-10-08 10:54:41 +08:00

604 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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";
import { listSkills, type Skill } from "@/services/api/skills";
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);
const [error, setError] = useState("");
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 {
setData(await getSkillCuration(true));
setError("");
} catch (e) {
setError(String(e));
}
};
useEffect(() => {
void reload();
}, []);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
listSkills({ scope: "public", search, pageSize: 50 })
.then((result) => {
if (!cancelled) setSkills(result.skills);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [search]);
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 {
const next = await updateSkillCuration(data.revision, change);
setData(next);
setError("");
onSaved?.(next);
message.success("已保存");
window.dispatchEvent(new Event("canvas-skills-changed"));
} catch (e) {
setError(`${String(e)};请重新加载后核对修改`);
} finally {
setBusy(false);
}
};
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);
setAssignedRoot(nextRoot);
setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []);
};
return (
<AdminPageFrame
title="技能分类"
description="管理平台技能的一级分类、子分类和技能归属。"
scroll
actions={
<Button icon={<RefreshCw className="size-3.5" />} onClick={() => void reload()} disabled={busy}>
重新加载
</Button>
}
>
<div className="admin-skill-curation">
{error ? <Alert type="error" showIcon title={error} /> : null}
<section className="admin-skill-curation-policy" aria-label="平台策展模式">
<div className="admin-skill-curation-policy-icon">
<SlidersHorizontal className="size-4" aria-hidden="true" />
</div>
<div className="admin-skill-curation-policy-copy">
<div className="admin-skill-curation-policy-title">平台策展模式</div>
<p>开启后,技能库会优先使用这里维护的分类和归属;关闭时不影响现有技能展示。</p>
</div>
<div className="admin-skill-curation-policy-control">
<AdminStatusBadge label={data?.enabled ? "已开启" : "未开启"} tone={data?.enabled ? "success" : "neutral"} />
<AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} />
</div>
</section>
<div className="admin-skill-curation-workspace">
<SettingsSectionCard
layout="stacked"
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-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>
) : 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>
);
})}
</div>
</div>
<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 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>
</div>
</div>
</div>
</AdminPageFrame>
);
}