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>;
}