feat(web): 工作台 - 优化 Agent 工作区与技能库
This commit is contained in:
1 parent
b4111a8721
commit
5df5e09c63
22 files changed
+1681
-196
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"]}
|
||||
|
||||
Reference in new issue
Block a user