PMFlow/frontend/src/pages/SkillsPage.tsx

426 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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