Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
625 lines
27 KiB
TypeScript
625 lines
27 KiB
TypeScript
'use client';
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import { Search, X } from 'lucide-react';
|
|
import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
|
import type { Priority } from '@/lib/derive';
|
|
import { filterRequirementProjectOptions, filterRequirementVersionOptions, filterSourceTargetsByKeyword, formatSourceTargetSelection, parseSourceTargetSelection, 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;
|
|
}
|
|
|
|
const PRIORITIES: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
|
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,
|
|
}: RequirementModalProps) {
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [sourceType, setSourceType] = useState<SourceType>('customer');
|
|
const [selectedSourceTargets, setSelectedSourceTargets] = useState<string[]>([]);
|
|
const [productId, setProductId] = useState('');
|
|
const [projectId, setProjectId] = useState('');
|
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
|
const [typeId, setTypeId] = useState('');
|
|
const [priority, setPriority] = useState<Priority>('P2');
|
|
const [productOwner, setProductOwner] = useState('');
|
|
const [parentId, setParentId] = useState('');
|
|
const [versionId, setVersionId] = useState('');
|
|
|
|
const [sourceTypeDropOpen, setSourceTypeDropOpen] = useState(false);
|
|
const [sourceTargetDropOpen, setSourceTargetDropOpen] = useState(false);
|
|
const [sourceTargetSearch, setSourceTargetSearch] = useState('');
|
|
const [productDropOpen, setProductDropOpen] = useState(false);
|
|
const [projectDropOpen, setProjectDropOpen] = useState(false);
|
|
const [projectSearch, setProjectSearch] = useState('');
|
|
const [versionDropOpen, setVersionDropOpen] = useState(false);
|
|
const [versionSearch, setVersionSearch] = useState('');
|
|
const [typeDropOpen, setTypeDropOpen] = useState(false);
|
|
const [platformDropOpen, setPlatformDropOpen] = useState(false);
|
|
|
|
const filteredProjects = useMemo(
|
|
() => filterRequirementProjectOptions(projects, productId, projectSearch),
|
|
[projects, productId, projectSearch],
|
|
);
|
|
|
|
const filteredVersions = useMemo(
|
|
() => filterRequirementVersionOptions(versions, projectId, versionSearch),
|
|
[versions, projectId, versionSearch],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (initial) {
|
|
setTitle(initial.title);
|
|
setDescription(initial.description || '');
|
|
setSourceType(initial.sourceType || 'customer');
|
|
setSelectedSourceTargets(parseSourceTargetSelection(initial.sourceTarget));
|
|
setSourceTypeDropOpen(false);
|
|
setSourceTargetSearch('');
|
|
setSourceTargetDropOpen(false);
|
|
setProductDropOpen(false);
|
|
setProjectSearch('');
|
|
setProjectDropOpen(false);
|
|
setVersionSearch('');
|
|
setVersionDropOpen(false);
|
|
setTypeDropOpen(false);
|
|
setProductId(initial.productId || '');
|
|
setProjectId(initial.projectId || '');
|
|
setVersionId(initial.versionId || '');
|
|
setSelectedPlatforms(initial.platforms || []);
|
|
setTypeId(initial.typeId || '');
|
|
setPriority(initial.priority || 'P2');
|
|
setProductOwner(initial.productOwner || '');
|
|
setParentId(initial.parentId || '');
|
|
} else {
|
|
setTitle('');
|
|
setDescription('');
|
|
setSourceType('customer');
|
|
setSelectedSourceTargets([]);
|
|
setSourceTypeDropOpen(false);
|
|
setSourceTargetSearch('');
|
|
setSourceTargetDropOpen(false);
|
|
setProductDropOpen(false);
|
|
setProjectSearch('');
|
|
setProjectDropOpen(false);
|
|
setVersionSearch('');
|
|
setVersionDropOpen(false);
|
|
setTypeDropOpen(false);
|
|
setProductId('');
|
|
setProjectId('');
|
|
setVersionId('');
|
|
setSelectedPlatforms([]);
|
|
setTypeId('');
|
|
setPriority('P2');
|
|
setProductOwner('');
|
|
setParentId('');
|
|
}
|
|
}, [initial, open]);
|
|
|
|
if (!open) return null;
|
|
|
|
const filteredTargets = filterSourceTargetsByKeyword(sourceTargets, sourceType, sourceTargetSearch);
|
|
|
|
const toggleSourceTarget = (name: string) => {
|
|
setSelectedSourceTargets((prev) =>
|
|
prev.includes(name) ? prev.filter((item) => item !== name) : [...prev, name]
|
|
);
|
|
};
|
|
|
|
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: formatSourceTargetSelection(selectedSourceTargets) || undefined,
|
|
productId,
|
|
projectId,
|
|
versionId: versionId || undefined,
|
|
platforms: selectedPlatforms,
|
|
typeId: typeId || undefined,
|
|
priority,
|
|
effort: initial?.effort ?? 'M',
|
|
status: initial?.status ?? 'pending_review',
|
|
creator: initial?.creator || currentUserName || '系统',
|
|
parentId: parentId || undefined,
|
|
});
|
|
};
|
|
|
|
const sourceTargetDisplay =
|
|
selectedSourceTargets.length > 0
|
|
? formatSourceTargetSelection(selectedSourceTargets)
|
|
: '请选择';
|
|
|
|
const productDisplay = productId
|
|
? products.find((product) => product.id === productId)?.name ?? '请选择产品'
|
|
: '请选择产品';
|
|
|
|
const projectDisplay = projectId
|
|
? projects.find((project) => project.id === projectId)?.name ?? '请选择项目'
|
|
: productId
|
|
? '请选择项目'
|
|
: '先选择产品';
|
|
|
|
const versionDisplay = versionId
|
|
? versions.find((version) => version.id === versionId)?.name ?? '请选择版本'
|
|
: projectId
|
|
? '请选择版本'
|
|
: '先选择项目';
|
|
|
|
const typeDisplay = typeId
|
|
? types.find((type) => type.id === typeId)?.name ?? '请选择类型'
|
|
: '请选择类型';
|
|
|
|
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>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSourceTypeDropOpen(!sourceTypeDropOpen)}
|
|
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"
|
|
>
|
|
{SOURCE_TYPE_LABEL[sourceType]}
|
|
</button>
|
|
{sourceTypeDropOpen && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
|
{SOURCE_TYPES.map((item) => (
|
|
<button
|
|
key={item}
|
|
type="button"
|
|
onClick={() => {
|
|
setSourceType(item);
|
|
setSelectedSourceTargets([]);
|
|
setSourceTargetSearch('');
|
|
setSourceTargetDropOpen(false);
|
|
setSourceTypeDropOpen(false);
|
|
}}
|
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
|
sourceType === item ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
|
}`}
|
|
>
|
|
{SOURCE_TYPE_LABEL[item]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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>
|
|
</div>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSourceTargetDropOpen(!sourceTargetDropOpen)}
|
|
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"
|
|
>
|
|
{sourceTargetDisplay}
|
|
</button>
|
|
{sourceTargetDropOpen && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
|
<div className="border-b border-[var(--line)] p-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
|
<input
|
|
value={sourceTargetSearch}
|
|
onChange={(event) => setSourceTargetSearch(event.target.value)}
|
|
placeholder="搜索"
|
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-40 overflow-y-auto py-1">
|
|
{filteredTargets.map((target) => (
|
|
<label
|
|
key={target.id}
|
|
className="flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--bg-subtle)] cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSourceTargets.includes(target.name)}
|
|
onChange={() => toggleSourceTarget(target.name)}
|
|
className="h-3.5 w-3.5 rounded border-[var(--line)] accent-[var(--accent)]"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-[13px] text-[var(--ink)]">{target.name}</span>
|
|
</label>
|
|
))}
|
|
{filteredTargets.length === 0 && (
|
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setProductDropOpen(!productDropOpen)}
|
|
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"
|
|
>
|
|
{productDisplay}
|
|
</button>
|
|
{productDropOpen && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
|
{products.map((product) => (
|
|
<button
|
|
key={product.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setProductId(product.id);
|
|
setProjectId('');
|
|
setVersionId('');
|
|
setProjectSearch('');
|
|
setProjectDropOpen(false);
|
|
setVersionSearch('');
|
|
setVersionDropOpen(false);
|
|
setProductDropOpen(false);
|
|
}}
|
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
|
productId === product.id ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
|
}`}
|
|
>
|
|
{product.name}
|
|
</button>
|
|
))}
|
|
{products.length === 0 && (
|
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无产品</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
|
所属项目 <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
disabled={!productId}
|
|
onClick={() => productId && setProjectDropOpen(!projectDropOpen)}
|
|
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 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{projectDisplay}
|
|
</button>
|
|
{projectDropOpen && productId && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
|
<div className="border-b border-[var(--line)] p-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
|
<input
|
|
value={projectSearch}
|
|
onChange={(event) => setProjectSearch(event.target.value)}
|
|
placeholder="搜索项目"
|
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-40 overflow-y-auto py-1">
|
|
{filteredProjects.map((project) => (
|
|
<button
|
|
key={project.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setProjectId(project.id);
|
|
setVersionId('');
|
|
setProjectSearch('');
|
|
setProjectDropOpen(false);
|
|
setVersionSearch('');
|
|
setVersionDropOpen(false);
|
|
}}
|
|
className="block w-full truncate px-3 py-1.5 text-left text-[13px] text-[var(--ink)] hover:bg-[var(--bg-subtle)]"
|
|
>
|
|
{project.name}
|
|
</button>
|
|
))}
|
|
{filteredProjects.length === 0 && (
|
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
|
所属版本
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
disabled={!projectId}
|
|
onClick={() => projectId && setVersionDropOpen(!versionDropOpen)}
|
|
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 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{versionDisplay}
|
|
</button>
|
|
{versionDropOpen && projectId && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-md)]">
|
|
<div className="border-b border-[var(--line)] p-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
|
<input
|
|
value={versionSearch}
|
|
onChange={(event) => setVersionSearch(event.target.value)}
|
|
placeholder="搜索版本"
|
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[12px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-40 overflow-y-auto py-1">
|
|
{filteredVersions.map((version) => (
|
|
<button
|
|
key={version.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setVersionId(version.id);
|
|
setVersionSearch('');
|
|
setVersionDropOpen(false);
|
|
}}
|
|
className="block w-full truncate px-3 py-1.5 text-left text-[13px] text-[var(--ink)] hover:bg-[var(--bg-subtle)]"
|
|
>
|
|
{version.name}
|
|
</button>
|
|
))}
|
|
{filteredVersions.length === 0 && (
|
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无匹配</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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>
|
|
</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>
|
|
</div>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTypeDropOpen(!typeDropOpen)}
|
|
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"
|
|
>
|
|
{typeDisplay}
|
|
</button>
|
|
{typeDropOpen && (
|
|
<div className="absolute left-0 top-10 z-20 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
|
{types.map((type) => (
|
|
<button
|
|
key={type.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setTypeId(type.id);
|
|
setTypeDropOpen(false);
|
|
}}
|
|
className={`block w-full truncate px-3 py-1.5 text-left text-[13px] hover:bg-[var(--bg-subtle)] ${
|
|
typeId === type.id ? 'font-medium text-[var(--accent)]' : 'text-[var(--ink)]'
|
|
}`}
|
|
>
|
|
{type.name}
|
|
</button>
|
|
))}
|
|
{types.length === 0 && (
|
|
<div className="px-3 py-2 text-[12px] text-[var(--ink-muted)]">暂无类型</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|