fix(需求池): 修复列表换行和版本解绑状态
This commit is contained in:
@@ -13,6 +13,13 @@ import type { Requirement, RequirementStatus, SourceType } from '@/lib/requireme
|
|||||||
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||||
import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||||
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
||||||
|
import {
|
||||||
|
REQUIREMENT_TABLE_BADGE_CLASS,
|
||||||
|
REQUIREMENT_TABLE_NOWRAP_CELL_CLASS,
|
||||||
|
REQUIREMENT_TABLE_SMALL_BADGE_CLASS,
|
||||||
|
getRequirementTableTextClass,
|
||||||
|
} from '@/lib/requirement-table-layout';
|
||||||
|
import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||||
@@ -488,29 +495,50 @@ function RequirementsPageContent() {
|
|||||||
|
|
||||||
{/* 需求来源 */}
|
{/* 需求来源 */}
|
||||||
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
|
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
|
||||||
{SOURCE_TYPE_LABEL[req.sourceType]}: {req.sourceTarget}
|
{(() => {
|
||||||
|
const sourceText = `${SOURCE_TYPE_LABEL[req.sourceType]}: ${req.sourceTarget}`;
|
||||||
|
return (
|
||||||
|
<span className={getRequirementTableTextClass('source')} title={sourceText}>
|
||||||
|
{sourceText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 所属项目 */}
|
{/* 所属项目 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||||
{allProjects.find((p) => p.id === req.projectId)?.name ?? '-'}
|
{(() => {
|
||||||
|
const projectName = allProjects.find((p) => p.id === req.projectId)?.name ?? '-';
|
||||||
|
return (
|
||||||
|
<span className={getRequirementTableTextClass('project')} title={projectName}>
|
||||||
|
{projectName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 需求类型 */}
|
{/* 需求类型 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||||
{types.find((t) => t.id === req.typeId)?.name ?? '-'}
|
{(() => {
|
||||||
|
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
|
||||||
|
return (
|
||||||
|
<span className={getRequirementTableTextClass('type')} title={typeName}>
|
||||||
|
{typeName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 状态 */}
|
{/* 状态 */}
|
||||||
<td className="px-4 py-3">
|
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
|
<span className={`${REQUIREMENT_TABLE_BADGE_CLASS} ${REQ_STATUS_COLOR[req.status]}`}>
|
||||||
{REQ_STATUS_LABEL[req.status]}
|
{REQ_STATUS_LABEL[req.status]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 优先级 */}
|
{/* 优先级 */}
|
||||||
<td className="px-4 py-3">
|
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
|
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
|
||||||
{req.priority}
|
{req.priority}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -521,11 +549,11 @@ function RequirementsPageContent() {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 开发状态 */}
|
{/* 开发状态 */}
|
||||||
<td className="px-4 py-3">
|
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const devStatus = deriveReqDevStatus(req.id, devTasks);
|
const devStatus = deriveReqDevStatus(req.id, devTasks);
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
|
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
|
||||||
{REQ_DEV_STATUS_LABEL[devStatus]}
|
{REQ_DEV_STATUS_LABEL[devStatus]}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -534,17 +562,19 @@ function RequirementsPageContent() {
|
|||||||
|
|
||||||
{/* 录入人员 */}
|
{/* 录入人员 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||||
{req.creator}
|
<span className={getRequirementTableTextClass('creator')} title={req.creator}>
|
||||||
|
{req.creator}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 录入日期 */}
|
{/* 录入日期 */}
|
||||||
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
|
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums whitespace-nowrap">
|
||||||
{new Date(req.createdAt).toISOString().slice(0, 10)}
|
{new Date(req.createdAt).toISOString().slice(0, 10)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 操作 */}
|
{/* 操作 */}
|
||||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1 whitespace-nowrap">
|
||||||
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
|
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
|
||||||
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasks) && (
|
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasks) && (
|
||||||
<button
|
<button
|
||||||
@@ -638,7 +668,12 @@ function RequirementsPageContent() {
|
|||||||
onClose={() => { setShowModal(false); setEditingReq(null); }}
|
onClose={() => { setShowModal(false); setEditingReq(null); }}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
if (editingReq) {
|
if (editingReq) {
|
||||||
updateRequirement(editingReq.id, data);
|
const versionPatch = getRequirementVersionSelectionPatch(
|
||||||
|
editingReq,
|
||||||
|
data.versionId,
|
||||||
|
data.versionId !== editingReq.versionId ? currentUserName : editingReq.addedToVersionBy,
|
||||||
|
);
|
||||||
|
updateRequirement(editingReq.id, { ...data, ...versionPatch });
|
||||||
} else {
|
} else {
|
||||||
createRequirement(data);
|
createRequirement(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { hasPermission } from '@/lib/permissions';
|
|||||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
||||||
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
||||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
|
import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link';
|
||||||
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';
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||||
@@ -230,7 +231,7 @@ export default function VersionDetailPage() {
|
|||||||
buttons.push({ label: '删除', action: () => {
|
buttons.push({ label: '删除', action: () => {
|
||||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||||
// 释放关联需求
|
// 释放关联需求
|
||||||
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
|
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
|
||||||
// 清理计划任务
|
// 清理计划任务
|
||||||
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
|
plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id));
|
||||||
// 清理开发任务
|
// 清理开发任务
|
||||||
@@ -861,11 +862,17 @@ export default function VersionDetailPage() {
|
|||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly}
|
||||||
onLink={(ids, addedBy) => {
|
onLink={(ids, addedBy) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly) return;
|
||||||
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
|
ids.forEach((id) => {
|
||||||
|
const requirement = requirements.find((item) => item.id === id);
|
||||||
|
if (!requirement) return;
|
||||||
|
updateRequirement(id, getRequirementVersionSelectionPatch(requirement, version.id, addedBy));
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onUnlink={(id) => {
|
onUnlink={(id) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly) return;
|
||||||
updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined });
|
const requirement = requirements.find((item) => item.id === id);
|
||||||
|
if (!requirement) return;
|
||||||
|
updateRequirement(id, getRequirementVersionUnlinkPatch(requirement));
|
||||||
}}
|
}}
|
||||||
onCreateChange={(data) => {
|
onCreateChange={(data) => {
|
||||||
if (versionReadonly) return;
|
if (versionReadonly) return;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
|||||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
||||||
import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list';
|
import { buildVersionScopeTree, filterVersionsForList, type VersionListScope, type VersionProductNode } from '@/lib/version-list';
|
||||||
|
import { getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link';
|
||||||
import {
|
import {
|
||||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||||
canSubmitNewVersionForm,
|
canSubmitNewVersionForm,
|
||||||
@@ -158,7 +159,7 @@ function VersionsPageContent() {
|
|||||||
setOpenMenuId(null);
|
setOpenMenuId(null);
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
if (!confirm('确认删除该版本?关联的需求会回到需求池。')) return;
|
if (!confirm('确认删除该版本?关联的需求会回到需求池。')) return;
|
||||||
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, { versionId: undefined, addedToVersionBy: undefined }));
|
requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
|
||||||
deleteVersion(version.productId, version.id);
|
deleteVersion(version.productId, version.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/web/lib/requirement-table-layout.test.ts
Normal file
22
apps/web/lib/requirement-table-layout.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
REQUIREMENT_TABLE_BADGE_CLASS,
|
||||||
|
getRequirementTableTextClass,
|
||||||
|
} from './requirement-table-layout';
|
||||||
|
|
||||||
|
test('keeps requirement table badges on one line', () => {
|
||||||
|
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/);
|
||||||
|
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clips long requirement source project type and creator cells', () => {
|
||||||
|
for (const column of ['source', 'project', 'type', 'creator'] as const) {
|
||||||
|
const className = getRequirementTableTextClass(column);
|
||||||
|
|
||||||
|
assert.match(className, /\btruncate\b/);
|
||||||
|
assert.match(className, /\bblock\b/);
|
||||||
|
assert.match(className, /\bmax-w-\[/);
|
||||||
|
}
|
||||||
|
});
|
||||||
20
apps/web/lib/requirement-table-layout.ts
Normal file
20
apps/web/lib/requirement-table-layout.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'creator';
|
||||||
|
|
||||||
|
const TEXT_COLUMN_MAX_WIDTH: Record<RequirementTableTextColumn, string> = {
|
||||||
|
source: 'max-w-[170px]',
|
||||||
|
project: 'max-w-[120px]',
|
||||||
|
type: 'max-w-[96px]',
|
||||||
|
creator: 'max-w-[96px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-4 py-3 whitespace-nowrap';
|
||||||
|
|
||||||
|
export const REQUIREMENT_TABLE_BADGE_CLASS =
|
||||||
|
'inline-flex h-6 min-w-[3.25rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-medium';
|
||||||
|
|
||||||
|
export const REQUIREMENT_TABLE_SMALL_BADGE_CLASS =
|
||||||
|
'inline-flex h-5 min-w-[2.75rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md px-2 py-0.5 text-[10px] font-medium';
|
||||||
|
|
||||||
|
export function getRequirementTableTextClass(column: RequirementTableTextColumn): string {
|
||||||
|
return `block truncate ${TEXT_COLUMN_MAX_WIDTH[column]}`;
|
||||||
|
}
|
||||||
63
apps/web/lib/requirement-version-link.test.ts
Normal file
63
apps/web/lib/requirement-version-link.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import type { Requirement } from './requirement';
|
||||||
|
import {
|
||||||
|
getRequirementVersionSelectionPatch,
|
||||||
|
getRequirementVersionUnlinkPatch,
|
||||||
|
} from './requirement-version-link';
|
||||||
|
|
||||||
|
function requirement(patch: Partial<Requirement> = {}): Requirement {
|
||||||
|
return {
|
||||||
|
id: 'req-1',
|
||||||
|
code: 'REQ-001',
|
||||||
|
title: 'Requirement',
|
||||||
|
description: '',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'version-1',
|
||||||
|
sourceType: 'internal',
|
||||||
|
sourceTarget: '',
|
||||||
|
platforms: [],
|
||||||
|
typeId: 'type-1',
|
||||||
|
status: 'planned',
|
||||||
|
priority: 'P2',
|
||||||
|
effort: 'M',
|
||||||
|
creator: 'tester',
|
||||||
|
createdAt: '2026-07-02T00:00:00.000Z',
|
||||||
|
addedToVersionBy: 'pm',
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unlinking a version-scoped requirement returns it to adopted', () => {
|
||||||
|
for (const status of ['planned', 'developing', 'testing', 'released'] as const) {
|
||||||
|
assert.deepEqual(getRequirementVersionUnlinkPatch(requirement({ status })), {
|
||||||
|
versionId: undefined,
|
||||||
|
addedToVersionBy: undefined,
|
||||||
|
status: 'adopted',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unlinking keeps other requirement statuses unchanged', () => {
|
||||||
|
assert.deepEqual(getRequirementVersionUnlinkPatch(requirement({ status: 'adopted' })), {
|
||||||
|
versionId: undefined,
|
||||||
|
addedToVersionBy: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing version from requirement edit uses the same unlink semantics', () => {
|
||||||
|
assert.deepEqual(getRequirementVersionSelectionPatch(requirement(), ''), {
|
||||||
|
versionId: undefined,
|
||||||
|
addedToVersionBy: undefined,
|
||||||
|
status: 'adopted',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a version preserves the current business status', () => {
|
||||||
|
assert.deepEqual(getRequirementVersionSelectionPatch(requirement({ status: 'adopted' }), 'version-2', 'pm2'), {
|
||||||
|
versionId: 'version-2',
|
||||||
|
addedToVersionBy: 'pm2',
|
||||||
|
});
|
||||||
|
});
|
||||||
29
apps/web/lib/requirement-version-link.ts
Normal file
29
apps/web/lib/requirement-version-link.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Requirement } from './requirement';
|
||||||
|
|
||||||
|
type RequirementVersionPatch = Pick<Partial<Requirement>, 'versionId' | 'addedToVersionBy' | 'status'>;
|
||||||
|
const VERSION_SCOPED_REQUIREMENT_STATUSES = new Set<Requirement['status']>(['planned', 'developing', 'testing', 'released']);
|
||||||
|
|
||||||
|
export function getRequirementVersionUnlinkPatch(
|
||||||
|
requirement: Pick<Requirement, 'status'>,
|
||||||
|
): RequirementVersionPatch {
|
||||||
|
return {
|
||||||
|
versionId: undefined,
|
||||||
|
addedToVersionBy: undefined,
|
||||||
|
...(VERSION_SCOPED_REQUIREMENT_STATUSES.has(requirement.status) ? { status: 'adopted' as const } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequirementVersionSelectionPatch(
|
||||||
|
requirement: Pick<Requirement, 'status' | 'addedToVersionBy'>,
|
||||||
|
versionId?: string,
|
||||||
|
addedBy?: string,
|
||||||
|
): RequirementVersionPatch {
|
||||||
|
if (!versionId) {
|
||||||
|
return getRequirementVersionUnlinkPatch(requirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
versionId,
|
||||||
|
addedToVersionBy: addedBy || requirement.addedToVersionBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user