diff --git a/web/src/pages/admin/skill-curation-page.css b/web/src/pages/admin/skill-curation-page.css
new file mode 100644
index 000000000..a768c1038
--- /dev/null
+++ b/web/src/pages/admin/skill-curation-page.css
@@ -0,0 +1,54 @@
+.admin-skill-curation-section-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--admin-divider);
+ padding-bottom: 10px;
+}
+
+.admin-skill-curation-section-heading h3 {
+ margin: 0;
+ color: var(--admin-text-primary);
+ font-size: 13px;
+ font-weight: 650;
+ line-height: 20px;
+}
+
+.admin-skill-curation-section-heading p {
+ margin: 2px 0 0;
+ color: var(--admin-text-muted);
+ font-size: 11px;
+ line-height: 17px;
+}
+
+.admin-skill-curation-fields-root {
+ grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) 96px 110px;
+}
+
+.admin-skill-curation-icon-option {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.admin-skill-curation-subsection {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin-top: 8px;
+ border-top: 1px solid var(--admin-divider);
+ padding-top: 18px;
+}
+
+@media (max-width: 1199px) {
+ .admin-skill-curation-fields-root {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 639px) {
+ .admin-skill-curation-fields-root {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/web/src/pages/admin/skill-curation-page.tsx b/web/src/pages/admin/skill-curation-page.tsx
index 2a8c6f061..714580f9e 100644
--- a/web/src/pages/admin/skill-curation-page.tsx
+++ b/web/src/pages/admin/skill-curation-page.tsx
@@ -1,75 +1,380 @@
import { useEffect, useState } from "react";
-import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
+import { Alert, App, Button, Input, InputNumber, Select } from "antd";
+import { Check, Edit3, Plus, RefreshCw, Shapes, SlidersHorizontal } from "lucide-react";
import { AdminPageFrame } from "./components/admin-shell";
-import { AdminDataTable } from "./components/admin-ui";
+import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, SettingsSectionCard } from "./components/admin-ui";
import { AdminSwitch } from "./ui/controls";
-import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration, type CurationRoot } from "@/services/api/skill-curation";
+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 });
export default function SkillCurationPage() {
const { message } = App.useApp();
const [data, setData] = useState
(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
- const [category, setCategory] = useState({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true });
+ const [root, setRoot] = useState(emptyRoot);
+ const [category, setCategory] = useState(emptyCategory);
const [search, setSearch] = useState("");
const [skills, setSkills] = useState([]);
const [skillId, setSkillId] = useState("");
- const [categoryIds, setCategoryIds] = useState([]);
- const [root, setRoot] = useState({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true });
const [assignedRoot, setAssignedRoot] = useState("");
- const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } };
- useEffect(() => { void reload(); }, []);
+ const [categoryIds, setCategoryIds] = useState([]);
+
+ 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 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); };
+ 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]);
+
const save = async (change: CurationChange) => {
if (!data) return;
setBusy(true);
- try { setData(await updateSkillCuration(data.revision, change)); setError(""); message.success("已保存"); window.dispatchEvent(new Event("canvas-skills-changed")); }
- catch (e) { setError(`${String(e)};请重新加载后核对修改`); }
- finally { setBusy(false); }
+ try {
+ setData(await updateSkillCuration(data.revision, change));
+ setError("");
+ message.success("已保存");
+ window.dispatchEvent(new Event("canvas-skills-changed"));
+ } catch (e) {
+ setError(`${String(e)};请重新加载后核对修改`);
+ } finally {
+ setBusy(false);
+ }
};
- const selectedSkill = skills.find((skill) => skill.skillId === skillId);
- return void reload()}>重新加载}>
-
- {error && }
- void save({ enabled })} />平台策展模式
- table={{ rowKey: "id", dataSource: data?.roots || [], pagination: false, loading: !data, columns: [
- { title: "一级分类", dataIndex: "name" }, { title: "图示", render: (_, item) => { const Icon = curationIcon(item.iconKey); return ; } },
- { title: "排序", dataIndex: "sortOrder" }, { title: "状态", render: (_, item) => item.enabled ? "启用" : "停用" },
- { title: "操作", render: (_, item) => },
- ] }} />
-
- setRoot({ ...root, name: e.target.value })} />
-
- 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" },
- { title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => },
- ] }} />
-
-
-
- setSearch(e.target.value)} />
-
-
- ;
+
+ 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 (
+ } onClick={() => void reload()} disabled={busy}>
+ 重新加载
+
+ }
+ >
+
+ {error ?
: null}
+
+
+
+
+
+
+
平台策展模式
+
开启后,技能库会优先使用这里维护的分类和归属;关闭时不影响现有技能展示。
+
+
+
+
void save({ enabled })} />
+
+
+
+
+
}
+ title="分类维护"
+ description="先维护一级分类和子分类,再把公开技能归入对应分类。"
+ status={
}
+ >
+
+
+
+
一级分类
+
一级分类用于组织技能库的主导航和图示。
+
+ {root.id ?
: null}
+
+
+
+
+
+
+
状态
+
+
setRoot({ ...root, enabled })} />
+ {root.enabled ? "启用" : "停用"}
+
+
+
+
+
停用一级分类后,技能仍保留原有数据,不再作为可选归类。
+
+
+
:
} loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>
+ {root.id ? "保存一级分类" : "新增一级分类"}
+
+
+
+
+
+
+
+ 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 ;
+ },
+ },
+ { title: "排序", dataIndex: "sortOrder", width: 78, align: "center" },
+ { title: "状态", width: 90, render: (_, item) => },
+ {
+ title: "操作",
+ width: 90,
+ render: (_, item) => (
+ } onClick={() => setRoot({ ...item })}>
+ 编辑
+
+ ),
+ },
+ ],
+ }}
+ empty={
+ } onClick={() => document.querySelector("[aria-label=一级分类名称]")?.focus()}>
+ 创建一级分类
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+
子分类
+
子分类必须归属于一个启用中的一级分类。
+
+
+
+
+
+
+
+
状态
+
+
setCategory({ ...category, enabled })} />
+ {category.enabled ? "启用" : "停用"}
+
+
+
+
+
{category.id ? `正在编辑「${category.name}」` : "新增后即可在技能归类中使用"}
+
+
+
:
} loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>
+ {category.id ? "保存分类" : "新增子分类"}
+
+
+
+
+
+
+
+ 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) => },
+ {
+ title: "操作",
+ width: 90,
+ render: (_, row) => (
+ } onClick={() => setCategory({ ...row })}>
+ 编辑
+
+ ),
+ },
+ ],
+ }}
+ empty={
+ } onClick={() => document.querySelector("[aria-label=分类名称]")?.focus()}>
+ 创建子分类
+
+ }
+ />
+ }
+ />
+
+
+
+
}
+ title="技能归类"
+ description="搜索公开技能,选择一级分类和它应该出现的子分类。"
+ status={
}
+ >
+
+
+
+
+
+
+ {selectedSkill ? `当前技能:${selectedSkill.skillName}` : "未选择技能时不会修改任何归类"}
+
+
+
+
+
+
+
+ );
}