feat(version): 增加参与人员AI推荐
This commit is contained in:
@@ -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 }[];
|
||||
|
||||
Reference in New Issue
Block a user