fix(version): 限制任务需求候选为版本已关联需求

This commit is contained in:
2026-07-06 11:43:23 +08:00
parent bc6e9357ea
commit 8d61bb7c13
7 changed files with 84 additions and 12 deletions

View File

@@ -28,7 +28,7 @@ import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime, formatLocalDate } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
import { getRequirementVersionSelectionPatch, getRequirementVersionUnlinkPatch } from '@/lib/requirement-version-link';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
@@ -1022,7 +1022,7 @@ export default function VersionDetailPage() {
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
(() => {
const pt = activeTab as 'research' | 'product' | 'ui';
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId);
const versionLinkedReqs = getVersionLinkedRequirementCandidates(displayRequirements, displayRequirementIds);
return (
<PlanTab
plans={displayPlans}
@@ -1032,7 +1032,7 @@ export default function VersionDetailPage() {
currentUserName={user?.name ?? ''}
planType={pt}
versionMembers={version.members ?? []}
linkedRequirements={projectAdoptedReqs}
linkedRequirements={versionLinkedReqs}
allRequirements={displayRequirements}
readOnly={versionReadonly || !planWriteReady}
onCreate={(data) => {

View File

@@ -9,12 +9,15 @@ import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours';
import type { Priority } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
interface Props {
versionId: string;
requirementIds: string[];
versionRequirements?: Requirement[];
versionDeadline?: string;
onClose: () => void;
onCreated?: () => void;
@@ -32,16 +35,17 @@ function defaultExpectedEnd(): string {
return isoToLocal(d.toISOString());
}
export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline, onClose, onCreated }: Props) {
export function DevTaskCreateModal({ versionId, requirementIds, versionRequirements, versionDeadline, onClose, onCreated }: Props) {
const { createTask, tasks } = useDevTaskStore();
const { categories } = useTaskCategoryStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const requirementSource = versionRequirements ?? requirements;
const versionReqs = useMemo(
() => requirements.filter((r) => requirementIds.includes(r.id)),
[requirements, requirementIds],
() => getVersionLinkedRequirementCandidates(requirementSource, requirementIds),
[requirementSource, requirementIds],
);
const versionTasks = useMemo(
() => tasks.filter((t) => t.versionId === versionId || requirementIds.includes(t.requirementId)),
@@ -65,6 +69,17 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
if (categories[0] && !categoryId) setCategoryId(categories[0].id);
}, [categories, categoryId]);
useEffect(() => {
if (versionReqs.length === 0) {
if (requirementId) setRequirementId('');
return;
}
if (versionReqs.some((r) => r.id === requirementId)) return;
const nextRequirement = versionReqs[0];
setRequirementId(nextRequirement.id);
if (!priorityManuallySet) setPriority(nextRequirement.priority);
}, [versionReqs, requirementId, priorityManuallySet]);
const expectedStartISO = localToISO(expectedStartLocal);
const expectedEndISO = localToISO(expectedEndLocal);

View File

@@ -254,7 +254,15 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{showCreate && !readOnly && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
{showCreate && !readOnly && (
<DevTaskCreateModal
versionId={versionId}
requirementIds={requirementIds}
versionRequirements={versionRequirements}
versionDeadline={versionDeadline}
onClose={() => setShowCreate(false)}
/>
)}
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} readOnly={readOnly} onClose={() => setSelectedTaskId(null)} />}
</div>
);

View File

@@ -10,13 +10,16 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import type { Priority } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
import { getVersionLinkedRequirementCandidates } from '@/lib/requirement-selector';
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import { isoToLocal, localToISO } from '@/lib/work-hours';
interface Props {
versionId: string;
requirementIds: string[];
versionRequirements?: Requirement[];
roundNo?: number;
onClose: () => void;
}
@@ -33,16 +36,17 @@ function defaultPlannedEndLocal(): string {
return isoToLocal(d.toISOString());
}
export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) {
export function TestCaseCreateModal({ versionId, requirementIds, versionRequirements, roundNo, onClose }: Props) {
const { createTestCase } = useTestCaseStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const requirementSource = versionRequirements ?? requirements;
const versionReqs = useMemo(
() => requirements.filter((r) => requirementIds.includes(r.id)),
[requirements, requirementIds],
() => getVersionLinkedRequirementCandidates(requirementSource, requirementIds),
[requirementSource, requirementIds],
);
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
@@ -69,6 +73,12 @@ export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClos
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
}, [categoryId, testCategories]);
useEffect(() => {
if (!requirementId) return;
if (versionReqs.some((r) => r.id === requirementId)) return;
setRequirementId('');
}, [versionReqs, requirementId]);
const handleCategoryChange = (nextCategoryId: string) => {
setCategoryId(nextCategoryId);
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);

View File

@@ -332,7 +332,15 @@ export function TestCaseTab({
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
{showCreate && !readOnly && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
{showCreate && !readOnly && (
<TestCaseCreateModal
versionId={versionId}
requirementIds={requirementIds}
versionRequirements={versionRequirements}
roundNo={activeRound}
onClose={() => setShowCreate(false)}
/>
)}
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} readOnly={readOnly} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />}
{bugForCaseId && !readOnly && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
</div>

View File

@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector';
import {
getProjectAdoptedRequirementCandidates,
getVersionLinkedRequirementCandidates,
mergeSelectedRequirementOptions,
} from './requirement-selector';
import type { Requirement } from './requirement';
const base = {
@@ -38,6 +42,17 @@ test('returns only adopted requirements from current project', () => {
assert.deepEqual(result.map((r: Requirement) => r.id), ['r1']);
});
test('returns only requirements already linked to current version', () => {
const result = getVersionLinkedRequirementCandidates([
req('r1', 'project-1', 'adopted'),
req('r2', 'project-1', 'developing'),
req('r3', 'project-1', 'adopted'),
req('r4', 'project-2', 'adopted'),
], ['r2', 'r1']);
assert.deepEqual(result.map((r: Requirement) => r.id), ['r2', 'r1']);
});
test('keeps historical selected requirements as non-candidate options', () => {
const options = mergeSelectedRequirementOptions(
[req('r1', 'project-1', 'adopted')],

View File

@@ -26,6 +26,22 @@ export function getProjectAdoptedRequirementCandidates(requirements: Requirement
.sort((a, b) => a.code.localeCompare(b.code, 'zh-CN'));
}
export function getVersionLinkedRequirementCandidates(requirements: Requirement[], requirementIds: string[]): Requirement[] {
const linkedOrder = new Map<string, number>();
requirementIds.forEach((id, index) => {
if (!linkedOrder.has(id)) linkedOrder.set(id, index);
});
return requirements
.filter((r) => linkedOrder.has(r.id))
.sort((a, b) => {
const orderA = linkedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
const orderB = linkedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return a.code.localeCompare(b.code, 'zh-CN');
});
}
export function mergeSelectedRequirementOptions(
candidates: Requirement[],
allRequirements: Requirement[],