diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index d188c15..f1a294d 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, ChevronLeft, Clock, FileText, Link2, Plus, Settings, X } from 'lucide-react'; +import { Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, UserPlus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; @@ -29,6 +29,7 @@ import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours'; 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'; function formatOverviewDateTime(value?: string | null): string { if (!value) return '-'; @@ -65,8 +66,16 @@ export default function VersionDetailPage() { const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs, deleteBug } = useBugStore(); const user = useAuthStore((s) => s.user); - const { members: allMembers, roles } = useMemberStore(); + const { departments, members: allMembers, roles } = useMemberStore(); const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); + const memberCandidates = useMemo( + () => allMembers.map((member) => ({ + id: member.id, + name: member.name, + departmentName: departments.find((department) => department.id === member.departmentId)?.name, + })), + [allMembers, departments], + ); const visibleTabs = useMemo( () => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)), [currentRole], @@ -879,7 +888,7 @@ export default function VersionDetailPage() { {showMemberModal && ( { updateVersion(version.productId, version.id, { members: newMembers }); setShowMemberModal(false); @@ -915,14 +924,14 @@ function OverviewMetric({ label, value, icon, sub, tone }: { function MemberSettingModal({ members, allMembers, onSave, onClose }: { members: { role: Role; name: string }[]; - allMembers: { id: string; name: string }[]; + allMembers: { id: string; name: string; departmentName?: string }[]; onSave: (members: { role: Role; name: string }[]) => void; onClose: () => void; }) { const [list, setList] = useState<{ role: Role; name: string }[]>([...members]); - const [newName, setNewName] = useState(''); - const [newRole, setNewRole] = useState('frontend'); - const roleOptions = [ + const [query, setQuery] = useState(''); + const [selectedNames, setSelectedNames] = useState>(new Set()); + const roleOptions: { value: Role; label: string }[] = [ { value: 'product', label: '产品' }, { value: 'ui', label: 'UI' }, { value: 'frontend', label: '前端' }, @@ -930,56 +939,154 @@ function MemberSettingModal({ members, allMembers, onSave, onClose }: { { value: 'testing', label: '测试' }, ]; - const handleAdd = () => { - if (!newName) return; - if (list.some((m) => m.name === newName && m.role === newRole)) return; - setList([...list, { role: newRole, name: newName }]); - setNewName(''); + const candidates = useMemo( + () => filterVersionMemberCandidates(allMembers, list, query), + [allMembers, list, query], + ); + + const toggleCandidate = (name: string) => { + setSelectedNames((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); }; - const handleRemove = (idx: number) => { - setList(list.filter((_, i) => i !== idx)); + const handleSelectAll = () => { + setSelectedNames((prev) => { + const next = new Set(prev); + candidates.forEach((member) => next.add(member.name)); + return next; + }); + }; + + const handleAddSelected = () => { + if (selectedNames.size === 0) return; + setList(addVersionMembers(list, selectedNames, allMembers, DEFAULT_VERSION_MEMBER_ROLE)); + setSelectedNames(new Set()); + setQuery(''); + }; + + const handleRoleChange = (name: string, role: Role) => { + setList(list.map((member) => (member.name === name ? { ...member, role } : member))); + }; + + const handleRemove = (name: string) => { + setList(list.filter((member) => member.name !== name)); + setSelectedNames((prev) => { + const next = new Set(prev); + next.delete(name); + return next; + }); }; return (
-
e.stopPropagation()}> -
-

设置参与人员

- +
e.stopPropagation()}> +
+
+

设置参与人员

+

{list.length} 人已加入版本

+
+
- {/* 已有成员 */} -
- {list.length === 0 && 暂无成员} - {list.map((m, i) => ( -
-
- {roleOptions.find((r) => r.value === m.role)?.label || m.role} - {m.name} +
+
+
+
+
添加成员
+ +
+
+ + setQuery(e.target.value)} + placeholder="搜索成员" + className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] pl-9 pr-3 text-[13px] text-[var(--ink)] outline-none transition-colors placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)]" + /> +
+
+ 可添加 {candidates.length} 人 +
+ + +
-
- ))} +
+ {candidates.length === 0 ? ( +
暂无可添加成员
+ ) : ( +
+ {candidates.map((member) => { + const selected = selectedNames.has(member.name); + return ( + + ); + })} +
+ )} +
+
+ +
+
+
已添加
+ {list.length} 人 +
+
+ {list.length === 0 ? ( +
暂无成员
+ ) : ( +
+ {list.map((member) => ( +
+ {member.name} + + +
+ ))} +
+ )} +
+
- {/* 添加新成员 */} -
- - - -
- -
- - +
+ +
diff --git a/apps/web/lib/version-members.test.ts b/apps/web/lib/version-members.test.ts new file mode 100644 index 0000000..23077e1 --- /dev/null +++ b/apps/web/lib/version-members.test.ts @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { addVersionMembers, filterVersionMemberCandidates, inferVersionMemberRole } from './version-members'; + +test('filterVersionMemberCandidates searches people and excludes already added members', () => { + const result = filterVersionMemberCandidates( + [ + { id: 'm-1', name: 'Alice' }, + { id: 'm-2', name: 'Bob' }, + { id: 'm-3', name: 'Alicia' }, + ], + [{ name: 'Alice', role: 'frontend' }], + 'ali', + ); + + assert.deepEqual(result.map((member) => member.name), ['Alicia']); +}); + +test('addVersionMembers appends selected people once with the default version role', () => { + const result = addVersionMembers( + [{ name: 'Alice', role: 'product' }], + ['Bob', 'Alicia', 'Alice'], + [ + { id: 'm-1', name: 'Alice' }, + { id: 'm-2', name: 'Bob' }, + { id: 'm-3', name: 'Alicia' }, + ], + ); + + assert.deepEqual(result, [ + { name: 'Alice', role: 'product' }, + { name: 'Bob', role: 'frontend' }, + { name: 'Alicia', role: 'frontend' }, + ]); +}); + +test('inferVersionMemberRole maps department names to project roles', () => { + assert.equal(inferVersionMemberRole({ id: 'm-1', name: 'Alice', departmentName: '产品部' }), 'product'); + assert.equal(inferVersionMemberRole({ id: 'm-2', name: 'Bob', departmentName: '设计部' }), 'ui'); + assert.equal(inferVersionMemberRole({ id: 'm-3', name: 'Cathy', departmentName: '前端组' }), 'frontend'); + assert.equal(inferVersionMemberRole({ id: 'm-4', name: 'David', departmentName: '后端组' }), 'backend'); + assert.equal(inferVersionMemberRole({ id: 'm-5', name: 'Eve', departmentName: '测试组' }), 'testing'); + assert.equal(inferVersionMemberRole({ id: 'm-6', name: 'Frank', departmentName: '运营部' }), 'frontend'); +}); + +test('addVersionMembers infers each selected person role from department', () => { + const result = addVersionMembers( + [], + ['Product', 'Designer', 'Backend', 'QA'], + [ + { id: 'm-1', name: 'Product', departmentName: '产品部' }, + { id: 'm-2', name: 'Designer', departmentName: 'UI设计部' }, + { id: 'm-3', name: 'Backend', departmentName: '后端组' }, + { id: 'm-4', name: 'QA', departmentName: '质量测试部' }, + ], + ); + + assert.deepEqual(result, [ + { name: 'Product', role: 'product' }, + { name: 'Designer', role: 'ui' }, + { name: 'Backend', role: 'backend' }, + { name: 'QA', role: 'testing' }, + ]); +}); diff --git a/apps/web/lib/version-members.ts b/apps/web/lib/version-members.ts new file mode 100644 index 0000000..de174c4 --- /dev/null +++ b/apps/web/lib/version-members.ts @@ -0,0 +1,58 @@ +import type { Role } from './stage'; + +export interface VersionMember { + name: string; + role: Role; +} + +export interface VersionMemberCandidate { + id: string; + name: string; + departmentName?: string; +} + +export const DEFAULT_VERSION_MEMBER_ROLE: Role = 'frontend'; + +export function inferVersionMemberRole( + member: Partial, + defaultRole: Role = DEFAULT_VERSION_MEMBER_ROLE, +): Role { + const departmentName = member.departmentName?.toLowerCase() ?? ''; + if (!departmentName) return defaultRole; + if (departmentName.includes('产品') || departmentName.includes('product')) return 'product'; + if (departmentName.includes('设计') || departmentName.includes('ui') || departmentName.includes('ux')) return 'ui'; + 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 defaultRole; +} + +export function filterVersionMemberCandidates( + allMembers: VersionMemberCandidate[], + currentMembers: VersionMember[], + query: string, +): VersionMemberCandidate[] { + const existingNames = new Set(currentMembers.map((member) => member.name)); + const normalizedQuery = query.trim().toLowerCase(); + + return allMembers.filter((member) => { + if (existingNames.has(member.name)) return false; + if (!normalizedQuery) return true; + return member.name.toLowerCase().includes(normalizedQuery); + }); +} + +export function addVersionMembers( + currentMembers: VersionMember[], + selectedNames: Iterable, + allMembers: VersionMemberCandidate[], + defaultRole: Role = DEFAULT_VERSION_MEMBER_ROLE, +): VersionMember[] { + const existingNames = new Set(currentMembers.map((member) => member.name)); + const selectedNameSet = new Set(selectedNames); + const additions = allMembers + .filter((member) => selectedNameSet.has(member.name) && !existingNames.has(member.name)) + .map((member) => ({ name: member.name, role: inferVersionMemberRole(member, defaultRole) })); + + return [...currentMembers, ...additions]; +}