feat(skills): 技能管理 - 支持后台维护一级分类 (#694)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
Catlaxy AI
2026-10-06 09:28:03 +08:00
committed by GitHub
parent fb4af9bd34
commit fe2b892f1f
29 changed files with 579 additions and 37 deletions

View File

@@ -5,7 +5,7 @@ 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, type useSkillCuration } from "@/components/skills/skill-curation-browser";
import { SkillCurationBrowser, matchesCuration, effectiveCurationRoot, type useSkillCuration } from "@/components/skills/skill-curation-browser";
type SkillLibraryTab = "enabled" | "installed" | "market";
@@ -135,6 +135,7 @@ export function CanvasAgentSkillLibraryModal({
setTab(value);
setLibraryCategoryId("all");
onCategoryChange("all");
onPlatformCategoryChange("");
};
const selectMarketplaceCategory = (value: string) => {
@@ -206,7 +207,7 @@ export function CanvasAgentSkillLibraryModal({
<div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">技能广场分类</div>
<SkillTab active={tab === "market" && category === "all"} label="全部技能" count={marketplaceCategoryTotal} onClick={() => changeTab("market")} />
{categoryItems.map((item) => (
{!curationState.curation?.enabled && categoryItems.map((item) => (
<SkillTab
key={item.value}
active={tab === "market" && category === item.value}
@@ -232,6 +233,7 @@ export function CanvasAgentSkillLibraryModal({
<SkillLibraryCard
key={skill.skillId}
skill={skill}
rootLabel={curationState.curation?.enabled ? curationState.curation.roots?.find((root) => root.id === effectiveCurationRoot(skill, curationState.curation!))?.name : undefined}
theme={theme}
categories={categories}
selected={selectedSkillIds.includes(skill.skillId)}
@@ -277,8 +279,9 @@ function SkillTab({ active, label, count, onClick }: { active: boolean; label: s
);
}
function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onToggle, onInstall }: {
function SkillLibraryCard({ skill, rootLabel, theme, categories, selected, canSelect, onToggle, onInstall }: {
skill: Skill;
rootLabel?: string;
theme: CanvasTheme;
categories: SkillCategory[];
selected: boolean;
@@ -292,7 +295,7 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
|| skill.showcaseMedia?.find((item) => item.showcaseUrl);
const coverUrl = cover?.showcaseUrl;
const hasCover = Boolean(coverUrl && !coverFailed);
const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他";
const categoryLabel = rootLabel || categories.find((item) => item.value === skill.tag)?.label || "其他";
const author = skill.effectiveUser?.name || "影策创作者";
const addedCount = formatSkillCount(skill.addedCount || 0);

View File

@@ -115,6 +115,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const skillBrowseKey = JSON.stringify([userId, skillTag, debouncedSkillSearch, platformCategory, curationState.curation?.revision, curationState.curation?.enabled]);
const skillBrowseKeyRef = useRef(skillBrowseKey);
skillBrowseKeyRef.current = skillBrowseKey;
useEffect(() => { if (curationState.curation?.enabled) setSkillTag("all"); }, [curationState.curation?.enabled]);
const [busy, setBusy] = useState(false);
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
const [exporting, setExporting] = useState(false);

View File

@@ -3,10 +3,24 @@ import { Alert, Button, Select, Space } from "antd";
import { getActiveUserScope } from "@/lib/user-scope";
import { getSkillCuration, type SkillCuration } from "@/services/api/skill-curation";
import type { Skill } from "@/services/api/skills";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { Film, ShoppingBag, Megaphone, Landmark, Palette, Users, Wrench, Shapes } from "lucide-react";
export const curationIconKeys = ["shapes", "film", "shopping-bag", "megaphone", "landmark", "palette", "users", "wrench"];
export const curationIconLabels: Record<string, string> = { shapes: "默认", film: "影视", "shopping-bag": "购物袋", megaphone: "喇叭", landmark: "地标", palette: "调色盘", users: "社群", wrench: "工具" };
export function curationIcon(key?: string) {
return ({ film: Film, "shopping-bag": ShoppingBag, megaphone: Megaphone, landmark: Landmark, palette: Palette, users: Users, wrench: Wrench } as Record<string, typeof Shapes>)[key || ""] || Shapes;
}
export function effectiveCurationRoot(skill: Skill, data: SkillCuration) {
const id = (!skill.isPrivate && data.rootAssignments?.find((item) => item.skillId === skill.skillId)?.rootId) || skill.tag;
return data.roots?.some((root) => root.id === id && root.enabled) ? id : "__unassigned__";
}
export function groupCuratedSkills(skills: Skill[], data: SkillCuration) {
return (data.roots || []).map((root) => ({ value: root.id, label: root.name, skills: skills.filter((skill) => effectiveCurationRoot(skill, data) === root.id) })).filter((group) => group.skills.length);
}
export function curationQuery(data: SkillCuration | null, value: string) {
if (!data?.enabled || !value) return {};
if (value.startsWith("root:")) return { platformRootId: value.slice(5) };
return value === "__uncategorized__" ? { platformUncategorized: true } : { platformCategoryId: value };
}
@@ -30,18 +44,21 @@ export function useSkillCuration(active = true) {
export function matchesCuration(skill: Skill, data: SkillCuration | null, category: string) {
if (!data?.enabled || !category) return true;
if (category.startsWith("root:")) return effectiveCurationRoot(skill, data) === category.slice(5);
const ids = data.assignments.filter((item) => item.skillId === skill.skillId).map((item) => item.categoryId);
return category === "__uncategorized__" ? ids.length === 0 : ids.includes(category);
}
export function SkillCurationBrowser({ data, value, onChange, error, retry }: { data: SkillCuration | null; value: string; onChange: (value: string) => void; error: string; retry: () => void }) {
useEffect(() => {
if ((!data?.enabled || (value && value !== "__uncategorized__" && !data.categories.some((item) => item.id === value))) && value) onChange("");
const valid = value.startsWith("root:") ? data?.roots?.some((root) => root.id === value.slice(5)) : data?.categories.some((item) => item.id === value);
if ((!data?.enabled || (value && value !== "__uncategorized__" && !valid)) && value) onChange("");
}, [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.categories.map((item) => ({ value: item.id, label: `${fallbackSkillCategories.find((root) => root.value === item.rootTag)?.label || item.rootTag} / ${item.name}` })),
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>;
}

View File

@@ -3,8 +3,8 @@ import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
import { AdminPageFrame } from "./components/admin-shell";
import { AdminDataTable } from "./components/admin-ui";
import { AdminSwitch } from "./ui/controls";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration } from "@/services/api/skill-curation";
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration, type CurationRoot } from "@/services/api/skill-curation";
import { curationIcon, curationIconKeys, curationIconLabels } from "@/components/skills/skill-curation-browser";
import { listSkills, type Skill } from "@/services/api/skills";
export default function SkillCurationPage() {
@@ -17,6 +17,8 @@ export default function SkillCurationPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [skillId, setSkillId] = useState("");
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const [root, setRoot] = useState<CurationRoot>({ 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(); }, []);
useEffect(() => {
@@ -36,12 +38,25 @@ export default function SkillCurationPage() {
<Space orientation="vertical" className="w-full" size="large">
{error && <Alert type="error" title={error} />}
<Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space>
<AdminDataTable<CurationRoot> table={{ rowKey: "id", dataSource: data?.roots || [], pagination: false, loading: !data, columns: [
{ title: "一级分类", dataIndex: "name" }, { title: "图示", render: (_, item) => { const Icon = curationIcon(item.iconKey); return <Icon size={18} />; } },
{ title: "排序", dataIndex: "sortOrder" }, { title: "状态", render: (_, item) => item.enabled ? "启用" : "停用" },
{ title: "操作", render: (_, item) => <Button onClick={() => setRoot({ ...item })}>编辑</Button> },
] }} />
<Space wrap>
<Input aria-label="一级分类名称" placeholder="一级分类名称" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
<Select aria-label="一级分类图示" value={root.iconKey || "shapes"} options={curationIconKeys.map((key) => { const Icon = curationIcon(key); return { value: key, label: <Space><Icon size={16} />{curationIconLabels[key]}</Space> }; })} onChange={(iconKey) => setRoot({ ...root, iconKey })} />
<InputNumber aria-label="一级分类排序" precision={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
<Button type="primary" loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>{root.id ? "保存一级分类" : "新增一级分类"}</Button>
<Button onClick={() => setRoot({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true })}>新建一级分类</Button>
</Space>
<AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [
{ title: "一级分类", dataIndex: "rootTag" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" },
{ 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) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> },
] }} />
<Space wrap>
<Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={fallbackSkillCategories} fieldNames={{ value: "value", label: "label" }} onChange={(rootTag) => setCategory({ ...category, rootTag })} />
<Select aria-label="所属一级分类" 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 })} />
<Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
<InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
@@ -50,9 +65,10 @@ export default function SkillCurationPage() {
</Space>
<Space wrap>
<Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} />
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === selectedSkill?.tag).map((item) => ({ value: item.id, label: item.name }))} />
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds } })}>保存技能归类</Button>
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setAssignedRoot(data?.rootAssignments?.find((item) => item.skillId === id)?.rootId || ""); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
<Select aria-label="技能一级分类" value={assignedRoot} options={[{ value: "", label: "按原始分类" }, ...(data?.roots || []).map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))]} onChange={(id) => { setAssignedRoot(id); setCategoryIds([]); }} />
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === (assignedRoot || selectedSkill?.tag)).map((item) => ({ value: item.id, label: item.name }))} />
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>保存技能归类</Button>
</Space>
</Space>
</AdminPageFrame>;

View File

@@ -41,7 +41,7 @@ const sortOptions: { label: string; value: SkillSort }[] = [
{ label: "最近更新", value: "updated" },
];
import { SkillCurationBrowser, useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
import { SkillCurationBrowser, useSkillCuration, curationQuery, groupCuratedSkills, curationIcon, effectiveCurationRoot } from "@/components/skills/skill-curation-browser";
export default function SkillsPage() {
const { curation, error: curationError, retry: retryCuration } = useSkillCuration();
@@ -52,6 +52,7 @@ export default function SkillsPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [tag, setTag] = useState("all");
useEffect(() => { if (curation?.enabled) setTag("all"); }, [curation?.enabled]);
const [libraryCategoryId, setLibraryCategoryId] = useState("all");
const [libraryCategoryList, setLibraryCategoryList] = useState<SkillLibraryCategoryList | null>(null);
const [libraryCategoryError, setLibraryCategoryError] = useState("");
@@ -146,7 +147,8 @@ export default function SkillsPage() {
return () => { cancelled = true; };
}, [libraryCategoryScope, reloadKey]);
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
const groupedSkills = useMemo(() => curation?.enabled ? groupCuratedSkills(skills, curation) : groupSkills(skills, categories), [categories, skills, curation]);
const browseCategories: SkillCategory[] = curation?.enabled ? (curation.roots || []).map((root) => ({ value: root.id, label: root.name })) : categories;
const filtersActive = Boolean(search || (!isLibraryScope && tag !== "all") || (isLibraryScope && libraryCategoryId !== "all") || sort !== "popular");
const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setLibraryCategoryId("all"); setSort("popular"); setPage(1); }, []);
@@ -315,6 +317,7 @@ export default function SkillsPage() {
<SkillCard
key={skill.skillId}
skill={skill}
rootLabel={curation?.enabled ? curation.roots?.find((root) => root.id === effectiveCurationRoot(skill, curation))?.name : undefined}
categories={categories}
libraryCategories={libraryCategories}
canCategorize={isLibraryScope}
@@ -336,7 +339,7 @@ export default function SkillsPage() {
) : !isLibraryScope && groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
const GroupIcon = curation?.enabled ? curationIcon(curation.roots?.find((root) => root.id === group.value)?.iconKey) : categoryIconOf(group.value);
return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading">
@@ -425,15 +428,15 @@ export default function SkillsPage() {
</div>
</div>
<nav className="skills-library-category-list" aria-label="技能广场分类">
<button type="button" className={`skills-library-category-item${scope === "public" && tag === "all" ? " is-active" : ""}`} aria-pressed={scope === "public" && tag === "all"} onClick={() => selectMarketplaceCategory("all")}>
<button type="button" className={`skills-library-category-item${scope === "public" && tag === "all" && (!curation?.enabled || !platformCategory) ? " is-active" : ""}`} aria-pressed={scope === "public" && tag === "all" && (!curation?.enabled || !platformCategory)} onClick={() => { setPlatformCategory(""); selectMarketplaceCategory("all"); }}>
<span><Sparkles className="size-4" aria-hidden="true" />全部技能</span>
<span>{marketplaceCategoryTotal}</span>
</button>
{categories.map((category) => {
const Icon = categoryIconOf(category.value);
const active = scope === "public" && tag === category.value;
{browseCategories.map((category) => {
const Icon = curation?.enabled ? curationIcon(curation.roots?.find((root) => root.id === category.value)?.iconKey) : categoryIconOf(category.value);
const active = scope === "public" && (curation?.enabled ? platformCategory === `root:${category.value}` : tag === category.value);
return (
<button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => selectMarketplaceCategory(category.value)}>
<button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => { if (curation?.enabled) { setPlatformCategory(`root:${category.value}`); selectMarketplaceCategory("all"); } else selectMarketplaceCategory(category.value); }}>
<span><Icon className="size-4" aria-hidden="true" />{category.label}</span>
<span>{category.count ?? "—"}</span>
</button>
@@ -541,7 +544,7 @@ export default function SkillsPage() {
);
}
function SkillCard({ skill, categories, libraryCategories, canCategorize, loading, style, onOpen, onAdd, onLike, onEdit, onDelete, onSetLibraryCategory }: { skill: Skill; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; canCategorize: boolean; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void; onSetLibraryCategory: (categoryId: string) => void }) {
function SkillCard({ skill, rootLabel, categories, libraryCategories, canCategorize, loading, style, onOpen, onAdd, onLike, onEdit, onDelete, onSetLibraryCategory }: { skill: Skill; rootLabel?: string; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; canCategorize: boolean; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void; onSetLibraryCategory: (categoryId: string) => void }) {
const CategoryIcon = categoryIconOf(skill.tag);
const currentLibraryCategory = libraryCategories.find((category) => category.id === skill.libraryCategoryId);
const libraryCategoryLabel = currentLibraryCategory?.name || (skill.libraryCategoryId ? "已归类" : "未分类");
@@ -597,7 +600,7 @@ function SkillCard({ skill, categories, libraryCategories, canCategorize, loadin
<span>{formatSkillCount(skill.likeCount)}</span>
</button>
<span className="skill-card-author">{skill.effectiveUser.name || "未知用户"}</span>
<span className="skill-card-tag">{skillCategoryLabel(skill.tag, categories)}</span>
<span className="skill-card-tag">{rootLabel || skillCategoryLabel(skill.tag, categories)}</span>
{skill.isPrivate ? <span className="skill-card-flag">仅自己</span> : null}
</div>
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}

View File

@@ -1,8 +1,9 @@
import { http } from "./request";
export type CurationCategory = { id: string; rootTag: string; name: string; sortOrder: number; enabled: boolean };
export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[] };
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { assignment: { skillId: string; categoryIds: string[] } };
export type CurationRoot = { id: string; name: string; iconKey: string; sortOrder: number; enabled: boolean };
export type SkillCuration = { enabled: boolean; revision: number; categories: CurationCategory[]; assignments: { skillId: string; categoryId: string }[]; roots?: CurationRoot[]; rootAssignments?: { skillId: string; rootId: string }[] };
export type CurationChange = { enabled: boolean } | { category: CurationCategory } | { root: CurationRoot } | { assignment: { skillId: string; categoryIds: string[]; rootId?: string } };
export function getSkillCuration(admin = false) { return http.get<SkillCuration>(admin ? "/admin/skill-curation" : "/skills/curation"); }
export function updateSkillCuration(expectedRevision: number, change: CurationChange) {
return http.put<SkillCuration>("/admin/skill-curation", { expectedRevision, ...change });

View File

@@ -109,6 +109,7 @@ export type SkillList = {
};
export type ListSkillsInput = {
platformRootId?: string;
platformCategoryId?: string;
platformUncategorized?: boolean;
page?: number;