449 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|