feat(version): 优化参与人员添加体验
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, 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 { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||||
@@ -29,6 +29,7 @@ import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
|||||||
import { formatDateTime } from '@/lib/format';
|
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';
|
||||||
|
|
||||||
function formatOverviewDateTime(value?: string | null): string {
|
function formatOverviewDateTime(value?: string | null): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -65,8 +66,16 @@ 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 { members: allMembers, roles } = useMemberStore();
|
const { departments, members: allMembers, roles } = useMemberStore();
|
||||||
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(
|
||||||
|
() => allMembers.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
|
||||||
|
})),
|
||||||
|
[allMembers, departments],
|
||||||
|
);
|
||||||
const visibleTabs = useMemo(
|
const visibleTabs = useMemo(
|
||||||
() => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)),
|
() => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)),
|
||||||
[currentRole],
|
[currentRole],
|
||||||
@@ -879,7 +888,7 @@ export default function VersionDetailPage() {
|
|||||||
{showMemberModal && (
|
{showMemberModal && (
|
||||||
<MemberSettingModal
|
<MemberSettingModal
|
||||||
members={version.members ?? []}
|
members={version.members ?? []}
|
||||||
allMembers={allMembers}
|
allMembers={memberCandidates}
|
||||||
onSave={(newMembers) => {
|
onSave={(newMembers) => {
|
||||||
updateVersion(version.productId, version.id, { members: newMembers });
|
updateVersion(version.productId, version.id, { members: newMembers });
|
||||||
setShowMemberModal(false);
|
setShowMemberModal(false);
|
||||||
@@ -915,14 +924,14 @@ function OverviewMetric({ label, value, icon, sub, tone }: {
|
|||||||
|
|
||||||
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 }[];
|
allMembers: { id: string; name: string; departmentName?: string }[];
|
||||||
onSave: (members: { role: Role; name: string }[]) => void;
|
onSave: (members: { role: Role; name: string }[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [list, setList] = useState<{ role: Role; name: string }[]>([...members]);
|
const [list, setList] = useState<{ role: Role; name: string }[]>([...members]);
|
||||||
const [newName, setNewName] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [newRole, setNewRole] = useState<Role>('frontend');
|
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
||||||
const roleOptions = [
|
const roleOptions: { value: Role; label: string }[] = [
|
||||||
{ value: 'product', label: '产品' },
|
{ value: 'product', label: '产品' },
|
||||||
{ value: 'ui', label: 'UI' },
|
{ value: 'ui', label: 'UI' },
|
||||||
{ value: 'frontend', label: '前端' },
|
{ value: 'frontend', label: '前端' },
|
||||||
@@ -930,56 +939,154 @@ function MemberSettingModal({ members, allMembers, onSave, onClose }: {
|
|||||||
{ value: 'testing', label: '测试' },
|
{ value: 'testing', label: '测试' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleAdd = () => {
|
const candidates = useMemo(
|
||||||
if (!newName) return;
|
() => filterVersionMemberCandidates(allMembers, list, query),
|
||||||
if (list.some((m) => m.name === newName && m.role === newRole)) return;
|
[allMembers, list, query],
|
||||||
setList([...list, { role: newRole, name: newName }]);
|
);
|
||||||
setNewName('');
|
|
||||||
|
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) => {
|
const handleSelectAll = () => {
|
||||||
setList(list.filter((_, i) => i !== idx));
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
<div className="flex max-h-[calc(100vh-48px)] w-[min(960px,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-center justify-between mb-4">
|
<div className="flex items-center justify-between border-b border-[var(--line)] px-6 py-4">
|
||||||
<h3 className="text-[14px] font-semibold text-[var(--ink)]">设置参与人员</h3>
|
<div>
|
||||||
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
<h3 className="text-[15px] font-semibold text-[var(--ink)]">设置参与人员</h3>
|
||||||
|
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">{list.length} 人已加入版本</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>
|
||||||
|
|
||||||
{/* 已有成员 */}
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<div className="space-y-1.5 mb-4 max-h-[240px] overflow-y-auto">
|
<section className="flex min-h-[420px] flex-col overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg)]">
|
||||||
{list.length === 0 && <span className="text-[12px] text-[var(--ink-muted)]">暂无成员</span>}
|
<div className="border-b border-[var(--line)] p-4">
|
||||||
{list.map((m, i) => (
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<div key={`${m.name}-${m.role}-${i}`} className="flex items-center justify-between px-3 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
|
<div className="text-[12px] font-semibold text-[var(--ink)]">添加成员</div>
|
||||||
<div className="flex items-center gap-2 text-[12px]">
|
<button
|
||||||
<span className="text-[var(--ink-muted)]">{roleOptions.find((r) => r.value === m.role)?.label || m.role}</span>
|
type="button"
|
||||||
<span className="font-medium text-[var(--ink)]">{m.name}</span>
|
onClick={handleAddSelected}
|
||||||
|
disabled={selectedNames.size === 0}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5" />
|
||||||
|
{selectedNames.size > 0 ? `添加 ${selectedNames.size} 人` : '添加'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => handleRemove(i)} className="text-red-400 hover:text-red-600"><X className="h-3.5 w-3.5" /></button>
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
<span>可添加 {candidates.length} 人</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={handleSelectAll} disabled={candidates.length === 0} className="text-[var(--accent)] hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline">全选当前</button>
|
||||||
|
<button type="button" onClick={() => setSelectedNames(new Set())} disabled={selectedNames.size === 0} className="hover:text-[var(--ink-soft)] disabled:cursor-not-allowed disabled:text-[var(--ink-muted)]">清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-[180px] items-center justify-center rounded-lg border border-dashed border-[var(--line)] text-[12px] text-[var(--ink-muted)]">暂无可添加成员</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{candidates.map((member) => {
|
||||||
|
const selected = selectedNames.has(member.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCandidate(member.name)}
|
||||||
|
className={`flex h-12 items-center justify-between rounded-lg border px-3 text-left transition-colors ${selected ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:bg-[var(--bg-subtle)]'}`}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[12px] font-medium">{member.name}</span>
|
||||||
|
{member.departmentName && <span className="mt-0.5 block truncate text-[10px] text-[var(--ink-muted)]">{member.departmentName}</span>}
|
||||||
|
</span>
|
||||||
|
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${selected ? 'border-[var(--accent)] bg-[var(--accent)] text-white' : 'border-[var(--line)] text-transparent'}`}>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-[420px] flex-col overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg)]">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--line)] p-4">
|
||||||
|
<div className="text-[12px] font-semibold text-[var(--ink)]">已添加</div>
|
||||||
|
<span className="rounded-full bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] tabular-nums text-[var(--ink-muted)]">{list.length} 人</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-[180px] items-center justify-center rounded-lg border border-dashed border-[var(--line)] text-[12px] text-[var(--ink-muted)]">暂无成员</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((member) => (
|
||||||
|
<div key={member.name} className="flex items-center gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--ink)]">{member.name}</span>
|
||||||
|
<select
|
||||||
|
value={member.role}
|
||||||
|
onChange={(e) => handleRoleChange(member.name, e.target.value as Role)}
|
||||||
|
className="h-8 w-24 shrink-0 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[12px] text-[var(--ink-soft)] outline-none focus:border-[var(--accent)]"
|
||||||
|
>
|
||||||
|
{roleOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={() => handleRemove(member.name)} className="rounded-md p-1 text-red-400 hover:bg-red-50 hover:text-red-600">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* 添加新成员 */}
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
</section>
|
||||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value as Role)} className="h-8 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
|
||||||
{roleOptions.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={newName} onChange={(e) => setNewName(e.target.value)} className="h-8 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
|
||||||
<option value="">选择成员</option>
|
|
||||||
{allMembers.filter((m) => !list.some((l) => l.name === m.name && l.role === newRole)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<button onClick={handleAdd} disabled={!newName} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white disabled:opacity-50 flex items-center gap-1">
|
|
||||||
<Plus className="h-3 w-3" />添加
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-3 border-t border-[var(--line)]">
|
<div className="flex justify-end gap-2 border-t border-[var(--line)] px-6 py-4">
|
||||||
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
<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={() => onSave(list)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white">保存</button>
|
<button onClick={() => onSave(list)} className="h-8 rounded-lg bg-[var(--accent)] px-4 text-[12px] font-medium text-white">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
65
apps/web/lib/version-members.test.ts
Normal file
65
apps/web/lib/version-members.test.ts
Normal file
@@ -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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
58
apps/web/lib/version-members.ts
Normal file
58
apps/web/lib/version-members.ts
Normal file
@@ -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<VersionMemberCandidate>,
|
||||||
|
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<string>,
|
||||||
|
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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user