feat(version): 增加参与人员AI推荐
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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 { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||||
@@ -23,6 +23,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
|||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||||
import { hasPermission } from '@/lib/permissions';
|
import { hasPermission } from '@/lib/permissions';
|
||||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
||||||
@@ -30,6 +31,7 @@ import { formatDateTime } from '@/lib/format';
|
|||||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
||||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
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 {
|
function formatOverviewDateTime(value?: string | null): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -43,6 +45,18 @@ const BUG_SEVERITY_SEGMENTS = [
|
|||||||
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
|
{ key: 'trivial', label: '轻微', color: 'bg-zinc-400' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const RECOMMENDATION_ROLE_LABEL: Record<RecommendableRole, string> = {
|
||||||
|
frontend: '前端',
|
||||||
|
backend: '后端',
|
||||||
|
testing: '测试',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECOMMENDATION_CONFIDENCE_LABEL = {
|
||||||
|
high: '高置信',
|
||||||
|
medium: '中置信',
|
||||||
|
low: '低置信',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'overview', label: '概览', permission: null as string | null },
|
{ key: 'overview', label: '概览', permission: null as string | null },
|
||||||
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
|
{ key: 'requirements', label: '关联需求', permission: 'version.req:view' },
|
||||||
@@ -66,7 +80,8 @@ export default function VersionDetailPage() {
|
|||||||
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
|
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs, deleteBug } = useBugStore();
|
const { bugs, fetchBugs, deleteBug } = useBugStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
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 currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]);
|
||||||
const memberCandidates = useMemo(
|
const memberCandidates = useMemo(
|
||||||
() => allMembers.map((member) => ({
|
() => allMembers.map((member) => ({
|
||||||
@@ -87,6 +102,8 @@ export default function VersionDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [visibleTabs, activeTab]);
|
}, [visibleTabs, activeTab]);
|
||||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||||
|
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||||
|
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
@@ -95,6 +112,14 @@ export default function VersionDetailPage() {
|
|||||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
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]);
|
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 healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
|
||||||
const healthLevel = getHealthLevel(healthScore);
|
const healthLevel = getHealthLevel(healthScore);
|
||||||
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
|
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 renderActions = () => {
|
||||||
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
|
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
|
||||||
@@ -455,12 +510,21 @@ export default function VersionDetailPage() {
|
|||||||
})()}
|
})()}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<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="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>
|
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
||||||
|
<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">
|
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
|
||||||
<Settings className="h-3 w-3" />设置
|
<Settings className="h-3 w-3" />设置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const membersList = version.members ?? [];
|
const membersList = version.members ?? [];
|
||||||
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' };
|
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' };
|
||||||
@@ -884,6 +948,20 @@ export default function VersionDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{showMemberModal && (
|
||||||
<MemberSettingModal
|
<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 }: {
|
function MemberSettingModal({ members, allMembers, onSave, onClose }: {
|
||||||
members: { role: Role; name: string }[];
|
members: { role: Role; name: string }[];
|
||||||
allMembers: { id: string; name: string; departmentName?: string }[];
|
allMembers: { id: string; name: string; departmentName?: string }[];
|
||||||
|
|||||||
303
apps/web/lib/member-recommendation.test.ts
Normal file
303
apps/web/lib/member-recommendation.test.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers } from './member-recommendation';
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
import type { Requirement } from './requirement';
|
||||||
|
import type { TestCase } from './test-case';
|
||||||
|
import { PRESET_CATEGORIES } from './task-category';
|
||||||
|
|
||||||
|
function devTask(patch: Partial<DevTask>): DevTask {
|
||||||
|
return {
|
||||||
|
id: patch.id || 'dev-1',
|
||||||
|
taskNo: patch.taskNo || 'DEV-001',
|
||||||
|
requirementId: patch.requirementId || 'req-1',
|
||||||
|
title: patch.title || 'Dev task',
|
||||||
|
categoryId: patch.categoryId || 'cat-1',
|
||||||
|
assigneeId: patch.assigneeId || 'Backend A',
|
||||||
|
priority: patch.priority || 'P2',
|
||||||
|
expectedStartAt: patch.expectedStartAt || '2026-06-24T09:00:00',
|
||||||
|
expectedEndAt: patch.expectedEndAt || '2026-06-24T18:00:00',
|
||||||
|
estimateHours: patch.estimateHours,
|
||||||
|
aiEstimateHours: patch.aiEstimateHours,
|
||||||
|
actualStartAt: patch.actualStartAt,
|
||||||
|
actualEndAt: patch.actualEndAt,
|
||||||
|
status: patch.status || 'in_progress',
|
||||||
|
isBlocked: patch.isBlocked ?? false,
|
||||||
|
createdBy: patch.createdBy || 'PM',
|
||||||
|
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
||||||
|
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirement(patch: Partial<Requirement>): Requirement {
|
||||||
|
return {
|
||||||
|
id: patch.id || 'req-1',
|
||||||
|
code: patch.code || 'REQ-001',
|
||||||
|
title: patch.title || 'Requirement',
|
||||||
|
description: patch.description || 'Requirement description',
|
||||||
|
productId: patch.productId || 'prod-1',
|
||||||
|
projectId: patch.projectId || 'proj-1',
|
||||||
|
versionId: patch.versionId || 'ver-1',
|
||||||
|
sourceType: patch.sourceType || 'internal',
|
||||||
|
sourceTarget: patch.sourceTarget || 'Product',
|
||||||
|
platforms: patch.platforms || ['web'],
|
||||||
|
typeId: patch.typeId || 'type-1',
|
||||||
|
status: patch.status || 'planned',
|
||||||
|
priority: patch.priority || 'P2',
|
||||||
|
effort: patch.effort || 'M',
|
||||||
|
productOwner: patch.productOwner || 'PM',
|
||||||
|
creator: patch.creator || 'PM',
|
||||||
|
createdAt: patch.createdAt || '2026-06-23',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function testCase(patch: Partial<TestCase>): TestCase {
|
||||||
|
return {
|
||||||
|
id: patch.id || 'tc-1',
|
||||||
|
caseNo: patch.caseNo || 'TC-001',
|
||||||
|
versionId: patch.versionId || 'ver-1',
|
||||||
|
requirementId: patch.requirementId,
|
||||||
|
title: patch.title || 'Test case',
|
||||||
|
categoryId: patch.categoryId || 'cat-test-functional',
|
||||||
|
priority: patch.priority || 'P2',
|
||||||
|
assigneeId: patch.assigneeId,
|
||||||
|
status: patch.status || 'pending',
|
||||||
|
estimateHours: patch.estimateHours,
|
||||||
|
aiEstimateHours: patch.aiEstimateHours,
|
||||||
|
startedAt: patch.startedAt,
|
||||||
|
completedAt: patch.completedAt,
|
||||||
|
createdBy: patch.createdBy || 'QA',
|
||||||
|
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
||||||
|
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recommendVersionMembers only recommends frontend backend and testing candidates outside current members', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-1', name: 'Frontend', departmentName: '前端组' },
|
||||||
|
{ id: 'm-2', name: 'Backend', departmentName: '后端组' },
|
||||||
|
{ id: 'm-3', name: 'Tester', departmentName: '测试组' },
|
||||||
|
{ id: 'm-4', name: 'Product', departmentName: '产品部' },
|
||||||
|
{ id: 'm-5', name: 'Designer', departmentName: '设计部' },
|
||||||
|
],
|
||||||
|
currentMembers: [{ name: 'Frontend', role: 'frontend' }],
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
||||||
|
assert.deepEqual(groups.flatMap((group) => group.items.map((item) => item.name)), ['Backend', 'Tester']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendVersionMembers ranks idle people above overloaded people with deadline conflicts', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-1', name: 'Backend Busy', departmentName: '后端组' },
|
||||||
|
{ id: 'm-2', name: 'Backend Idle', departmentName: '后端组' },
|
||||||
|
],
|
||||||
|
currentMembers: [],
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
assigneeId: 'Backend Busy',
|
||||||
|
estimateHours: 16,
|
||||||
|
expectedEndAt: '2026-06-30T18:00:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
versionDeadline: '2026-06-28T18:00:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
||||||
|
assert.equal(backendItems[0].name, 'Backend Idle');
|
||||||
|
assert.equal(backendItems[1].metrics.deadlineConflictCount, 1);
|
||||||
|
assert.ok(backendItems[1].warnings.some((warning) => warning.includes('截止')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendVersionMembers uses historical bug rate without punishing missing history', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-1', name: 'Stable Dev', departmentName: '后端组' },
|
||||||
|
{ id: 'm-2', name: 'Risky Dev', departmentName: '后端组' },
|
||||||
|
{ id: 'm-3', name: 'New Dev', departmentName: '后端组' },
|
||||||
|
],
|
||||||
|
currentMembers: [],
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
historicalStats: [
|
||||||
|
{ name: 'Stable Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 2 },
|
||||||
|
{ name: 'Risky Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 14 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
||||||
|
const stable = backendItems.find((item) => item.name === 'Stable Dev')!;
|
||||||
|
const risky = backendItems.find((item) => item.name === 'Risky Dev')!;
|
||||||
|
const newcomer = backendItems.find((item) => item.name === 'New Dev')!;
|
||||||
|
|
||||||
|
assert.ok(stable.score > risky.score);
|
||||||
|
assert.equal(stable.confidence, 'high');
|
||||||
|
assert.equal(newcomer.confidence, 'medium');
|
||||||
|
assert.equal(newcomer.metrics.bugRate, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendVersionMembers limits candidates to strict AI-assisted role demand from requirements', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||||
|
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
||||||
|
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
||||||
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||||
|
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
||||||
|
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
||||||
|
{ id: 'm-q2', name: 'QA B', departmentName: '测试组' },
|
||||||
|
],
|
||||||
|
currentMembers: [{ name: 'Backend Existing', role: 'backend' }],
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
scopeRequirements: [
|
||||||
|
requirement({ id: 'req-xl', effort: 'XL' }),
|
||||||
|
requirement({ id: 'req-l', effort: 'L' }),
|
||||||
|
requirement({ id: 'req-m', effort: 'M' }),
|
||||||
|
],
|
||||||
|
scopeDevTasks: [],
|
||||||
|
scopeTestCases: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const frontend = groups.find((group) => group.role === 'frontend')!;
|
||||||
|
const backend = groups.find((group) => group.role === 'backend')!;
|
||||||
|
const testing = groups.find((group) => group.role === 'testing')!;
|
||||||
|
|
||||||
|
assert.equal(frontend.requiredCount, 2);
|
||||||
|
assert.equal(frontend.currentCount, 0);
|
||||||
|
assert.equal(frontend.missingCount, 2);
|
||||||
|
assert.equal(frontend.items.length, 2);
|
||||||
|
assert.equal(backend.requiredCount, 2);
|
||||||
|
assert.equal(backend.currentCount, 1);
|
||||||
|
assert.equal(backend.missingCount, 1);
|
||||||
|
assert.equal(backend.items.length, 1);
|
||||||
|
assert.equal(testing.requiredCount, 1);
|
||||||
|
assert.equal(testing.items.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendVersionMembers omits roles without scoped task demand', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||||
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||||
|
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
||||||
|
],
|
||||||
|
currentMembers: [],
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
scopeRequirements: [],
|
||||||
|
scopeDevTasks: [
|
||||||
|
devTask({ id: 'dev-backend', categoryId: 'cat-backend-api', estimateHours: 20, assigneeId: 'Someone' }),
|
||||||
|
],
|
||||||
|
scopeTestCases: [
|
||||||
|
testCase({ id: 'tc-functional', estimateHours: 6 }),
|
||||||
|
],
|
||||||
|
taskCategories: PRESET_CATEGORIES,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
||||||
|
assert.equal(groups.find((group) => group.role === 'backend')?.items.length, 1);
|
||||||
|
assert.equal(groups.find((group) => group.role === 'testing')?.items.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addRecommendedVersionMembers appends selected people with recommended roles once', () => {
|
||||||
|
const next = addRecommendedVersionMembers(
|
||||||
|
[
|
||||||
|
{ name: 'Product Owner', role: 'product' },
|
||||||
|
{ name: 'Backend Existing', role: 'backend' },
|
||||||
|
],
|
||||||
|
['Frontend Candidate', 'Backend Existing', 'QA Candidate'],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'Frontend Candidate',
|
||||||
|
role: 'frontend',
|
||||||
|
score: 88,
|
||||||
|
confidence: 'medium',
|
||||||
|
reasons: [],
|
||||||
|
warnings: [],
|
||||||
|
metrics: {
|
||||||
|
activeTaskCount: 0,
|
||||||
|
activeBugCount: 0,
|
||||||
|
deadlineConflictCount: 0,
|
||||||
|
remainingHours: 0,
|
||||||
|
recentOvertimeHours: 0,
|
||||||
|
availableInDays: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Backend Existing',
|
||||||
|
role: 'backend',
|
||||||
|
score: 80,
|
||||||
|
confidence: 'medium',
|
||||||
|
reasons: [],
|
||||||
|
warnings: [],
|
||||||
|
metrics: {
|
||||||
|
activeTaskCount: 0,
|
||||||
|
activeBugCount: 0,
|
||||||
|
deadlineConflictCount: 0,
|
||||||
|
remainingHours: 0,
|
||||||
|
recentOvertimeHours: 0,
|
||||||
|
availableInDays: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QA Candidate',
|
||||||
|
role: 'testing',
|
||||||
|
score: 84,
|
||||||
|
confidence: 'medium',
|
||||||
|
reasons: [],
|
||||||
|
warnings: [],
|
||||||
|
metrics: {
|
||||||
|
activeTaskCount: 0,
|
||||||
|
activeBugCount: 0,
|
||||||
|
deadlineConflictCount: 0,
|
||||||
|
remainingHours: 0,
|
||||||
|
recentOvertimeHours: 0,
|
||||||
|
availableInDays: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(next, [
|
||||||
|
{ name: 'Product Owner', role: 'product' },
|
||||||
|
{ name: 'Backend Existing', role: 'backend' },
|
||||||
|
{ name: 'Frontend Candidate', role: 'frontend' },
|
||||||
|
{ name: 'QA Candidate', role: 'testing' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDefaultRecommendedMemberNames selects every displayed recommendation within role gaps', () => {
|
||||||
|
const groups = recommendVersionMembers({
|
||||||
|
candidates: [
|
||||||
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
||||||
|
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
||||||
|
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
||||||
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
||||||
|
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
||||||
|
],
|
||||||
|
currentMembers: [],
|
||||||
|
devTasks: [],
|
||||||
|
testCases: [],
|
||||||
|
bugs: [],
|
||||||
|
scopeRequirements: [
|
||||||
|
requirement({ id: 'req-xl', effort: 'XL' }),
|
||||||
|
requirement({ id: 'req-xl-2', effort: 'XL' }),
|
||||||
|
],
|
||||||
|
scopeDevTasks: [],
|
||||||
|
scopeTestCases: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(getDefaultRecommendedMemberNames(groups), ['Frontend A', 'Frontend B', 'Backend A', 'Backend B']);
|
||||||
|
});
|
||||||
400
apps/web/lib/member-recommendation.ts
Normal file
400
apps/web/lib/member-recommendation.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import type { Role } from './stage';
|
||||||
|
import type { VersionMember, VersionMemberCandidate } from './version-members';
|
||||||
|
import type { DevTask } from './dev-task';
|
||||||
|
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
||||||
|
import type { TestCase } from './test-case';
|
||||||
|
import { TEST_CASE_STATUS_PROGRESS, getTestCaseEstimateHours } from './test-case';
|
||||||
|
import type { Bug, BugSeverity } from './bug';
|
||||||
|
import type { OvertimeRecord } from './overtime';
|
||||||
|
import type { Effort, Requirement } from './requirement';
|
||||||
|
import type { TaskCategory } from './task-category';
|
||||||
|
|
||||||
|
export type RecommendableRole = 'frontend' | 'backend' | 'testing';
|
||||||
|
export type RecommendationConfidence = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
export interface HistoricalMemberStats {
|
||||||
|
name: string;
|
||||||
|
projectParticipationCount?: number;
|
||||||
|
deliveredTaskCount?: number;
|
||||||
|
severityWeightedBugCount?: number;
|
||||||
|
delayRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberRecommendationMetrics {
|
||||||
|
activeTaskCount: number;
|
||||||
|
activeBugCount: number;
|
||||||
|
deadlineConflictCount: number;
|
||||||
|
remainingHours: number;
|
||||||
|
recentOvertimeHours: number;
|
||||||
|
projectParticipationCount?: number;
|
||||||
|
bugRate?: number;
|
||||||
|
availableInDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberRecommendationItem {
|
||||||
|
name: string;
|
||||||
|
role: RecommendableRole;
|
||||||
|
score: number;
|
||||||
|
confidence: RecommendationConfidence;
|
||||||
|
reasons: string[];
|
||||||
|
warnings: string[];
|
||||||
|
metrics: MemberRecommendationMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberRecommendationGroup {
|
||||||
|
role: RecommendableRole;
|
||||||
|
requiredCount: number;
|
||||||
|
currentCount: number;
|
||||||
|
missingCount: number;
|
||||||
|
scopeHours: number;
|
||||||
|
items: MemberRecommendationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberRecommendationInput {
|
||||||
|
candidates: VersionMemberCandidate[];
|
||||||
|
currentMembers: VersionMember[];
|
||||||
|
devTasks: DevTask[];
|
||||||
|
testCases: TestCase[];
|
||||||
|
bugs: Bug[];
|
||||||
|
overtimeRecords?: OvertimeRecord[];
|
||||||
|
versionDeadline?: string | null;
|
||||||
|
historicalStats?: HistoricalMemberStats[];
|
||||||
|
scopeRequirements?: Requirement[];
|
||||||
|
scopeDevTasks?: DevTask[];
|
||||||
|
scopeTestCases?: TestCase[];
|
||||||
|
taskCategories?: TaskCategory[];
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_ORDER: RecommendableRole[] = ['frontend', 'backend', 'testing'];
|
||||||
|
const BUG_SEVERITY_WEIGHT: Record<BugSeverity, number> = {
|
||||||
|
critical: 4,
|
||||||
|
major: 3,
|
||||||
|
minor: 1.5,
|
||||||
|
trivial: 0.5,
|
||||||
|
};
|
||||||
|
const AI_ASSISTED_ROLE_CAPACITY_HOURS: Record<RecommendableRole, number> = {
|
||||||
|
frontend: 32,
|
||||||
|
backend: 32,
|
||||||
|
testing: 40,
|
||||||
|
};
|
||||||
|
const REQUIREMENT_AI_SCOPE_HOURS: Record<Effort, Record<RecommendableRole, number>> = {
|
||||||
|
S: { frontend: 4, backend: 4, testing: 3 },
|
||||||
|
M: { frontend: 8, backend: 8, testing: 5 },
|
||||||
|
L: { frontend: 16, backend: 16, testing: 8 },
|
||||||
|
XL: { frontend: 24, backend: 24, testing: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampScore(score: number): number {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(score)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundTenth(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalDevTask(task: DevTask): boolean {
|
||||||
|
return task.status === 'submitted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalTestCase(testCase: TestCase): boolean {
|
||||||
|
return testCase.status === 'passed' || testCase.status === 'failed' || testCase.status === 'blocked';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalBug(bug: Bug): boolean {
|
||||||
|
return bug.status === 'closed' || bug.status === 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDevTaskRemainingHours(task: DevTask): number {
|
||||||
|
const estimate = getEstimateHours(task);
|
||||||
|
if (estimate <= 0) return 2;
|
||||||
|
const progress = STATUS_PROGRESS[task.status] ?? 0;
|
||||||
|
return Math.max(0, estimate * (100 - progress) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTestCaseRemainingHours(testCase: TestCase): number {
|
||||||
|
const estimate = getTestCaseEstimateHours(testCase);
|
||||||
|
if (estimate <= 0) return testCase.status === 'running' ? 0.75 : 1.5;
|
||||||
|
const progress = TEST_CASE_STATUS_PROGRESS[testCase.status] ?? 0;
|
||||||
|
return Math.max(0, estimate * (100 - progress) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBugRemainingHours(bug: Bug): number {
|
||||||
|
if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return bug.estimateHours;
|
||||||
|
if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return bug.aiEstimateHours;
|
||||||
|
return Math.max(1, BUG_SEVERITY_WEIGHT[bug.severity] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAfterDeadline(value: string | undefined, deadline: string | null | undefined): boolean {
|
||||||
|
if (!value || !deadline) return false;
|
||||||
|
const valueMs = new Date(value).getTime();
|
||||||
|
const deadlineMs = new Date(deadline).getTime();
|
||||||
|
return Number.isFinite(valueMs) && Number.isFinite(deadlineMs) && valueMs > deadlineMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRecommendableRole(candidate: VersionMemberCandidate): RecommendableRole | undefined {
|
||||||
|
const departmentName = candidate.departmentName?.toLowerCase() ?? '';
|
||||||
|
if (!departmentName) return undefined;
|
||||||
|
if (departmentName.includes('测试') || departmentName.includes('质量') || departmentName.includes('qa')) return 'testing';
|
||||||
|
if (departmentName.includes('后端') || departmentName.includes('backend') || departmentName.includes('server')) return 'backend';
|
||||||
|
if (departmentName.includes('前端') || departmentName.includes('frontend') || departmentName.includes('web') || departmentName.includes('client')) return 'frontend';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfidence(candidate: VersionMemberCandidate, stats: HistoricalMemberStats | undefined, hasCurrentActivity: boolean): RecommendationConfidence {
|
||||||
|
const hasHistory = Boolean(
|
||||||
|
stats &&
|
||||||
|
(
|
||||||
|
typeof stats.projectParticipationCount === 'number' ||
|
||||||
|
typeof stats.deliveredTaskCount === 'number' ||
|
||||||
|
typeof stats.severityWeightedBugCount === 'number' ||
|
||||||
|
typeof stats.delayRate === 'number'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (hasHistory) return 'high';
|
||||||
|
if (candidate.departmentName || hasCurrentActivity) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoricalBugRate(stats: HistoricalMemberStats | undefined): number | undefined {
|
||||||
|
if (!stats?.deliveredTaskCount || stats.deliveredTaskCount <= 0) return undefined;
|
||||||
|
if (typeof stats.severityWeightedBugCount !== 'number') return undefined;
|
||||||
|
return roundTenth(stats.severityWeightedBugCount / stats.deliveredTaskCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScopedDemandInput(input: MemberRecommendationInput): boolean {
|
||||||
|
return Boolean(input.scopeRequirements || input.scopeDevTasks || input.scopeTestCases);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCurrentMembersByRole(currentMembers: VersionMember[]): Record<RecommendableRole, number> {
|
||||||
|
const counts: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||||
|
for (const member of currentMembers) {
|
||||||
|
if (member.role === 'frontend' || member.role === 'backend' || member.role === 'testing') {
|
||||||
|
counts[member.role] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequirementScopeHours(requirements: Requirement[]): Record<RecommendableRole, number> {
|
||||||
|
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||||
|
for (const requirement of requirements) {
|
||||||
|
const scope = REQUIREMENT_AI_SCOPE_HOURS[requirement.effort] ?? REQUIREMENT_AI_SCOPE_HOURS.M;
|
||||||
|
hours.frontend += scope.frontend;
|
||||||
|
hours.backend += scope.backend;
|
||||||
|
hours.testing += scope.testing;
|
||||||
|
}
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferDevTaskScopeRole(task: DevTask, categories: TaskCategory[]): RecommendableRole | undefined {
|
||||||
|
const category = categories.find((item) => item.id === task.categoryId);
|
||||||
|
const text = `${task.categoryId} ${category?.code ?? ''} ${category?.name ?? ''}`.toLowerCase();
|
||||||
|
if (text.includes('frontend') || text.includes('前端') || text.includes('web') || text.includes('client')) return 'frontend';
|
||||||
|
if (
|
||||||
|
text.includes('backend') ||
|
||||||
|
text.includes('server') ||
|
||||||
|
text.includes('api') ||
|
||||||
|
text.includes('database') ||
|
||||||
|
text.includes('db') ||
|
||||||
|
text.includes('后端') ||
|
||||||
|
text.includes('接口') ||
|
||||||
|
text.includes('数据库')
|
||||||
|
) return 'backend';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopedDevTaskHours(devTasks: DevTask[], categories: TaskCategory[]): Pick<Record<RecommendableRole, number>, 'frontend' | 'backend'> {
|
||||||
|
const hours = { frontend: 0, backend: 0 };
|
||||||
|
for (const task of devTasks) {
|
||||||
|
const estimate = Math.max(getEstimateHours(task), 2);
|
||||||
|
const role = inferDevTaskScopeRole(task, categories);
|
||||||
|
if (role === 'frontend' || role === 'backend') {
|
||||||
|
hours[role] += estimate;
|
||||||
|
} else {
|
||||||
|
hours.frontend += estimate / 2;
|
||||||
|
hours.backend += estimate / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopedTestCaseHours(testCases: TestCase[]): number {
|
||||||
|
return testCases.reduce((sum, testCase) => sum + Math.max(getTestCaseEstimateHours(testCase), 1), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleRecommendationDemand(input: MemberRecommendationInput, scoped: boolean): Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>> {
|
||||||
|
const currentCounts = countCurrentMembersByRole(input.currentMembers);
|
||||||
|
const hours: Record<RecommendableRole, number> = { frontend: 0, backend: 0, testing: 0 };
|
||||||
|
const scopeRequirements = input.scopeRequirements ?? [];
|
||||||
|
const scopeDevTasks = input.scopeDevTasks ?? [];
|
||||||
|
const scopeTestCases = input.scopeTestCases ?? [];
|
||||||
|
const taskCategories = input.taskCategories ?? [];
|
||||||
|
const requirementHours = getRequirementScopeHours(scopeRequirements);
|
||||||
|
|
||||||
|
if (scopeDevTasks.length > 0) {
|
||||||
|
const devHours = getScopedDevTaskHours(scopeDevTasks, taskCategories);
|
||||||
|
hours.frontend = devHours.frontend;
|
||||||
|
hours.backend = devHours.backend;
|
||||||
|
} else if (scopeRequirements.length > 0) {
|
||||||
|
hours.frontend = requirementHours.frontend;
|
||||||
|
hours.backend = requirementHours.backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeTestCases.length > 0) {
|
||||||
|
hours.testing = getScopedTestCaseHours(scopeTestCases);
|
||||||
|
} else if (scopeRequirements.length > 0) {
|
||||||
|
hours.testing = requirementHours.testing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ROLE_ORDER.reduce((acc, role) => {
|
||||||
|
const requiredCount = scoped && hours[role] > 0
|
||||||
|
? Math.max(1, Math.ceil(hours[role] / AI_ASSISTED_ROLE_CAPACITY_HOURS[role]))
|
||||||
|
: 0;
|
||||||
|
const currentCount = currentCounts[role];
|
||||||
|
acc[role] = {
|
||||||
|
role,
|
||||||
|
requiredCount,
|
||||||
|
currentCount,
|
||||||
|
missingCount: scoped ? Math.max(0, requiredCount - currentCount) : 0,
|
||||||
|
scopeHours: roundTenth(hours[role]),
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<RecommendableRole, Omit<MemberRecommendationGroup, 'items'>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommendVersionMembers(input: MemberRecommendationInput): MemberRecommendationGroup[] {
|
||||||
|
const currentMemberNames = new Set(input.currentMembers.map((member) => member.name));
|
||||||
|
const statsByName = new Map((input.historicalStats ?? []).map((stats) => [stats.name, stats]));
|
||||||
|
const deadline = input.versionDeadline;
|
||||||
|
const scoped = hasScopedDemandInput(input);
|
||||||
|
const demandByRole = getRoleRecommendationDemand(input, scoped);
|
||||||
|
|
||||||
|
const items = input.candidates
|
||||||
|
.filter((candidate) => !currentMemberNames.has(candidate.name))
|
||||||
|
.map((candidate): MemberRecommendationItem | null => {
|
||||||
|
const role = inferRecommendableRole(candidate);
|
||||||
|
if (!role) return null;
|
||||||
|
|
||||||
|
const activeDevTasks = input.devTasks.filter((task) => task.assigneeId === candidate.name && !isTerminalDevTask(task));
|
||||||
|
const activeTestCases = input.testCases.filter((testCase) => testCase.assigneeId === candidate.name && !isTerminalTestCase(testCase));
|
||||||
|
const activeBugs = input.bugs.filter((bug) => bug.assigneeId === candidate.name && !isTerminalBug(bug));
|
||||||
|
const activeTaskCount = activeDevTasks.length + activeTestCases.length;
|
||||||
|
const activeBugCount = activeBugs.length;
|
||||||
|
const deadlineConflictCount = activeDevTasks.filter((task) => isAfterDeadline(task.expectedEndAt, deadline)).length;
|
||||||
|
const remainingHours = roundTenth(
|
||||||
|
activeDevTasks.reduce((sum, task) => sum + getDevTaskRemainingHours(task), 0) +
|
||||||
|
activeTestCases.reduce((sum, testCase) => sum + getTestCaseRemainingHours(testCase), 0) +
|
||||||
|
activeBugs.reduce((sum, bug) => sum + getBugRemainingHours(bug), 0),
|
||||||
|
);
|
||||||
|
const activeBugWeight = activeBugs.reduce((sum, bug) => sum + (BUG_SEVERITY_WEIGHT[bug.severity] ?? 1), 0);
|
||||||
|
const recentOvertimeHours = roundTenth(
|
||||||
|
(input.overtimeRecords ?? [])
|
||||||
|
.filter((record) => record.person === candidate.name)
|
||||||
|
.reduce((sum, record) => sum + record.duration, 0),
|
||||||
|
);
|
||||||
|
const stats = statsByName.get(candidate.name);
|
||||||
|
const projectParticipationCount = stats?.projectParticipationCount;
|
||||||
|
const bugRate = getHistoricalBugRate(stats);
|
||||||
|
const confidence = getConfidence(candidate, stats, activeTaskCount > 0 || activeBugCount > 0 || recentOvertimeHours > 0);
|
||||||
|
|
||||||
|
const reasons: string[] = [`部门匹配${role === 'frontend' ? '前端' : role === 'backend' ? '后端' : '测试'}角色`];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let score = 65;
|
||||||
|
|
||||||
|
if (activeTaskCount === 0 && activeBugCount === 0) {
|
||||||
|
score += 18;
|
||||||
|
reasons.push('当前无进行中任务,空闲度高');
|
||||||
|
} else {
|
||||||
|
score -= Math.min(35, activeTaskCount * 6 + activeBugCount * 5 + remainingHours * 1.2);
|
||||||
|
reasons.push(`当前 ${activeTaskCount + activeBugCount} 个进行中事项,剩余约 ${remainingHours}h`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deadlineConflictCount > 0) {
|
||||||
|
score -= 20;
|
||||||
|
warnings.push(`${deadlineConflictCount} 个任务预计截止晚于当前版本`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeBugCount > 0) {
|
||||||
|
score -= Math.min(15, activeBugWeight * 3);
|
||||||
|
warnings.push(`当前有 ${activeBugCount} 个未关闭 Bug`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof projectParticipationCount === 'number' && projectParticipationCount > 0) {
|
||||||
|
score += Math.min(15, projectParticipationCount * 3);
|
||||||
|
reasons.push(`历史参与项目 ${projectParticipationCount} 次`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof bugRate === 'number') {
|
||||||
|
score -= Math.min(22, bugRate * 30);
|
||||||
|
if (bugRate <= 0.2) reasons.push('历史 Bug 率较低');
|
||||||
|
if (bugRate >= 0.5) warnings.push('历史 Bug 率偏高');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof stats?.delayRate === 'number' && stats.delayRate > 0.2) {
|
||||||
|
score -= Math.min(12, stats.delayRate * 30);
|
||||||
|
warnings.push('历史延期率偏高');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentOvertimeHours >= 8) {
|
||||||
|
score -= 8;
|
||||||
|
warnings.push('近期加班较多');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics: MemberRecommendationMetrics = {
|
||||||
|
activeTaskCount,
|
||||||
|
activeBugCount,
|
||||||
|
deadlineConflictCount,
|
||||||
|
remainingHours,
|
||||||
|
recentOvertimeHours,
|
||||||
|
projectParticipationCount,
|
||||||
|
bugRate,
|
||||||
|
availableInDays: remainingHours > 0 ? Math.ceil(remainingHours / 8) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: candidate.name,
|
||||||
|
role,
|
||||||
|
score: clampScore(score),
|
||||||
|
confidence,
|
||||||
|
reasons,
|
||||||
|
warnings,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is MemberRecommendationItem => Boolean(item));
|
||||||
|
|
||||||
|
return ROLE_ORDER
|
||||||
|
.map((role) => {
|
||||||
|
const roleItems = items
|
||||||
|
.filter((item) => item.role === role)
|
||||||
|
.sort((a, b) => b.score - a.score || a.metrics.remainingHours - b.metrics.remainingHours || a.name.localeCompare(b.name));
|
||||||
|
const demand = demandByRole[role];
|
||||||
|
const missingCount = scoped ? demand.missingCount : roleItems.length;
|
||||||
|
return {
|
||||||
|
...demand,
|
||||||
|
requiredCount: scoped ? demand.requiredCount : demand.currentCount + roleItems.length,
|
||||||
|
missingCount,
|
||||||
|
items: roleItems.slice(0, missingCount),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRecommendedVersionMembers(
|
||||||
|
currentMembers: VersionMember[],
|
||||||
|
selectedNames: Iterable<string>,
|
||||||
|
recommendations: MemberRecommendationItem[],
|
||||||
|
): VersionMember[] {
|
||||||
|
const existingNames = new Set(currentMembers.map((member) => member.name));
|
||||||
|
const selectedNameSet = new Set(selectedNames);
|
||||||
|
const additions = recommendations
|
||||||
|
.filter((item) => selectedNameSet.has(item.name) && !existingNames.has(item.name))
|
||||||
|
.map((item) => ({ name: item.name, role: item.role }));
|
||||||
|
|
||||||
|
return [...currentMembers, ...additions];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultRecommendedMemberNames(groups: MemberRecommendationGroup[]): string[] {
|
||||||
|
return groups.flatMap((group) => group.items.map((item) => item.name));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user