feat(web): 工作台 - 优化 Agent 工作区与技能库

This commit is contained in:
ddcat666 committed 2026-09-27 09:45:45 +08:00
1 parent b4111a8721
commit 5df5e09c63
22 files changed
+1681 -196

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
.canvas-sync-status-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding-inline: 9px;
border: 1px solid transparent;
border-radius: 999px;
color: var(--user-ink-muted, var(--muted-foreground));
transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
}
.canvas-sync-status-trigger:hover,
.canvas-sync-status-trigger[aria-expanded="true"] {
background: var(--user-surface-hover, var(--accent));
color: var(--user-ink, var(--foreground));
}
.canvas-sync-status-trigger--done { color: var(--status-success, #3f8f68); }
.canvas-sync-status-trigger--pending { color: var(--status-warning, #b7791f); }
.canvas-sync-status-trigger--error,
.canvas-sync-status-trigger--conflict { color: var(--status-error, var(--destructive)); }
.canvas-sync-popover .ant-popover-inner {
overflow: hidden;
padding: 0;
border: 1px solid var(--user-border, var(--border));
border-radius: var(--user-radius-card, 16px);
background: var(--user-surface-raised, var(--popover));
box-shadow: 0 18px 48px color-mix(in srgb, var(--user-ink, var(--foreground)) 16%, transparent);
}
.canvas-sync-popover .ant-popover-inner-content { padding: 0; }
.canvas-sync-panel {
width: min(340px, calc(100vw - 32px));
color: var(--user-ink, var(--foreground));
}
.canvas-sync-panel__header {
display: flex;
align-items: center;
gap: 11px;
padding: 16px 17px 12px;
}
.canvas-sync-panel__status-icon {
display: grid;
width: 34px;
height: 34px;
flex: 0 0 34px;
place-items: center;
border-radius: 11px;
background: color-mix(in srgb, var(--user-ink-muted, var(--muted-foreground)) 11%, transparent);
color: var(--user-ink-muted, var(--muted-foreground));
}
.canvas-sync-panel__status-icon svg { width: 17px; height: 17px; }
.canvas-sync-panel--done .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-success, #3f8f68) 13%, transparent); color: var(--status-success, #3f8f68); }
.canvas-sync-panel--pending .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-warning, #b7791f) 14%, transparent); color: var(--status-warning, #b7791f); }
.canvas-sync-panel--error .canvas-sync-panel__status-icon,
.canvas-sync-panel--conflict .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-error, var(--destructive)) 13%, transparent); color: var(--status-error, var(--destructive)); }
.canvas-sync-panel__heading { min-width: 0; }
.canvas-sync-panel__eyebrow {
display: block;
margin-bottom: 2px;
color: var(--user-ink-soft, var(--muted-foreground));
font-size: 11px;
letter-spacing: .04em;
}
.canvas-sync-panel__heading strong { display: block; font-size: 14px; font-weight: 650; }
.canvas-sync-panel__description {
margin: 0;
padding: 0 17px 13px;
color: var(--user-ink-muted, var(--muted-foreground));
font-size: 12px;
line-height: 1.65;
}
.canvas-sync-panel__callout {
display: grid;
gap: 3px;
margin: 0 17px 13px;
padding: 10px 11px;
border: 1px solid color-mix(in srgb, var(--status-warning, #b7791f) 25%, var(--user-border, var(--border)));
border-radius: 10px;
background: color-mix(in srgb, var(--status-warning, #b7791f) 8%, var(--user-surface, var(--background)));
color: var(--user-ink-muted, var(--muted-foreground));
font-size: 11px;
line-height: 1.55;
}
.canvas-sync-panel--error .canvas-sync-panel__callout { border-color: color-mix(in srgb, var(--status-error, var(--destructive)) 25%, var(--user-border, var(--border))); background: color-mix(in srgb, var(--status-error, var(--destructive)) 7%, var(--user-surface, var(--background))); }
.canvas-sync-panel__callout strong { color: var(--user-ink, var(--foreground)); font-size: 12px; }
.canvas-sync-panel__actions {
display: grid;
gap: 8px;
padding: 0 17px 15px;
}
.canvas-sync-panel__actions .ant-btn { border-radius: 9px; }
.canvas-sync-panel__secondary-actions {
display: flex;
justify-content: space-between;
gap: 4px;
margin: 1px -5px -3px;
}
.canvas-sync-panel__secondary-actions .ant-btn {
min-width: 0;
padding-inline: 6px;
color: var(--user-ink-muted, var(--muted-foreground));
font-size: 11px;
}
.canvas-sync-panel__secondary-actions .ant-btn:hover { color: var(--user-ink, var(--foreground)); background: var(--user-surface-hover, var(--accent)); }
.canvas-sync-confirm-copy p { margin: 0 0 8px; line-height: 1.65; }
.canvas-sync-confirm-copy p:last-child { margin-bottom: 0; color: var(--muted-foreground); font-size: 12px; }
.canvas-sync-modal-icon { color: var(--status-warning, #b7791f); }
@media (prefers-reduced-motion: reduce) {
.canvas-sync-status-trigger { transition: none; }
}
+120 -37
View File
@@ -1,65 +1,147 @@
import { App, Button, Dropdown, Popover } from "antd";
import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
import { CloudCheck, CloudOff, Download, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { useState } from "react";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { readAllCanvasSyncDrafts, readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { retryRemoteUserDataSync } from "@/services/user-data-sync";
import "./canvas-sync-status.css";
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
const [busy, setBusy] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const phase = progress?.phase;
const conflict = phase === "conflict";
const failed = phase === "error" || conflict || !phase;
const error = phase === "error";
const retryScheduled = error && progress?.message?.includes("自动重试");
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
const label = conflict ? "版本冲突 · 未同步" : !phase ? "尚未同步" : failed ? "云端未保存" : saving ? "正在保存" : "已保存";
const tone = conflict ? "conflict" : error ? "error" : saving ? "pending" : phase === "done" ? "done" : "idle";
const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步";
const description = conflict
? "本地修改没有被覆盖,已保存为本地草稿。"
: error
? progress?.message || "本地内容仍然保留,云端暂未确认。"
: saving
? progress?.message || "正在把本地修改同步到云端。"
: phase === "done"
? progress?.message || "当前画布与云端版本一致。"
: "本地内容会先保存到浏览器,再尝试同步到云端。";
const run = async (operation: () => Promise<unknown>) => {
setBusy(true);
try {
await operation();
} catch (error) {
message.error(error instanceof Error ? error.message : "操作失败,请重试");
} catch (cause) {
message.error(cause instanceof Error ? cause.message : "操作失败,请重试");
throw cause;
} finally {
setBusy(false);
}
};
const confirmLoadLatest = () => {
modal.confirm({
title: "加载云端最新版本?",
icon: <CloudOff className="canvas-sync-modal-icon" aria-hidden="true" />,
content: (
<div className="canvas-sync-confirm-copy">
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。</p>
</div>
),
okText: "保留草稿并加载",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () =>
run(async () => {
await onLoadLatest();
setStatusOpen(false);
message.success("云端版本已加载,本地内容已保留为草稿");
}),
});
};
return (
<>
<Popover
trigger="click"
placement="bottom"
open={statusOpen}
onOpenChange={setStatusOpen}
content={
<div className="max-w-80 space-y-3" data-canvas-no-zoom>
<p role="status" className="text-sm">
{progress?.message || (phase === "done" ? "画布已同步到云端" : "尚未确认云端保存,请保留本地内容")}
</p>
{conflict ? <p className="text-xs text-muted-foreground">此画布的自动提交已暂停。加载最新版前会保留本地草稿,可下载后从画布列表导入为副本。</p> : null}
<div className="flex flex-wrap gap-2">
<Button size="small" loading={busy} onClick={() => void run(onLoadLatest)}>
加载云端最新版本
</Button>
<Popover
trigger="click"
placement="bottom"
open={statusOpen}
onOpenChange={setStatusOpen}
arrow={false}
classNames={{ root: "canvas-sync-popover", container: "canvas-sync-popover-surface", content: "canvas-sync-popover-content" }}
content={
<div className={`canvas-sync-panel canvas-sync-panel--${tone}`} data-canvas-no-zoom>
<div className="canvas-sync-panel__header">
<span className="canvas-sync-panel__status-icon" aria-hidden="true">
{saving ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff /> : <CloudCheck />}
</span>
<div className="canvas-sync-panel__heading">
<span className="canvas-sync-panel__eyebrow">云端同步</span>
<strong>{label}</strong>
</div>
</div>
<p className="canvas-sync-panel__description" role="status">{description}</p>
{conflict ? (
<div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
</div>
) : error ? (
<div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>
{retryScheduled
? "网络恢复后会自动重试;也可以点击“立即重试”。"
: "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
</span>
</div>
) : null}
<div className="canvas-sync-panel__actions">
{error ? (
<Button
type="primary"
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() => void run(() => retryRemoteUserDataSync(projectId)).then(() => setStatusOpen(false)).catch(() => undefined)}
>
立即重试
</Button>
) : null}
<Button
block
icon={<CloudOff className="size-3.5" />}
disabled={busy || saving}
onClick={confirmLoadLatest}
>
加载云端版
</Button>
<div className="canvas-sync-panel__secondary-actions">
<Button
type="text"
size="small"
disabled={busy}
icon={<Download className="size-3.5" />}
onClick={() =>
void run(async () => {
const project = useCanvasStore.getState().openProject(projectId);
if (project) await exportCanvasProjects([project], `${project.title}-本地草稿`);
})
if (project) await exportCanvasProjects([project], `${project.title}-本地副本`);
message.success("当前内容已下载,可作为新画布导入");
}).catch(() => undefined)
}
>
下载当前内容
下载本地副本
</Button>
{onOpenVersions ? (
<Button
type="text"
size="small"
disabled={busy}
icon={<FileClock className="size-3.5" />}
onClick={() => {
setStatusOpen(false);
onOpenVersions();
@@ -70,19 +152,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
) : null}
</div>
</div>
}
</div>
}
>
<Button
type="text"
size="small"
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
aria-label={`画布保存状态:${label}`}
aria-expanded={statusOpen}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<Button
type="text"
size="small"
danger={failed}
aria-label={`画布保存状态:${label}`}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : failed ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
</>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
);
}
+11 -1
View File
@@ -2828,7 +2828,17 @@ function InfiniteCanvasPage() {
<div className={versions.open ? "hidden" : "contents"}>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
const currentNodes = nodesRef.current;
const target = currentNodes.find((node) => node.id === nodeId);
if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }
const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null;
if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
const batchRootId = target.metadata?.batchRootId;
const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null;
if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
focusCanvasNode(nodeId);
}} />
</div>
+305 -43
View File
@@ -1,8 +1,9 @@
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { AppModal } from "@/components/ui/product/app-modal";
import { App, Button, Dropdown, Input } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, UserRound } from "lucide-react";
import { Boxes, Check, Clapperboard, FolderInput, FolderPlus, Globe2, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, Trash2, UserRound } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
@@ -13,7 +14,8 @@ import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLa
import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer";
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
import { SkillInstallModal } from "@/pages/skills/skill-install-modal";
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
import { addSkill, createSkillLibraryCategory, deleteSkill, deleteSkillLibraryCategory, getSkill, likeSkill, listSkillLibraryCategories, listSkills, removeSkill, setSkillLibraryCategory, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillLibraryCategoryList, type SkillScope, type SkillSort } from "@/services/api/skills";
import { useUserStore } from "@/stores/use-user-store";
import { Select } from "@/components/ui/base/select";
const scopeOptions = [
@@ -46,6 +48,13 @@ export default function SkillsPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [tag, setTag] = useState("all");
const [libraryCategoryId, setLibraryCategoryId] = useState("all");
const [libraryCategoryList, setLibraryCategoryList] = useState<SkillLibraryCategoryList | null>(null);
const [libraryCategoryError, setLibraryCategoryError] = useState("");
const [categoryEditorOpen, setCategoryEditorOpen] = useState(false);
const [categoryName, setCategoryName] = useState("");
const [categoryScope, setCategoryScope] = useState<"personal" | "platform">("personal");
const [categorySaving, setCategorySaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [skills, setSkills] = useState<Skill[]>([]);
@@ -71,14 +80,33 @@ export default function SkillsPage() {
const [editorOpen, setEditorOpen] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const isAdmin = useUserStore((state) => state.user?.role === "admin");
const isLibraryScope = scope === "mine" || scope === "created";
const libraryCategoryScope = scope === "created" ? "created" : "mine";
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
const selectMarketplaceCategory = (categoryId: string) => {
setScope("public");
setTag(categoryId);
setLibraryCategoryId("all");
setPage(1);
};
const selectLibraryCategory = (categoryId: string) => {
setScope((current) => current === "created" ? "created" : "mine");
setLibraryCategoryId(categoryId);
setPage(1);
};
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError("");
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: tag === "all" ? undefined : tag })
const libraryFilter = isLibraryScope
? libraryCategoryId === "__uncategorized__"
? { libraryUncategorized: true }
: libraryCategoryId !== "all" ? { libraryCategoryId } : {}
: {};
listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter })
.then((result) => {
if (cancelled) return;
setSkills(result.skills);
@@ -98,11 +126,25 @@ export default function SkillsPage() {
return () => {
cancelled = true;
};
}, [debouncedSearch, page, pageSize, reloadKey, scope, sort, tag]);
}, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag]);
useEffect(() => {
let cancelled = false;
setLibraryCategoryError("");
listSkillLibraryCategories(libraryCategoryScope)
.then((result) => { if (!cancelled) setLibraryCategoryList(result); })
.catch((error) => {
if (!cancelled) {
setLibraryCategoryList(null);
setLibraryCategoryError(error instanceof Error ? error.message : "技能库分类加载失败");
}
});
return () => { cancelled = true; };
}, [libraryCategoryScope, reloadKey]);
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
const filtersActive = Boolean(search || tag !== "all" || sort !== "popular");
const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }, []);
const filtersActive = Boolean(search || (!isLibraryScope && tag !== "all") || (isLibraryScope && libraryCategoryId !== "all") || sort !== "popular");
const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setLibraryCategoryId("all"); setSort("popular"); setPage(1); }, []);
const openSkill = async (skill: Skill) => {
setActiveSkill(skill);
@@ -147,7 +189,7 @@ export default function SkillsPage() {
const result = skill.isAdded ? await removeSkill(skill.skillId) : await addSkill(skill.skillId);
patchSkill(result.skill);
message.success(result.skill.isAdded ? "已加入我的技能" : "已从我的技能移除");
if (scope === "mine" && !result.skill.isAdded) reload();
if (scope === "mine") reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "技能状态更新失败");
} finally {
@@ -204,6 +246,124 @@ export default function SkillsPage() {
});
};
const saveLibraryCategory = async () => {
const name = categoryName.trim();
if (!name) {
message.error("请输入分类名称");
return;
}
setCategorySaving(true);
try {
await createSkillLibraryCategory({ name, scope: isAdmin ? categoryScope : "personal" });
message.success("技能库分类已创建");
setCategoryEditorOpen(false);
setCategoryName("");
setLibraryCategoryId("all");
setPage(1);
reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "分类创建失败");
} finally {
setCategorySaving(false);
}
};
const confirmDeleteLibraryCategory = (category: SkillLibraryCategory) => {
modal.confirm({
title: `删除分类“${category.name}”?`,
content: "删除分类只会解除技能与该分类的关联,不会删除技能本身。",
okText: "删除分类",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
try {
await deleteSkillLibraryCategory(category.id);
if (libraryCategoryId === category.id) setLibraryCategoryId("all");
message.success("分类已删除,技能仍保留在技能库中");
reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "分类删除失败");
throw error;
}
},
});
};
const assignLibraryCategory = async (skill: Skill, categoryId: string) => {
setMutatingID(skill.skillId);
try {
const result = await setSkillLibraryCategory(skill.skillId, categoryId);
patchSkill(result.skill);
message.success(categoryId ? "技能已归类" : "已取消技能归类");
reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "技能归类失败");
} finally {
setMutatingID("");
}
};
const libraryCategories = libraryCategoryList?.categories ?? [];
const marketplaceCategoryTotal = categories.every((category) => category.count !== undefined)
? categories.reduce((count, category) => count + (category.count ?? 0), 0)
: "—";
const renderSkillCard = (skill: Skill, index: number) => (
<SkillCard
key={skill.skillId}
skill={skill}
categories={categories}
libraryCategories={libraryCategories}
canCategorize={isLibraryScope}
loading={mutatingID === skill.skillId}
style={{ animationDelay: `${Math.min(index, 5) * 30}ms` }}
onOpen={() => void openSkill(skill)}
onAdd={() => void toggleAdded(skill)}
onLike={() => void toggleLiked(skill)}
onEdit={() => void openEditor(skill)}
onDelete={() => confirmDelete(skill)}
onSetLibraryCategory={(categoryId) => void assignLibraryCategory(skill, categoryId)}
/>
);
const skillContent = loading && !skills.length ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : isLibraryScope && skills.length ? (
<div className="library-grid skill-library-grid">
{skills.map(renderSkillCard)}
</div>
) : !isLibraryScope && groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading">
<h2 id={`skill-category-${group.value}`} className="flex items-center gap-2 text-base font-semibold text-foreground/75">
<span className="skill-group-icon"><GroupIcon /></span>
{group.label}
</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{group.skills.map(renderSkillCard)}
</div>
</section>
);
})}
</div>
) : (
<WorkspaceState
compact
className="min-h-[188px]"
icon="skills"
title={filtersActive ? "没有找到匹配技能" : scope === "created" ? "还没有创建技能" : scope === "public" ? "技能广场还是空的" : "这里还没有技能"}
description={filtersActive ? "换个关键词或分类试试。" : scope === "favorites" ? "收藏的公开技能会显示在这里。" : scope === "mine" ? "从技能广场加入后会显示在这里。" : "创建并公开第一个技能,其他用户就能直接加入使用。"}
action={filtersActive
? <Button onClick={resetFilters}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
);
return (
<>
<WorkspacePage className="library-page skills-library-page" grid>
@@ -217,6 +377,7 @@ export default function SkillsPage() {
const current = scopeOptions.findIndex((option) => option.value === scope);
const next = event.key === "Home" ? 0 : event.key === "End" ? scopeOptions.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + scopeOptions.length) % scopeOptions.length;
setScope(scopeOptions[next].value as SkillScope);
setLibraryCategoryId("all");
setPage(1);
tabsRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus();
}}>
@@ -233,7 +394,7 @@ export default function SkillsPage() {
tabIndex={active ? 0 : -1}
aria-selected={active}
className={`skills-tab${active ? " is-active" : ""}`}
onClick={() => { setScope(option.value as SkillScope); setPage(1); }}
onClick={() => { setScope(option.value as SkillScope); setLibraryCategoryId("all"); setPage(1); }}
>
<Icon className="size-4" />
<span>{option.label}</span>
@@ -245,49 +406,129 @@ export default function SkillsPage() {
</div>
<CollectionToolbar active={filtersActive} onReset={resetFilters}>
<Input className="min-w-0 sm:!w-56" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
<Select aria-label="技能分类" className="w-28" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
<Select aria-label="技能排序" className="w-24" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
</CollectionToolbar>
</div>
{loading && !skills.length ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading">
<h2 id={`skill-category-${group.value}`} className="flex items-center gap-2 text-base font-semibold text-foreground/75">
<span className="skill-group-icon"><GroupIcon /></span>
{group.label}
</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
<div className="skills-library-layout">
<aside className="skills-library-sidebar" aria-label="技能分类筛选">
<section className="skills-category-section">
<div className="skills-library-sidebar-heading">
<div>
<strong>技能广场分类</strong>
<span>筛选广场公开技能</span>
</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")}>
<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;
return (
<button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => selectMarketplaceCategory(category.value)}>
<span><Icon className="size-4" aria-hidden="true" />{category.label}</span>
<span>{category.count ?? "—"}</span>
</button>
);
})}
</nav>
</section>
<section className="skills-category-section">
<div className="skills-library-sidebar-heading">
<div>
<strong>我的技能分类</strong>
</div>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => { setCategoryName(""); setCategoryScope("personal"); setCategoryEditorOpen(true); }}>新建</Button>
</div>
{libraryCategoryError ? <div className="skills-library-category-error" role="alert">{libraryCategoryError}<Button type="link" size="small" onClick={reload}>重试</Button></div> : null}
<nav className="skills-library-category-list" aria-label="我的技能分类">
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === "all" ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === "all"} onClick={() => selectLibraryCategory("all")}>
<span><Library className="size-4" aria-hidden="true" />全部技能</span>
<span>{libraryCategoryList?.totalCount ?? "—"}</span>
</button>
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === "__uncategorized__" ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === "__uncategorized__"} onClick={() => selectLibraryCategory("__uncategorized__")}>
<span><FolderInput className="size-4" aria-hidden="true" />未分类</span>
<span>{libraryCategoryList?.uncategorizedCount ?? "—"}</span>
</button>
{libraryCategories.map((category) => (
<div key={category.id} className={`skills-library-category-row${isLibraryScope && libraryCategoryId === category.id ? " is-active" : ""}`}>
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === category.id ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === category.id} onClick={() => selectLibraryCategory(category.id)}>
<span><FolderInput className="size-4" aria-hidden="true" />{category.name}</span>
<span>{category.count}</span>
</button>
{category.scope === "personal" || isAdmin ? (
<button type="button" className="skills-library-category-delete" aria-label={`删除分类${category.name}`} title="删除分类" onClick={() => confirmDeleteLibraryCategory(category)}>
<Trash2 className="size-3.5" aria-hidden="true" />
</button>
) : null}
</div>
<div className="library-grid skill-library-grid">
{group.skills.map((skill, index) => <SkillCard key={skill.skillId} skill={skill} categories={categories} loading={mutatingID === skill.skillId} style={{ animationDelay: `${Math.min(index, 5) * 30}ms` }} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
);
})}
</div>
) : (
<WorkspaceState
compact
className="min-h-[188px]"
icon="skills"
title={filtersActive ? "没有找到匹配技能" : scope === "created" ? "还没有创建技能" : scope === "public" ? "技能广场还是空的" : "这里还没有技能"}
description={filtersActive ? "换个关键词或分类试试。" : scope === "favorites" ? "收藏的公开技能会显示在这里。" : scope === "mine" ? "从技能广场加入后会显示在这里。" : "创建并公开第一个技能,其他用户就能直接加入使用。"}
action={filtersActive
? <Button onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
)}
))}
</nav>
</section>
</aside>
<div className="skills-library-results">{skillContent}</div>
</div>
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</WorkspacePage>
<AppModal
open={categoryEditorOpen}
title={(
<div className="skills-library-category-title">
<span className="skills-library-category-title-icon"><FolderPlus className="size-4" aria-hidden="true" /></span>
<span>
<small>技能库整理</small>
<strong>新建分类</strong>
</span>
</div>
)}
className="library-modal skills-library-category-modal"
width="min(480px, calc(100vw - 32px))"
centered
okText="创建分类"
cancelText="取消"
okButtonProps={{ disabled: !categoryName.trim() }}
confirmLoading={categorySaving}
onOk={() => void saveLibraryCategory()}
onCancel={() => setCategoryEditorOpen(false)}
destroyOnHidden
>
<div className="skills-library-category-editor">
<div className="skills-library-category-field">
<label htmlFor="skills-library-category-name">分类名称</label>
<Input id="skills-library-category-name" autoFocus maxLength={64} showCount value={categoryName} onChange={(event) => setCategoryName(event.target.value)} onPressEnter={() => void saveLibraryCategory()} placeholder="例如:角色设定、分镜、宣发" />
<span className="skills-library-category-field-hint">最多 64 个字符;名称不能与当前可用分类重名。</span>
</div>
{isAdmin ? (
<div className="skills-library-category-scope">
<span className="skills-library-category-field-label">分类范围</span>
<div className="skills-library-category-scope-options" role="group" aria-label="分类范围">
<button type="button" className={`skills-library-category-scope-option${categoryScope === "personal" ? " is-active" : ""}`} aria-pressed={categoryScope === "personal"} onClick={() => setCategoryScope("personal")}>
<span className="skills-library-category-scope-icon"><UserRound className="size-4" aria-hidden="true" /></span>
<span className="skills-library-category-scope-copy"><strong>个人分类</strong><small>仅你自己可见和使用</small></span>
{categoryScope === "personal" ? <Check className="skills-library-category-scope-check size-4" aria-hidden="true" /> : null}
</button>
<button type="button" className={`skills-library-category-scope-option${categoryScope === "platform" ? " is-active" : ""}`} aria-pressed={categoryScope === "platform"} onClick={() => setCategoryScope("platform")}>
<span className="skills-library-category-scope-icon"><Globe2 className="size-4" aria-hidden="true" /></span>
<span className="skills-library-category-scope-copy"><strong>平台分类</strong><small>所有用户可见、可分配</small></span>
{categoryScope === "platform" ? <Check className="skills-library-category-scope-check size-4" aria-hidden="true" /> : null}
</button>
</div>
</div>
) : null}
<div className={`skills-library-category-note${categoryScope === "platform" && isAdmin ? " is-platform" : ""}`}>
<Sparkles className="size-4" aria-hidden="true" />
<p>{categoryScope === "platform" && isAdmin
? "平台分类对所有用户可见、可分配,并可用于各自的技能库和画布 Agent;不会替换技能广场分类。"
: "个人分类只影响你的技能库和画布 Agent。短剧影视、电商营销是技能广场的共享分类,可在左侧“技能广场分类”中筛选。"}</p>
</div>
</div>
</AppModal>
<SkillDetailModal skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skillId)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} />
<SkillInstallModal open={installOpen} onClose={() => setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} />
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
@@ -295,8 +536,10 @@ export default function SkillsPage() {
);
}
function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; categories: SkillCategory[]; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
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 }) {
const CategoryIcon = categoryIconOf(skill.tag);
const currentLibraryCategory = libraryCategories.find((category) => category.id === skill.libraryCategoryId);
const libraryCategoryLabel = currentLibraryCategory?.name || (skill.libraryCategoryId ? "已归类" : "未分类");
return (
<article style={style} className={`product-collection-card library-card library-card-surface skill-library-card group${skill.isAdded ? " is-added" : ""}`}>
<div className="skill-card-top">
@@ -304,6 +547,25 @@ function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, o
<button type="button" className="skill-card-title-button" onClick={onOpen}>
<h3>{skill.skillName}</h3>
</button>
{canCategorize ? (
<Tooltip title={`技能库分类:${libraryCategoryLabel}`}>
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "__uncategorized__", label: "未分类" },
...libraryCategories.map((category) => ({ key: category.id, label: category.name })),
],
onClick: ({ key }) => onSetLibraryCategory(key === "__uncategorized__" ? "" : key),
}}
>
<button type="button" aria-label={`设置技能分类,当前为${libraryCategoryLabel}`} className="skill-card-category-picker" disabled={loading}>
<FolderInput className="size-4" aria-hidden="true" />
<span>{libraryCategoryLabel}</span>
</button>
</Dropdown>
</Tooltip>
) : null}
{skill.isOwner ? (
<Dropdown
trigger={["click"]}