426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import {
|
||
createSkillTag,
|
||
deleteSkillTag,
|
||
getSkillTags,
|
||
updateSkillTag,
|
||
type SkillTag,
|
||
} from "@/api/skill";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { useToast } from "@/components/ui/toast";
|
||
import { isManagerRole } from "@/lib/rbac";
|
||
import { toZhSkillCategory } from "@/lib/display";
|
||
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
dev: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||
algorithm: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
|
||
product: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300",
|
||
pm: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||
qa: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-300",
|
||
language: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||
framework: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||
skill: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||
tool: "bg-stone-100 text-stone-800 dark:bg-stone-800 dark:text-stone-300",
|
||
};
|
||
|
||
const CATEGORY_OPTIONS = ["dev", "algorithm", "product", "pm", "qa", "language", "framework", "skill", "tool"];
|
||
|
||
function categoryClass(category?: string | null) {
|
||
return CATEGORY_COLORS[(category || "").toLowerCase()] ?? "bg-muted text-muted-foreground";
|
||
}
|
||
|
||
export default function SkillsPage() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const canManage = isManagerRole(user?.role);
|
||
const queryClient = useQueryClient();
|
||
const { toast } = useToast();
|
||
|
||
const [search, setSearch] = useState("");
|
||
const [categoryFilter, setCategoryFilter] = useState("");
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<SkillTag | null>(null);
|
||
const [deleteRow, setDeleteRow] = useState<SkillTag | null>(null);
|
||
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["skill-tags", categoryFilter],
|
||
queryFn: () => getSkillTags({ category: categoryFilter || undefined }),
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (payload: { name: string; slug: string; category?: string; description?: string }) =>
|
||
createSkillTag(payload),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||
setCreateOpen(false);
|
||
toast({ title: "技能标签创建成功", variant: "success" });
|
||
},
|
||
onError: () => toast({ title: "技能标签创建失败", variant: "error" }),
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ tagId, payload }: { tagId: string; payload: { name?: string; category?: string; description?: string } }) =>
|
||
updateSkillTag(tagId, payload),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||
setEditRow(null);
|
||
toast({ title: "技能标签更新成功", variant: "success" });
|
||
},
|
||
onError: () => toast({ title: "技能标签更新失败", variant: "error" }),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (tagId: string) => deleteSkillTag(tagId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["skill-tags"] });
|
||
setDeleteRow(null);
|
||
toast({ title: "技能标签删除成功", variant: "success" });
|
||
},
|
||
onError: () => toast({ title: "技能标签删除失败", variant: "error" }),
|
||
});
|
||
|
||
const rows = useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
return (data ?? []).filter((row) => {
|
||
if (!q) return true;
|
||
return (
|
||
row.name.toLowerCase().includes(q) ||
|
||
(row.slug || "").toLowerCase().includes(q) ||
|
||
(row.category || "").toLowerCase().includes(q)
|
||
);
|
||
});
|
||
}, [data, search]);
|
||
const renderTagActions = (tag: SkillTag) => (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => setEditRow(tag)}>
|
||
<Pencil className="mr-2 h-4 w-4" />
|
||
编辑
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
disabled={!!tag.is_system}
|
||
className="text-destructive focus:text-destructive"
|
||
onClick={() => setDeleteRow(tag)}
|
||
>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">能力词库</h1>
|
||
<p className="text-muted-foreground">高级设置入口;标签只作为执行能力图谱的参考信号,不再作为任务分配中心。</p>
|
||
</div>
|
||
{canManage && (
|
||
<Button onClick={() => setCreateOpen(true)}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建标签
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||
<div>
|
||
<CardTitle>标签列表</CardTitle>
|
||
<CardDescription>共 {rows.length} 个</CardDescription>
|
||
</div>
|
||
<div className="ml-auto w-full sm:w-auto">
|
||
<Input
|
||
placeholder="搜索名称/标识/分类"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full sm:w-72"
|
||
/>
|
||
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
|
||
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
|
||
<select
|
||
className="h-10 min-w-[132px] rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
value={categoryFilter}
|
||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||
>
|
||
<option value="">全部分类</option>
|
||
{CATEGORY_OPTIONS.map((c) => (
|
||
<option key={c} value={c}>
|
||
{toZhSkillCategory(c)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{isLoading ? (
|
||
<p className="py-8 text-center text-muted-foreground">加载中…</p>
|
||
) : error ? (
|
||
<p className="py-8 text-center text-destructive">加载失败</p>
|
||
) : rows.length === 0 ? (
|
||
<p className="py-8 text-center text-muted-foreground">暂无技能标签</p>
|
||
) : (
|
||
<>
|
||
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
|
||
<Table className="min-w-[760px]">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="min-w-[180px] whitespace-nowrap">名称</TableHead>
|
||
<TableHead className="min-w-[120px] whitespace-nowrap">分类</TableHead>
|
||
<TableHead className="min-w-[96px] whitespace-nowrap">关联人数</TableHead>
|
||
<TableHead className="min-w-[220px] whitespace-nowrap">描述</TableHead>
|
||
{canManage && <TableHead className="min-w-[40px] whitespace-nowrap" />}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{rows.map((tag) => (
|
||
<TableRow key={tag.id}>
|
||
<TableCell className="min-w-[180px]">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">{tag.name}</span>
|
||
{tag.is_system && (
|
||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||
系统
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="min-w-[120px]">
|
||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||
{toZhSkillCategory(tag.category)}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="min-w-[96px] whitespace-nowrap">{tag.linked_user_count ?? 0}</TableCell>
|
||
<TableCell className="min-w-[220px] text-muted-foreground">{tag.description || "-"}</TableCell>
|
||
{canManage && <TableCell className="min-w-[40px]">{renderTagActions(tag)}</TableCell>}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<div className="space-y-3 md:hidden">
|
||
{rows.map((tag) => (
|
||
<article key={tag.id} className="rounded-lg border bg-white p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-base font-semibold">{tag.name}</span>
|
||
{tag.is_system && (
|
||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||
系统
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-2">
|
||
<span className={`rounded px-2 py-0.5 text-xs ${categoryClass(tag.category)}`}>
|
||
{toZhSkillCategory(tag.category)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{canManage && renderTagActions(tag)}
|
||
</div>
|
||
<p className="mt-3 text-sm text-muted-foreground">{tag.description || "暂无描述"}</p>
|
||
<p className="mt-2 text-xs text-muted-foreground">关联人数:{tag.linked_user_count ?? 0}</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<SkillTagDialog
|
||
open={createOpen}
|
||
title="新建技能标签"
|
||
description="创建自定义技能标签"
|
||
onOpenChange={setCreateOpen}
|
||
onSubmit={(payload) => {
|
||
if (!payload.name || !payload.slug) return;
|
||
createMutation.mutate({
|
||
name: payload.name,
|
||
slug: payload.slug,
|
||
category: payload.category,
|
||
description: payload.description,
|
||
});
|
||
}}
|
||
isPending={createMutation.isPending}
|
||
/>
|
||
|
||
<SkillTagDialog
|
||
open={!!editRow}
|
||
title="编辑技能标签"
|
||
description={editRow?.is_system ? "系统标签仅允许修改分类" : "可修改名称、分类与描述"}
|
||
onOpenChange={(open) => !open && setEditRow(null)}
|
||
defaultValue={editRow || undefined}
|
||
disableName={!!editRow?.is_system}
|
||
onSubmit={(payload) => {
|
||
if (!editRow) return;
|
||
updateMutation.mutate({ tagId: editRow.id, payload });
|
||
}}
|
||
isPending={updateMutation.isPending}
|
||
/>
|
||
|
||
<Dialog open={!!deleteRow} onOpenChange={(open) => !open && setDeleteRow(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除</DialogTitle>
|
||
<DialogDescription>删除后将移除该标签及关联关系,无法恢复。</DialogDescription>
|
||
</DialogHeader>
|
||
<p className="text-sm">标签:{deleteRow?.name}</p>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteRow(null)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
disabled={deleteMutation.isPending}
|
||
onClick={() => deleteRow && deleteMutation.mutate(deleteRow.id)}
|
||
>
|
||
{deleteMutation.isPending ? "删除中…" : "删除"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkillTagDialog(props: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
title: string;
|
||
description: string;
|
||
defaultValue?: SkillTag;
|
||
disableName?: boolean;
|
||
isPending?: boolean;
|
||
onSubmit: (payload: { name?: string; slug?: string; category?: string; description?: string }) => void;
|
||
}) {
|
||
const { open, onOpenChange, title, description, defaultValue, disableName, isPending, onSubmit } = props;
|
||
const [name, setName] = useState(defaultValue?.name || "");
|
||
const [slug, setSlug] = useState(defaultValue?.slug || "");
|
||
const [category, setCategory] = useState(defaultValue?.category || "dev");
|
||
const [descriptionValue, setDescriptionValue] = useState(defaultValue?.description || "");
|
||
|
||
const isCreate = !defaultValue;
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onOpenChange={(next) => {
|
||
if (next) {
|
||
setName(defaultValue?.name || "");
|
||
setSlug(defaultValue?.slug || "");
|
||
setCategory(defaultValue?.category || "dev");
|
||
setDescriptionValue(defaultValue?.description || "");
|
||
}
|
||
onOpenChange(next);
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>{title}</DialogTitle>
|
||
<DialogDescription>{description}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3">
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">名称</label>
|
||
<Input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
disabled={disableName}
|
||
placeholder="例如:前端框架"
|
||
/>
|
||
</div>
|
||
{isCreate && (
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">标识</label>
|
||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="例如:qian-duan-kuang-jia" />
|
||
</div>
|
||
)}
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">分类</label>
|
||
<select
|
||
className="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
value={category}
|
||
onChange={(e) => setCategory(e.target.value)}
|
||
>
|
||
{CATEGORY_OPTIONS.map((c) => (
|
||
<option key={c} value={c}>
|
||
{toZhSkillCategory(c)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-sm font-medium">描述</label>
|
||
<Input
|
||
value={descriptionValue}
|
||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||
placeholder="可选"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
disabled={
|
||
!!isPending ||
|
||
!category ||
|
||
(!defaultValue && (!name.trim() || !slug.trim())) ||
|
||
(!!defaultValue && !disableName && !name.trim())
|
||
}
|
||
onClick={() =>
|
||
onSubmit({
|
||
name: disableName ? undefined : name.trim(),
|
||
slug: isCreate ? slug.trim() : undefined,
|
||
category: category.trim(),
|
||
description: descriptionValue.trim() || undefined,
|
||
})
|
||
}
|
||
>
|
||
{isPending ? "保存中…" : "保存"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|