PMFlow/frontend/src/pages/UserSkillsPage.tsx

449 lines
17 KiB
TypeScript

import { useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
Tooltip,
} from "recharts";
import { ArrowLeft, Download, Plus, RefreshCw } from "lucide-react";
import { getUser, type UserRole } from "@/api/users";
import {
addUserSkill,
getSkillTags,
getUserSkillBindings,
removeUserSkill,
updateUserSkill,
type UserSkillBinding,
} from "@/api/skill";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/auth";
import { isManagerRole } from "@/lib/rbac";
import { useToast } from "@/components/ui/toast";
import { toZhSkillCategory, toZhSkillLevel } from "@/lib/display";
const LEVEL_OPTIONS = ["beginner", "intermediate", "advanced", "expert"] as const;
function getErrorMessage(error: unknown): string {
const detail = (error as AxiosError<{ detail?: string }>)?.response?.data?.detail;
return detail || "请求失败";
}
function roleLabel(role?: UserRole | string) {
if (!role) return "-";
const m: Record<string, string> = {
BOSS: "负责人",
ADMIN: "管理员",
AI: "AI工程师",
FE: "前端开发",
BE: "后端开发",
};
return m[role] || role;
}
function groupedByCategory(bindings: UserSkillBinding[]) {
const map = new Map<string, UserSkillBinding[]>();
for (const row of bindings) {
const key = (row as UserSkillBinding & { category?: string }).category || "uncategorized";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
return map;
}
export default function UserSkillsPage() {
const { id } = useParams<{ id: string }>();
const userId = id || "";
const queryClient = useQueryClient();
const chartWrapperRef = useRef<HTMLDivElement | null>(null);
const currentUser = useAuthStore((s) => s.user);
const { toast } = useToast();
const [batchMode, setBatchMode] = useState(false);
const [warningOpen, setWarningOpen] = useState(false);
const [addSkillOpen, setAddSkillOpen] = useState(false);
const [selectedSkillId, setSelectedSkillId] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const userQuery = useQuery({
queryKey: ["user", userId],
queryFn: () => getUser(userId),
enabled: !!userId,
});
const bindingsQuery = useQuery({
queryKey: ["user-skills", userId],
queryFn: () => getUserSkillBindings(userId),
enabled: !!userId,
});
const tagsQuery = useQuery({
queryKey: ["skill-tags-all"],
queryFn: () => getSkillTags(),
});
const removeMutation = useMutation({
mutationFn: ({ skillId }: { skillId: string }) => removeUserSkill(userId, skillId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
toast({ title: "技能移除成功", variant: "success" });
},
onError: (error) => toast({ title: `技能移除失败:${getErrorMessage(error)}`, variant: "error" }),
});
const updateMutation = useMutation({
mutationFn: ({
skillId,
payload,
}: {
skillId: string;
payload: {
level?: string;
proficiency?: number;
quality_score?: number;
efficiency_score?: number;
};
}) => updateUserSkill(userId, skillId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
toast({ title: "技能已更新", variant: "success" });
},
onError: (error) => toast({ title: `技能更新失败:${getErrorMessage(error)}`, variant: "error" }),
});
const addMutation = useMutation({
mutationFn: (payload: { skill_tag_id: string }) => addUserSkill(userId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
setAddSkillOpen(false);
setSelectedSkillId("");
toast({ title: "技能添加成功", variant: "success" });
},
onError: (error) => toast({ title: `技能添加失败:${getErrorMessage(error)}`, variant: "error" }),
});
const user = userQuery.data;
const bindings = bindingsQuery.data ?? [];
const grouped = useMemo(() => groupedByCategory(bindings), [bindings]);
const isSelf = String(currentUser?.id || "") === String(userId);
const canManageAll = isManagerRole(currentUser?.role);
const canEditProficiency = canManageAll || isSelf;
const canEditScores = canManageAll;
const canAddRemove = canManageAll;
const chartData = useMemo(() => {
const mapped = bindings.map((row) => {
const score = Math.round((Number(row.quality_score ?? 0) * 0.6 + Number(row.efficiency_score ?? 0) * 0.4) || row.proficiency);
return {
subject: row.skill_name,
score: Math.max(0, Math.min(100, score)),
category: (row as UserSkillBinding & { category?: string }).category || "uncategorized",
};
});
if (selectedCategory === "all") return mapped;
return mapped.filter((row) => row.category === selectedCategory);
}, [bindings, selectedCategory]);
const availableSkillTags = useMemo(() => {
const owned = new Set(bindings.map((b) => b.skill_tag_id));
return (tagsQuery.data ?? []).filter((tag) => !owned.has(tag.id));
}, [tagsQuery.data, bindings]);
function exportChart() {
const root = chartWrapperRef.current;
const svg = root?.querySelector("svg");
if (!svg) return;
const serializer = new XMLSerializer();
const source = serializer.serializeToString(svg);
const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.width || 1200;
canvas.height = img.height || 800;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const pngUrl = canvas.toDataURL("image/png");
const a = document.createElement("a");
a.href = pngUrl;
a.download = `user-${userId}-skills-radar.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(svgUrl);
};
img.src = svgUrl;
}
if (userQuery.isLoading || bindingsQuery.isLoading) {
return <p className="text-muted-foreground">加载中…</p>;
}
if (userQuery.error || !user) {
return <p className="text-destructive">用户不存在或无权限查看</p>;
}
if (bindingsQuery.error) {
return <p className="text-destructive">技能数据加载失败</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to={`/users/${userId}/profile`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">{user.name || user.username} 技能详情</h1>
<p className="text-muted-foreground">
角色: {roleLabel(user.role)} · 项目数: {(user.project_ids ?? []).length}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={!canManageAll}
onClick={() => {
queryClient.invalidateQueries({ queryKey: ["user-skills", userId] });
setWarningOpen(true);
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
刷新评分
</Button>
<Button variant="outline" onClick={() => setBatchMode((v) => !v)}>
{batchMode ? "退出批量编辑" : "批量编辑"}
</Button>
<Button disabled={!canAddRemove} onClick={() => setAddSkillOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
添加技能
</Button>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-5">
<div className="space-y-4 xl:col-span-3">
{bindings.length === 0 && (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
当前暂无技能绑定
</CardContent>
</Card>
)}
{[...grouped.entries()].map(([category, items]) => (
<Card key={category}>
<CardHeader>
<CardTitle className="text-base">{toZhSkillCategory(category)}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{items.map((row) => (
<div key={row.skill_tag_id} className="rounded-md border p-3">
<div className="mb-2 flex items-center justify-between">
<div>
<p className="font-medium">{row.skill_name}</p>
<p className="text-xs text-muted-foreground">
最近使用: {row.last_used_at ? row.last_used_at.slice(0, 10) : "暂无"}
</p>
</div>
<Button
variant="destructive"
size="sm"
disabled={!canAddRemove || !!row.is_system || removeMutation.isPending}
onClick={() => removeMutation.mutate({ skillId: row.skill_tag_id })}
>
移除
</Button>
</div>
<div className="grid gap-2 sm:grid-cols-4">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={row.level}
disabled={!canEditProficiency}
onChange={(e) =>
updateMutation.mutate({
skillId: row.skill_tag_id,
payload: { level: e.target.value },
})
}
>
{LEVEL_OPTIONS.map((lv) => (
<option key={lv} value={lv}>
{toZhSkillLevel(lv)}
</option>
))}
</select>
<Input
type="number"
min={0}
max={100}
defaultValue={row.proficiency}
disabled={!batchMode || !canEditProficiency}
onBlur={(e) =>
batchMode &&
canEditProficiency &&
updateMutation.mutate({
skillId: row.skill_tag_id,
payload: { proficiency: Number(e.target.value || 0) },
})
}
/>
<Input
type="number"
min={0}
max={100}
step={0.1}
defaultValue={row.quality_score ?? 0}
disabled={!batchMode || !canEditScores}
onBlur={(e) =>
batchMode &&
canEditScores &&
updateMutation.mutate({
skillId: row.skill_tag_id,
payload: { quality_score: Number(e.target.value || 0) },
})
}
/>
<Input
type="number"
min={0}
max={100}
step={0.1}
defaultValue={row.efficiency_score ?? 0}
disabled={!batchMode || !canEditScores}
onBlur={(e) =>
batchMode &&
canEditScores &&
updateMutation.mutate({
skillId: row.skill_tag_id,
payload: { efficiency_score: Number(e.target.value || 0) },
})
}
/>
</div>
</div>
))}
{items.length === 0 && <p className="text-sm text-muted-foreground">暂无技能绑定</p>}
</div>
</CardContent>
</Card>
))}
</div>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base">技能雷达图</CardTitle>
<div className="flex items-center gap-2">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="all">全部分类</option>
{[...new Set(bindings.map((row) => (row as UserSkillBinding & { category?: string }).category || "uncategorized"))].map(
(category) => (
<option key={category} value={category}>
{toZhSkillCategory(category)}
</option>
)
)}
</select>
<Button variant="outline" size="sm" onClick={exportChart}>
<Download className="mr-2 h-4 w-4" />
导出图表
</Button>
</div>
</CardHeader>
<CardContent ref={chartWrapperRef}>
{chartData.length === 0 ? (
<div className="flex h-[420px] items-center justify-center text-sm text-muted-foreground">
当前分类暂无可展示技能数据
</div>
) : (
<div className="h-[420px]">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={chartData}>
<PolarGrid />
<PolarAngleAxis dataKey="subject" />
<PolarRadiusAxis domain={[0, 100]} />
<Radar dataKey="score" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.28} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
</div>
<Dialog open={warningOpen} onOpenChange={setWarningOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>提示</DialogTitle>
<DialogDescription>手动分数编辑会在下次 AI 更新时被覆盖。</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setWarningOpen(false)}>我知道了</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={addSkillOpen} onOpenChange={setAddSkillOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加技能</DialogTitle>
<DialogDescription>选择一个未绑定技能并添加到当前用户。</DialogDescription>
</DialogHeader>
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedSkillId}
onChange={(e) => setSelectedSkillId(e.target.value)}
>
<option value="">请选择技能</option>
{tagsQuery.error && <option value="">技能加载失败</option>}
{availableSkillTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name} ({toZhSkillCategory(tag.category || "uncategorized")})
</option>
))}
</select>
<DialogFooter>
<Button variant="outline" onClick={() => setAddSkillOpen(false)}>
取消
</Button>
<Button
disabled={!selectedSkillId || addMutation.isPending}
onClick={() => addMutation.mutate({ skill_tag_id: selectedSkillId })}
>
{addMutation.isPending ? "添加中…" : "添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}