Files
ftb-project-management/apps/web/components/test-case/TestCaseCreateModal.tsx
Script Generator 52a88626d4 feat(版本): 完善计划覆盖与工作台待办
关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 13:43:18 +08:00

234 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState, useMemo } from 'react';
import { X } from 'lucide-react';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import type { Priority } from '@/lib/derive';
import { getCategoriesByGroup, getDefaultCategoryByGroup } from '@/lib/task-category';
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
import { isoToLocal, localToISO } from '@/lib/work-hours';
interface Props {
versionId: string;
requirementIds: string[];
roundNo?: number;
onClose: () => void;
}
function defaultPlannedTestLocal(): string {
const d = new Date();
d.setHours(9, 0, 0, 0);
return isoToLocal(d.toISOString());
}
function defaultPlannedEndLocal(): string {
const d = new Date();
d.setHours(10, 0, 0, 0);
return isoToLocal(d.toISOString());
}
export function TestCaseCreateModal({ versionId, requirementIds, roundNo, onClose }: Props) {
const { createTestCase } = useTestCaseStore();
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const { categories } = useTaskCategoryStore();
const user = useAuthStore((s) => s.user);
const versionReqs = useMemo(
() => requirements.filter((r) => requirementIds.includes(r.id)),
[requirements, requirementIds],
);
const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]);
const [title, setTitle] = useState('');
const [requirementId, setRequirementId] = useState(versionReqs[0]?.id || '');
const [priority, setPriority] = useState<Priority>(versionReqs[0]?.priority || 'P2');
const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id);
const selectedCategory = useMemo(
() => categories.find((category) => category.id === categoryId) ?? testCategories[0],
[categories, categoryId, testCategories],
);
const [estimateHours, setEstimateHours] = useState(0.5);
const [plannedTestLocal, setPlannedTestLocal] = useState(defaultPlannedTestLocal);
const [plannedEndLocal, setPlannedEndLocal] = useState(defaultPlannedEndLocal);
const [assigneeId, setAssigneeId] = useState(user?.name || '');
const [description, setDescription] = useState('');
const [prototypeNotes, setPrototypeNotes] = useState('');
useEffect(() => {
if (testCategories.length === 0) return;
if (testCategories.some((category) => category.id === categoryId)) return;
const next = testCategories[0];
setCategoryId(next.id);
setEstimateHours(getDefaultTestCaseEstimateHours(next.code));
}, [categoryId, testCategories]);
const handleCategoryChange = (nextCategoryId: string) => {
setCategoryId(nextCategoryId);
const category = categories.find((c) => c.id === nextCategoryId) ?? testCategories.find((c) => c.id === nextCategoryId);
setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
};
const normalizedEstimateHours = clampTestCaseEstimateHours(selectedCategory?.code, estimateHours);
const plannedTestAt = localToISO(plannedTestLocal);
const plannedEndAt = localToISO(plannedEndLocal);
const hasValidPlan = Boolean(plannedTestAt && plannedEndAt && plannedEndAt > plannedTestAt);
const canSubmit = title.trim() && categoryId && normalizedEstimateHours > 0 && hasValidPlan;
const handleSubmit = () => {
if (!canSubmit) return;
const reqRef = versionReqs.find((r) => r.id === requirementId);
const references = [
...(reqRef ? [{ type: 'requirement' as const, id: reqRef.code, label: `${reqRef.code} ${reqRef.title}` }] : []),
...prototypeNotes.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).map((note) => ({
type: 'prototype_note' as const,
id: note,
label: note,
})),
];
createTestCase({
versionId,
requirementId: requirementId || undefined,
roundNo: roundNo ?? 1,
title: title.trim(),
description: description.trim() || undefined,
categoryId,
priority,
estimateHours: normalizedEstimateHours,
plannedTestAt,
plannedEndAt,
assigneeId: assigneeId || undefined,
references: references.length > 0 ? references : undefined,
createdBy: user?.name || '系统',
});
onClose();
};
return (
<div className="fixed inset-0 z-50 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 items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="例如:正常排班" />
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<FilterSelect
value={requirementId || 'all'}
onChange={(value) => {
const nextValue = value === 'all' ? '' : value;
setRequirementId(nextValue);
const r = versionReqs.find((x) => x.id === nextValue);
if (r) setPriority(r.priority);
}}
options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))}
allLabel="不关联需求"
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<FilterSelect
value={priority}
onChange={(value) => setPriority(value as Priority)}
options={(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => ({ value: p, label: p }))}
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<FilterSelect
value={categoryId}
onChange={handleCategoryChange}
options={testCategories.map((category) => ({ value: category.id, label: category.name }))}
placeholder="选择任务类型"
showAllOption={false}
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<input
type="number"
min="0.25"
max="2"
step="0.25"
value={estimateHours}
onChange={(e) => setEstimateHours(Number(e.target.value))}
onBlur={() => setEstimateHours(normalizedEstimateHours)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<FilterSelect
value={assigneeId || 'all'}
onChange={(value) => setAssigneeId(value === 'all' ? '' : value)}
options={members.map((m) => ({ value: m.name, label: m.name }))}
allLabel="选择负责人"
className="w-full"
labelClassName="max-w-[calc(100%-20px)]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<WorkDateTimePicker
value={plannedTestLocal}
onChange={setPlannedTestLocal}
placeholder="选择计划开始时间"
defaultHour={9}
/>
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> *</label>
<WorkDateTimePicker
value={plannedEndLocal}
onChange={setPlannedEndLocal}
placeholder="选择计划结束时间"
defaultHour={10}
popoverAlign="right"
/>
</div>
</div>
{!hasValidPlan && plannedTestLocal && plannedEndLocal && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-2 text-[11px] text-amber-700">
</div>
)}
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"> & </label>
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" placeholder="1. 操作步骤...&#10;2. 预期结果..." />
</div>
<div>
<label className="block text-[12px] text-[var(--ink-soft)] mb-1"></label>
<input value={prototypeNotes} onChange={(e) => setPrototypeNotes(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" placeholder="如 QY0007, QY0023" />
<p className="mt-1 text-[11px] text-[var(--ink-muted)]"></p>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-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={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>
);
}