feat(version): 增加参与人员AI推荐

This commit is contained in:
Script Generator
2026-06-26 17:07:09 +08:00
parent 1319677c84
commit 64491789ae
3 changed files with 964 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore';
@@ -23,6 +23,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
@@ -30,6 +31,7 @@ import { formatDateTime } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
@@ -43,6 +45,18 @@ const BUG_SEVERITY_SEGMENTS = [
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
] as const;
const RECOMMENDATION_ROLE_LABEL: Record<RecommendableRole, string> = {
frontend: '前端',
backend: '后端',
testing: '测试',
};
const RECOMMENDATION_CONFIDENCE_LABEL = {
high: '高置信',
medium: '中置信',
low: '低置信',
} as const;
const TABS = [
{ key: 'overview', label: '概览', permission: null as string | null },
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
@@ -66,7 +80,8 @@ export default function VersionDetailPage() {
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore();
const user = useAuthStore((s) => s.user);
const { departments, members: allMembers, roles } = useMemberStore();
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
const memberCandidates = useMemo(
() => allMembers.map((member) => ({
@@ -87,6 +102,8 @@ export default function VersionDetailPage() {
}
}, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRecommendModal, setShowRecommendModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
@@ -95,6 +112,14 @@ export default function VersionDetailPage() {
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => {
let active = true;
setRecommendationDataReady(false);
Promise.all([Promise.resolve(fetchMembers()), Promise.resolve(fetchCategories())]).finally(() => {
if (active) setRecommendationDataReady(true);
});
return () => { active = false; };
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
@@ -144,6 +169,36 @@ export default function VersionDetailPage() {
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id);
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
product.versions.forEach((item) => {
const uniqueNames = new Set((item.members ?? []).map((member) => member.name));
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
});
});
const memberRecommendationGroups = recommendVersionMembers({
candidates: memberCandidates,
currentMembers: version.members ?? [],
devTasks,
testCases,
bugs,
overtimeRecords: records,
versionDeadline: version.expectedReleaseDate,
scopeRequirements: recommendationVersionReqs,
scopeDevTasks: recommendationVersionDevTasks,
scopeTestCases: recommendationVersionTestCases,
taskCategories,
historicalStats: memberCandidates.flatMap((member) => {
const projectParticipationCount = currentSystemParticipation.get(member.name);
return typeof projectParticipationCount === 'number'
? [{ name: member.name, projectParticipationCount }]
: [];
}),
});
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
@@ -455,11 +510,20 @@ export default function VersionDetailPage() {
})()}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="text-[11px] text-[var(--ink-muted)] font-medium"></div>
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
<Settings className="h-3 w-3" />
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowRecommendModal(true)}
disabled={!recommendationDataReady}
className="flex items-center gap-1 text-[11px] font-medium text-violet-600 hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"
>
<Sparkles className="h-3 w-3" />{recommendationDataReady ? 'AI推荐' : '加载中'}
</button>
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
<Settings className="h-3 w-3" />
</button>
</div>
</div>
{(() => {
const membersList = version.members ?? [];
@@ -884,6 +948,20 @@ export default function VersionDetailPage() {
)}
</div>
{showRecommendModal && (
<MemberRecommendationModal
groups={memberRecommendationGroups}
members={version.members ?? []}
onAdd={(selectedNames) => {
const recommendations = memberRecommendationGroups.flatMap((group) => group.items);
const newMembers = addRecommendedVersionMembers(version.members ?? [], selectedNames, recommendations);
updateVersion(version.productId, version.id, { members: newMembers });
setShowRecommendModal(false);
}}
onClose={() => setShowRecommendModal(false)}
/>
)}
{/* 参与人员设置弹窗 */}
{showMemberModal && (
<MemberSettingModal
@@ -922,6 +1000,183 @@ function OverviewMetric({ label, value, icon, sub, tone }: {
);
}
function formatRecommendationHours(hours: number): string {
if (hours <= 0) return '0h';
return `${Math.round(hours * 10) / 10}h`;
}
function formatAvailableDays(days: number): string {
if (days <= 0) return '今天';
return `${days}`;
}
function getRecommendationScoreClass(score: number): string {
if (score >= 80) return 'bg-emerald-50 text-emerald-700 border-emerald-100';
if (score >= 65) return 'bg-blue-50 text-blue-700 border-blue-100';
if (score >= 50) return 'bg-amber-50 text-amber-700 border-amber-100';
return 'bg-red-50 text-red-700 border-red-100';
}
function getRecommendationBarClass(score: number): string {
if (score >= 80) return 'bg-emerald-500';
if (score >= 65) return 'bg-blue-500';
if (score >= 50) return 'bg-amber-500';
return 'bg-red-500';
}
function getInitialRecommendationSelection(groups: MemberRecommendationGroup[]): Set<string> {
return new Set(getDefaultRecommendedMemberNames(groups));
}
function MemberRecommendationModal({ groups, members, onAdd, onClose }: {
groups: MemberRecommendationGroup[];
members: { role: Role; name: string }[];
onAdd: (selectedNames: string[]) => void;
onClose: () => void;
}) {
const allItems = groups.flatMap((group) => group.items);
const [selectedNames, setSelectedNames] = useState<Set<string>>(() => getInitialRecommendationSelection(groups));
const hasHistoricalSignal = allItems.some((item) => item.confidence === 'high');
const toggleName = (name: string) => {
setSelectedNames((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleAdd = () => {
if (selectedNames.size === 0) return;
onAdd(Array.from(selectedNames));
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="flex max-h-[calc(100vh-48px)] w-[min(860px,calc(100vw-32px))] flex-col overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[15px] font-semibold text-[var(--ink)]">AI推荐参与人员</h3>
{!hasHistoricalSignal && allItems.length > 0 && (
<span className="rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700"></span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">
{members.length} {allItems.length} {selectedNames.size}
</p>
</div>
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]"><X className="h-4 w-4" /></button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{allItems.length === 0 ? (
<div className="flex min-h-[260px] flex-col items-center justify-center rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg)] text-center">
<Sparkles className="mb-3 h-5 w-5 text-[var(--ink-muted)]" />
<div className="text-[13px] font-medium text-[var(--ink)]"></div>
<div className="mt-1 text-[11px] text-[var(--ink-muted)]"></div>
</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.role}>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[12px] font-semibold text-[var(--ink)]">{RECOMMENDATION_ROLE_LABEL[group.role]}</span>
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">
{group.requiredCount} / {group.currentCount} / {group.missingCount}
</span>
</div>
<span className="text-[10px] text-[var(--ink-muted)]"> {formatRecommendationHours(group.scopeHours)}</span>
</div>
<div className="space-y-2">
{group.items.map((item) => {
const selected = selectedNames.has(item.name);
const workloadCount = item.metrics.activeTaskCount + item.metrics.activeBugCount;
const stats = [
{ label: '剩余', value: formatRecommendationHours(item.metrics.remainingHours) },
{ label: '可接入', value: formatAvailableDays(item.metrics.availableInDays) },
{ label: '事项', value: `${workloadCount}` },
{ label: '加班', value: formatRecommendationHours(item.metrics.recentOvertimeHours) },
];
return (
<button
key={item.name}
type="button"
onClick={() => toggleName(item.name)}
className={`w-full overflow-hidden rounded-xl border text-left transition-colors ${selected ? 'border-violet-300 bg-violet-50/60' : 'border-[var(--line)] bg-[var(--bg)] hover:border-violet-200 hover:bg-[var(--bg-subtle)]'}`}
>
<div className={`h-1 ${getRecommendationBarClass(item.score)}`} style={{ width: `${item.score}%` }} />
<div className="flex gap-3 px-3 py-3">
<span className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border ${selected ? 'border-violet-600 bg-violet-600 text-white' : 'border-[var(--line)] text-transparent'}`}>
<Check className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[13px] font-semibold text-[var(--ink)]">{item.name}</span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold tabular-nums ${getRecommendationScoreClass(item.score)}`}>{item.score}</span>
<span className="rounded-full bg-[var(--bg-card)] px-2 py-0.5 text-[10px] text-[var(--ink-muted)]">{RECOMMENDATION_CONFIDENCE_LABEL[item.confidence]}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
{stats.map((stat) => (
<span key={stat.label} className="rounded-lg bg-[var(--bg-card)] px-2 py-1">
<span className="block text-[10px] text-[var(--ink-muted)]">{stat.label}</span>
<span className="block text-[11px] font-semibold text-[var(--ink)] tabular-nums">{stat.value}</span>
</span>
))}
</div>
{(item.reasons.length > 0 || item.warnings.length > 0 || typeof item.metrics.bugRate === 'number') && (
<div className="mt-2 flex flex-wrap gap-1.5">
{item.reasons.slice(0, 2).map((reason) => (
<span key={reason} className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">{reason}</span>
))}
{typeof item.metrics.bugRate === 'number' && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] text-blue-700">Bug率 {item.metrics.bugRate}</span>
)}
{item.warnings.slice(0, 2).map((warning) => (
<span key={warning} className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
<AlertTriangle className="h-3 w-3" />
{warning}
</span>
))}
</div>
)}
</div>
</div>
</button>
);
})}
</div>
</section>
))}
</div>
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-4">
<button
type="button"
onClick={() => setSelectedNames(new Set())}
disabled={selectedNames.size === 0}
className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<div className="flex items-center gap-2">
<button onClick={onClose} className="h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={handleAdd} disabled={selectedNames.size === 0} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-violet-600 px-4 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-45">
<UserPlus className="h-3.5 w-3.5" />
{selectedNames.size}
</button>
</div>
</div>
</div>
</div>
);
}
function MemberSettingModal({ members, allMembers, onSave, onClose }: {
members: { role: Role; name: string }[];
allMembers: { id: string; name: string; departmentName?: string }[];