diff --git a/apps/server/src/modules/data/data-keys.ts b/apps/server/src/modules/data/data-keys.ts index b9d8f8a..afdd4e4 100644 --- a/apps/server/src/modules/data/data-keys.ts +++ b/apps/server/src/modules/data/data-keys.ts @@ -8,6 +8,7 @@ export const APP_DATA_KEYS = [ 'members', 'task-categories', 'task-worklogs', + 'work-activities', 'overtime', ] as const; diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index ca1d56a..ec7eb1f 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -58,6 +58,10 @@ describe('DataService', () => { key: 'task-worklogs', value, }); + await expect(service.put('work-activities', value)).resolves.toEqual({ + key: 'work-activities', + value, + }); await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toEqual({ key: 'overtime', value, diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index f1a294d..4eb57fb 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -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 = { + 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(); + 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() { })()}
-
+
参与人员
- +
+ + +
{(() => { const membersList = version.members ?? []; @@ -884,6 +948,20 @@ export default function VersionDetailPage() { )}
+ {showRecommendModal && ( + { + 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 && ( = 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 { + 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>(() => 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 ( +
+
e.stopPropagation()}> +
+
+
+

AI推荐参与人员

+ {!hasHistoricalSignal && allItems.length > 0 && ( + 历史数据待导入 + )} +
+

+ 当前 {members.length} 人已加入,推荐 {allItems.length} 人,已选 {selectedNames.size} 人 +

+
+ +
+ +
+ {allItems.length === 0 ? ( +
+ +
暂无可推荐人员
+
当前推荐缺口已满足,或暂无符合前端、后端、测试部门且未加入版本的候选人。
+
+ ) : ( +
+ {groups.map((group) => ( +
+
+
+ {RECOMMENDATION_ROLE_LABEL[group.role]} + + 建议 {group.requiredCount} / 已有 {group.currentCount} / 缺 {group.missingCount} + +
+ 范围 {formatRecommendationHours(group.scopeHours)} +
+
+ {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 ( + + ); + })} +
+
+ ))} +
+ )} +
+ +
+ +
+ + +
+
+
+
+ ); +} + function MemberSettingModal({ members, allMembers, onSave, onClose }: { members: { role: Role; name: string }[]; allMembers: { id: string; name: string; departmentName?: string }[]; diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index d1c36b8..e900573 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -11,6 +11,7 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { flattenVersions } from '@/lib/derive'; import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; import type { WorkItem, WorkItemType } from '@/lib/workspace-engine'; @@ -55,6 +56,7 @@ export default function WorkspacePage() { const { testCases, fetchTestCases } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const { worklogs, fetchWorklogs } = useTaskWorklogStore(); + const { activities, fetchActivities } = useWorkActivityStore(); const user = useAuthStore((s) => s.user); const [activeTab, setActiveTab] = useState('all'); const [showCompleted, setShowCompleted] = useState(true); @@ -69,6 +71,7 @@ export default function WorkspacePage() { useEffect(() => { fetchTestCases(); }, [fetchTestCases]); useEffect(() => { fetchBugs(); }, [fetchBugs]); useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); + useEffect(() => { fetchActivities(); }, [fetchActivities]); const userName = user?.name ?? ''; const allVersions = useMemo(() => flattenVersions(overview), [overview]); @@ -122,12 +125,13 @@ export default function WorkspacePage() { const dailyReport = useMemo(() => getWorkspaceDailyReport({ + activities, worklogs, workItems, userId: userName, date: today, }), - [worklogs, workItems, userName, today] + [activities, worklogs, workItems, userName, today] ); // 待办数量按 tab 分(受 version filter 影响) diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 90e4f5e..ab1eb1c 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2, import { StatusBadge } from './StatusBadge'; import { CategoryChip } from './CategoryChip'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; +import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; @@ -29,6 +30,7 @@ interface Props { export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) { const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); + const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); const { categories } = useTaskCategoryStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); @@ -36,6 +38,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const [transferTo, setTransferTo] = useState(''); const [showDelayInput, setShowDelayInput] = useState(false); const [delayReason, setDelayReason] = useState(''); + const [progressNote, setProgressNote] = useState(''); + const [progressBlocker, setProgressBlocker] = useState(''); + const [progressHelperId, setProgressHelperId] = useState(''); + const [progressDelayRisk, setProgressDelayRisk] = useState(''); const task = tasks.find((t) => t.id === taskId); if (!task) return null; @@ -86,6 +92,28 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel setBlockReason(''); }; + const handleProgressNote = () => { + const note = progressNote.trim(); + const blocker = progressBlocker.trim(); + const delayRisk = progressDelayRisk.trim(); + if (!note && !blocker && !delayRisk) return; + + addProgressNote({ + actorId: task.assigneeId, + sourceType: 'dev_task', + sourceId: task.id, + title: task.title, + note: note || '今日进展已更新', + blocker: blocker || undefined, + helperId: progressHelperId || undefined, + delayRisk: delayRisk || undefined, + }); + setProgressNote(''); + setProgressBlocker(''); + setProgressHelperId(''); + setProgressDelayRisk(''); + }; + return (
e.stopPropagation()}> @@ -199,6 +227,50 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
+ {task.status !== 'todo' && task.status !== 'submitted' && ( +
+
今日进展
+