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 = { BOSS: "负责人", ADMIN: "管理员", AI: "AI工程师", FE: "前端开发", BE: "后端开发", }; return m[role] || role; } function groupedByCategory(bindings: UserSkillBinding[]) { const map = new Map(); 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(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

加载中…

; } if (userQuery.error || !user) { return

用户不存在或无权限查看

; } if (bindingsQuery.error) { return

技能数据加载失败

; } return (

{user.name || user.username} 技能详情

角色: {roleLabel(user.role)} · 项目数: {(user.project_ids ?? []).length}

{bindings.length === 0 && ( 当前暂无技能绑定 )} {[...grouped.entries()].map(([category, items]) => ( {toZhSkillCategory(category)}
{items.map((row) => (

{row.skill_name}

最近使用: {row.last_used_at ? row.last_used_at.slice(0, 10) : "暂无"}

batchMode && canEditProficiency && updateMutation.mutate({ skillId: row.skill_tag_id, payload: { proficiency: Number(e.target.value || 0) }, }) } /> batchMode && canEditScores && updateMutation.mutate({ skillId: row.skill_tag_id, payload: { quality_score: Number(e.target.value || 0) }, }) } /> batchMode && canEditScores && updateMutation.mutate({ skillId: row.skill_tag_id, payload: { efficiency_score: Number(e.target.value || 0) }, }) } />
))} {items.length === 0 &&

暂无技能绑定

}
))}
技能雷达图
{chartData.length === 0 ? (
当前分类暂无可展示技能数据
) : (
)}
提示 手动分数编辑会在下次 AI 更新时被覆盖。 添加技能 选择一个未绑定技能并添加到当前用户。
); }