Files
ftb-project-management/apps/web/components/requirement/RequirementModal.tsx
Script Generator 53ae3f02a5 fix: 新建需求漏传 status 字段导致状态/操作按钮不显示
根因:RequirementModal.handleSubmit 提交时漏传 status,store
createRequirement 也无默认值,落库 status 为 undefined。
page.tsx 的所有操作按钮均按 req.status === '...' 判断,
undefined 不匹配任何分支 → 所有按钮消失;REQ_STATUS_LABEL[undefined]
也是 undefined → 状态列空白。

修复(双兜底,二选一就够,但都改更稳):
- RequirementModal.tsx: handleSubmit 加 status: initial?.status ?? 'pending_review'
- useRequirementStore.ts: createRequirement 加 status: data.status ?? 'pending_review'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 20:55:04 +08:00

429 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import type { Requirement, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
import type { Priority } from '@/lib/derive';
import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
interface RequirementModalProps {
open: boolean;
initial?: Requirement | null;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId: string }[];
sourceTargets: SourceTarget[];
types: DictItem[];
platforms: DictItem[];
requirements: Requirement[];
currentUserName: string;
onClose: () => void;
onSubmit: (data: any) => void;
onOpenDrawer: (type: 'source' | 'type' | 'platform') => void;
}
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
const EFFORTS: Effort[] = ['S', 'M', 'L', 'XL'];
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
const PRIORITY_COLORS: Record<Priority, string> = {
P0: 'bg-red-500 text-white',
P1: 'bg-orange-500 text-white',
P2: 'bg-blue-500 text-white',
P3: 'bg-zinc-500 text-white',
P4: 'bg-zinc-400 text-white',
};
export function RequirementModal({
open,
initial,
products,
projects,
versions,
sourceTargets,
types,
platforms,
requirements,
currentUserName,
onClose,
onSubmit,
onOpenDrawer,
}: RequirementModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [sourceType, setSourceType] = useState<SourceType>('customer');
const [sourceTarget, setSourceTarget] = useState('');
const [productId, setProductId] = useState('');
const [projectId, setProjectId] = useState('');
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [typeId, setTypeId] = useState('');
const [priority, setPriority] = useState<Priority>('P2');
const [effort, setEffort] = useState<Effort>('M');
const [productOwner, setProductOwner] = useState('');
const [parentId, setParentId] = useState('');
const [versionId, setVersionId] = useState('');
const [platformDropOpen, setPlatformDropOpen] = useState(false);
const filteredProjects = useMemo(
() => productId ? projects.filter((p) => p.productId === productId) : projects,
[projects, productId],
);
const filteredVersions = useMemo(
() => projectId ? versions.filter((v) => v.projectId === projectId) : [],
[versions, projectId],
);
useEffect(() => {
if (initial) {
setTitle(initial.title);
setDescription(initial.description || '');
setSourceType(initial.sourceType || 'customer');
setSourceTarget(initial.sourceTarget || '');
setProductId(initial.productId || '');
setProjectId(initial.projectId || '');
setVersionId(initial.versionId || '');
setSelectedPlatforms(initial.platforms || []);
setTypeId(initial.typeId || '');
setPriority(initial.priority || 'P2');
setEffort(initial.effort || 'M');
setProductOwner(initial.productOwner || '');
setParentId(initial.parentId || '');
} else {
setTitle('');
setDescription('');
setSourceType('customer');
setSourceTarget('');
setProductId('');
setProjectId('');
setVersionId('');
setSelectedPlatforms([]);
setTypeId('');
setPriority('P2');
setEffort('M');
setProductOwner('');
setParentId('');
}
}, [initial, open]);
if (!open) return null;
const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType);
const togglePlatform = (id: string) => {
setSelectedPlatforms((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
};
const handleSubmit = () => {
if (!title.trim() || !productId || !projectId) return;
onSubmit({
title: title.trim(),
description: description.trim(),
sourceType,
sourceTarget: sourceTarget || undefined,
productId,
projectId,
versionId: versionId || undefined,
platforms: selectedPlatforms,
typeId: typeId || undefined,
priority,
effort,
status: initial?.status ?? 'pending_review',
creator: initial?.creator || currentUserName || '系统',
parentId: parentId || undefined,
});
};
const platformDisplay =
selectedPlatforms.length > 0
? platforms
.filter((p) => selectedPlatforms.includes(p.id))
.map((p) => p.name)
.join(', ')
: '请选择';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="w-full max-w-lg rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)] max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h2 className="text-[15px] font-semibold text-[var(--ink)]">
{initial ? '编辑需求' : '新建需求'}
</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4">
{/* 1. 需求概述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="请输入需求标题"
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
/>
</div>
{/* 2. 需求描述 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="请输入需求描述"
className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)] resize-none"
/>
</div>
{/* 3. 来源类型 + 来源对象 (2-column grid) */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={sourceType}
onChange={(e) => {
setSourceType(e.target.value as SourceType);
setSourceTarget('');
}}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
{SOURCE_TYPES.map((st) => (
<option key={st} value={st}>
{SOURCE_TYPE_LABEL[st]}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]">
{SOURCE_TARGET_LABEL[sourceType]}
</label>
<button
type="button"
onClick={() => onOpenDrawer('source')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={sourceTarget}
onChange={(e) => setSourceTarget(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredTargets.map((t) => (
<option key={t.id} value={t.name}>
{t.name}
</option>
))}
</select>
</div>
</div>
{/* 4. 所属产品 → 项目 → 版本 */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
</label>
<select
value={productId}
onChange={(e) => { setProductId(e.target.value); setProjectId(''); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
<span className="text-red-500">*</span>
</label>
<select
value={projectId}
onChange={(e) => { setProjectId(e.target.value); setVersionId(''); }}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredProjects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={versionId}
onChange={(e) => setVersionId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{filteredVersions.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
</div>
{/* 5. 支持端 (multi-select) */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('platform')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<div className="relative">
<button
type="button"
onClick={() => setPlatformDropOpen(!platformDropOpen)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-left text-[var(--ink)] truncate"
>
{platformDisplay}
</button>
{platformDropOpen && (
<div className="absolute top-10 left-0 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)] z-10 max-h-40 overflow-y-auto py-1">
{platforms.map((p) => (
<label
key={p.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--bg-subtle)] cursor-pointer"
>
<input
type="checkbox"
checked={selectedPlatforms.includes(p.id)}
onChange={() => togglePlatform(p.id)}
className="h-3.5 w-3.5 rounded border-[var(--line)] accent-[var(--accent)]"
/>
<span className="text-[13px] text-[var(--ink)]">{p.name}</span>
</label>
))}
{platforms.length === 0 && (
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]"></div>
)}
</div>
)}
</div>
</div>
{/* 6. 需求类型 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[12px] font-medium text-[var(--ink-soft)]"></label>
<button
type="button"
onClick={() => onOpenDrawer('type')}
className="text-[11px] text-[var(--accent)] hover:underline"
>
</button>
</div>
<select
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{/* 7. 优先级 (button group) */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<div className="flex gap-2">
{PRIORITIES.map((p) => (
<button
key={p}
type="button"
onClick={() => setPriority(p)}
className={`h-8 px-3 rounded-lg text-[12px] font-medium transition-colors ${
priority === p
? PRIORITY_COLORS[p]
: 'border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
}`}
>
{p}
</button>
))}
</div>
</div>
{/* 10. 关联父需求 */}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
>
<option value=""></option>
{requirements
.filter((r) => r.id !== initial?.id)
.map((r) => (
<option key={r.id} value={r.id}>
{r.code} - {r.title}
</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-[var(--line)]">
<button
type="button"
onClick={onClose}
className="h-9 px-4 rounded-lg border border-[var(--line)] text-[13px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
>
</button>
<button
type="button"
onClick={handleSubmit}
className="h-9 px-4 rounded-lg bg-[var(--accent)] text-white text-[13px] font-medium hover:opacity-90 transition-opacity"
>
{initial ? '保存' : '创建'}
</button>
</div>
</div>
</div>
);
}