288 lines
18 KiB
TypeScript
288 lines
18 KiB
TypeScript
import { App, Button, Dropdown, Input, Select, Tooltip } from "antd";
|
|
import { Check, Heart, Library, LoaderCircle, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, UserRound } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import { PaginationBar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
|
|
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
import { fallbackSkillCategories, formatSkillCount, groupSkills } from "@/pages/skills/skill-catalog";
|
|
import { SkillDetailDrawer } from "@/pages/skills/skill-detail-drawer";
|
|
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
|
|
import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
|
|
|
|
const scopeOptions = [
|
|
{ label: "技能广场", value: "public", icon: Sparkles },
|
|
{ label: "我的技能", value: "mine", icon: Library },
|
|
{ label: "我创建的", value: "created", icon: UserRound },
|
|
{ label: "我的收藏", value: "favorites", icon: Heart },
|
|
];
|
|
|
|
const sortOptions: { label: string; value: SkillSort }[] = [
|
|
{ label: "最多加入", value: "popular" },
|
|
{ label: "最新发布", value: "new" },
|
|
{ label: "最近更新", value: "updated" },
|
|
];
|
|
|
|
export default function SkillsPage() {
|
|
const { message, modal } = App.useApp();
|
|
const [scope, setScope] = useState<SkillScope>("public");
|
|
const [sort, setSort] = useState<SkillSort>("popular");
|
|
const [search, setSearch] = useState("");
|
|
const debouncedSearch = useDebouncedValue(search, 250);
|
|
const [tag, setTag] = useState("all");
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(20);
|
|
const [skills, setSkills] = useState<Skill[]>([]);
|
|
const [categories, setCategories] = useState<SkillCategory[]>(fallbackSkillCategories);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState("");
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const [activeSkill, setActiveSkill] = useState<Skill | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [mutatingID, setMutatingID] = useState("");
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
|
|
|
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setLoadError("");
|
|
listSkills({ page, page_size: pageSize, scope, sort, search: debouncedSearch || undefined, tag: tag === "all" ? undefined : tag })
|
|
.then((result) => {
|
|
if (cancelled) return;
|
|
setSkills(result.skills);
|
|
setTotal(result.total_count);
|
|
if (result.categories.length) setCategories(result.categories);
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
setSkills([]);
|
|
setTotal(0);
|
|
setLoadError(error instanceof Error ? error.message : "技能加载失败");
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [debouncedSearch, page, pageSize, reloadKey, scope, sort, tag]);
|
|
|
|
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
|
|
const filtersActive = Boolean(search || tag !== "all" || sort !== "popular");
|
|
|
|
const openSkill = async (skill: Skill) => {
|
|
setActiveSkill(skill);
|
|
setDetailLoading(true);
|
|
try {
|
|
const result = await getSkill(skill.skill_id);
|
|
setActiveSkill(result.skill);
|
|
patchSkill(result.skill);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "技能详情加载失败");
|
|
setActiveSkill(null);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const openEditor = async (skill?: Skill) => {
|
|
if (!skill) {
|
|
setEditingSkill(null);
|
|
setEditorOpen(true);
|
|
return;
|
|
}
|
|
try {
|
|
const result = skill.instruction ? { skill } : await getSkill(skill.skill_id);
|
|
setActiveSkill(null);
|
|
setEditingSkill(result.skill);
|
|
setEditorOpen(true);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "技能读取失败");
|
|
}
|
|
};
|
|
|
|
const patchSkill = (next: Skill) => {
|
|
setSkills((items) => items.map((item) => item.skill_id === next.skill_id ? { ...item, ...next, instruction: next.instruction || item.instruction } : item));
|
|
setActiveSkill((current) => current?.skill_id === next.skill_id ? { ...current, ...next, instruction: next.instruction || current.instruction } : current);
|
|
};
|
|
|
|
const toggleAdded = async (skill: Skill) => {
|
|
if (skill.is_owner) return;
|
|
setMutatingID(skill.skill_id);
|
|
try {
|
|
const result = skill.is_added ? await removeSkill(skill.skill_id) : await addSkill(skill.skill_id);
|
|
patchSkill(result.skill);
|
|
message.success(result.skill.is_added ? "已加入我的技能" : "已从我的技能移除");
|
|
if (scope === "mine" && !result.skill.is_added) reload();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "技能状态更新失败");
|
|
} finally {
|
|
setMutatingID("");
|
|
}
|
|
};
|
|
|
|
const toggleLiked = async (skill: Skill) => {
|
|
setMutatingID(skill.skill_id);
|
|
try {
|
|
const result = skill.is_like ? await unlikeSkill(skill.skill_id) : await likeSkill(skill.skill_id);
|
|
patchSkill(result.skill);
|
|
message.success(result.skill.is_like ? "已收藏" : "已取消收藏");
|
|
if (scope === "favorites" && !result.skill.is_like) reload();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "收藏状态更新失败");
|
|
} finally {
|
|
setMutatingID("");
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (skill: Skill) => {
|
|
modal.confirm({
|
|
title: `删除“${skill.skill_name}”?`,
|
|
content: "删除后,其他用户将无法继续使用该技能,已有加入和收藏关系也会一并移除。",
|
|
okText: "删除技能",
|
|
okButtonProps: { danger: true },
|
|
cancelText: "取消",
|
|
onOk: async () => {
|
|
try {
|
|
await deleteSkill(skill.skill_id);
|
|
setActiveSkill(null);
|
|
message.success("技能已删除");
|
|
reload();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "技能删除失败");
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<WorkspacePage className="library-page skills-library-page" grid>
|
|
<PageHeader icon="skills" title="技能库" description="把常用的提示词、角色设定和创作方法收进自己的工具架。" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={null} />
|
|
|
|
<div className="mt-1 flex flex-col border-b border-border/75 xl:flex-row xl:items-end xl:justify-between">
|
|
<nav className="thin-scrollbar -mb-px flex min-w-0 overflow-x-auto" aria-label="技能库范围" role="tablist">
|
|
{scopeOptions.map((option) => {
|
|
const Icon = option.icon;
|
|
const active = scope === option.value;
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active}
|
|
className={`relative inline-flex h-12 shrink-0 items-center gap-2 border-b-2 px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${active ? "border-foreground font-medium text-foreground" : "border-transparent text-foreground/48 hover:text-foreground/76"}`}
|
|
onClick={() => { setScope(option.value as SkillScope); setPage(1); }}
|
|
>
|
|
<Icon className="size-4" />
|
|
{option.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="library-toolbar-controls flex min-w-0 flex-wrap items-center gap-2 py-2.5 xl:flex-nowrap xl:justify-end">
|
|
<Input className="w-full sm:!w-72" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
|
|
<Select className="w-[136px]" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
|
|
<Select className="w-[124px]" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
|
|
{filtersActive ? (
|
|
<Tooltip title="重置筛选">
|
|
<Button type="text" aria-label="重置筛选" icon={<RotateCcw className="size-4" />} onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }} />
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
|
|
<div className="space-y-9 py-6">
|
|
{groupedSkills.map((group) => (
|
|
<section key={group.value} aria-labelledby={`skill-category-${group.value}`}>
|
|
<div className="mb-3 flex items-baseline justify-between px-0.5">
|
|
<h2 id={`skill-category-${group.value}`} className="text-sm font-medium text-foreground/62">{group.label}</h2>
|
|
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
|
|
</div>
|
|
<div className="library-grid skill-library-grid">
|
|
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
|
|
{group.skills.map((skill) => <SkillCard key={skill.skill_id} skill={skill} loading={mutatingID === skill.skill_id} 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={() => void openEditor()}>创建技能</Button>
|
|
: undefined}
|
|
/>
|
|
)}
|
|
|
|
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
|
|
</WorkspacePage>
|
|
|
|
<SkillDetailDrawer skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skill_id)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} />
|
|
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
|
|
return (
|
|
<article className="library-card skill-library-card group">
|
|
<div className="flex min-h-8 items-start gap-3">
|
|
<button type="button" className="min-w-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
|
<h3 className="line-clamp-1 text-[var(--fs-body-lg)] font-semibold leading-6">{skill.skill_name}</h3>
|
|
</button>
|
|
{skill.is_owner ? (
|
|
<Dropdown
|
|
trigger={["click"]}
|
|
menu={{
|
|
items: [
|
|
{ key: "edit", label: "编辑技能" },
|
|
{ key: "delete", label: "删除技能", danger: true },
|
|
],
|
|
onClick: ({ key }) => key === "edit" ? onEdit() : onDelete(),
|
|
}}
|
|
>
|
|
<button type="button" aria-label="技能操作" className="-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<MoreHorizontal className="size-4" />
|
|
</button>
|
|
</Dropdown>
|
|
) : (
|
|
<Tooltip title={`${skill.is_added ? "从我的技能移除" : "加入我的技能"} · ${formatSkillCount(skill.added_count)} 人已加入`}>
|
|
<button type="button" disabled={loading} aria-label={skill.is_added ? "从我的技能移除" : "加入我的技能"} className={`-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait ${skill.is_added ? "bg-foreground/[.08] text-foreground" : "text-foreground/70 hover:bg-foreground/[.07] hover:text-foreground"}`} onClick={onAdd}>
|
|
{loading ? <LoaderCircle className="size-4 animate-spin" /> : skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />}
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
<button type="button" className="mt-1 min-h-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
|
|
<p className="line-clamp-3 text-xs leading-5 text-foreground/52">{skill.description || "暂无技能简介"}</p>
|
|
</button>
|
|
<div className="mt-3 flex min-w-0 items-center gap-2 border-t border-border/45 pt-3 text-[var(--fs-label)] text-foreground/42">
|
|
<button type="button" disabled={loading} className="inline-flex shrink-0 items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait" aria-label={skill.is_like ? "取消收藏" : "收藏"} onClick={onLike}>
|
|
<Heart className={`size-3.5 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />
|
|
<span>{formatSkillCount(skill.like_count)}</span>
|
|
</button>
|
|
<span className="truncate">来自 · {skill.effective_user.name || "未知用户"}</span>
|
|
{skill.is_private ? <span className="ml-auto shrink-0 text-foreground/55">仅自己</span> : null}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function SkillSkeleton() {
|
|
return <div className="grid grid-cols-1 gap-3 py-6 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[178px] animate-pulse rounded-md border border-border/45 bg-foreground/[.035]" />)}</div>;
|
|
}
|