fix(data): 清理旧数据并保护服务端写入
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import type { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||
import { filterRequirementProjectOptions, filterRequirementVersionOptions, filterSourceTargetsByKeyword, formatSourceTargetSelection, parseSourceTargetSelection, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||
|
||||
interface RequirementModalProps {
|
||||
open: boolean;
|
||||
@@ -50,7 +50,7 @@ export function RequirementModal({
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sourceType, setSourceType] = useState<SourceType>('customer');
|
||||
const [sourceTarget, setSourceTarget] = useState('');
|
||||
const [selectedSourceTargets, setSelectedSourceTargets] = useState<string[]>([]);
|
||||
const [productId, setProductId] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
@@ -60,16 +60,25 @@ export function RequirementModal({
|
||||
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(
|
||||
() => productId ? projects.filter((p) => p.productId === productId) : projects,
|
||||
[projects, productId],
|
||||
() => filterRequirementProjectOptions(projects, productId, projectSearch),
|
||||
[projects, productId, projectSearch],
|
||||
);
|
||||
|
||||
const filteredVersions = useMemo(
|
||||
() => projectId ? versions.filter((v) => v.projectId === projectId) : [],
|
||||
[versions, projectId],
|
||||
() => filterRequirementVersionOptions(versions, projectId, versionSearch),
|
||||
[versions, projectId, versionSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,7 +86,16 @@ export function RequirementModal({
|
||||
setTitle(initial.title);
|
||||
setDescription(initial.description || '');
|
||||
setSourceType(initial.sourceType || 'customer');
|
||||
setSourceTarget(initial.sourceTarget || '');
|
||||
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 || '');
|
||||
@@ -90,7 +108,16 @@ export function RequirementModal({
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setSourceType('customer');
|
||||
setSourceTarget('');
|
||||
setSelectedSourceTargets([]);
|
||||
setSourceTypeDropOpen(false);
|
||||
setSourceTargetSearch('');
|
||||
setSourceTargetDropOpen(false);
|
||||
setProductDropOpen(false);
|
||||
setProjectSearch('');
|
||||
setProjectDropOpen(false);
|
||||
setVersionSearch('');
|
||||
setVersionDropOpen(false);
|
||||
setTypeDropOpen(false);
|
||||
setProductId('');
|
||||
setProjectId('');
|
||||
setVersionId('');
|
||||
@@ -104,7 +131,13 @@ export function RequirementModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const filteredTargets = sourceTargets.filter(t => t.sourceType === sourceType);
|
||||
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) =>
|
||||
@@ -118,7 +151,7 @@ export function RequirementModal({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
sourceType,
|
||||
sourceTarget: sourceTarget || undefined,
|
||||
sourceTarget: formatSourceTargetSelection(selectedSourceTargets) || undefined,
|
||||
productId,
|
||||
projectId,
|
||||
versionId: versionId || undefined,
|
||||
@@ -132,6 +165,31 @@ export function RequirementModal({
|
||||
});
|
||||
};
|
||||
|
||||
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
|
||||
@@ -194,20 +252,37 @@ export function RequirementModal({
|
||||
<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 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">
|
||||
@@ -222,18 +297,49 @@ export function RequirementModal({
|
||||
管理
|
||||
</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 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>
|
||||
|
||||
@@ -243,46 +349,144 @@ export function RequirementModal({
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
|
||||
@@ -342,18 +546,37 @@ export function RequirementModal({
|
||||
管理
|
||||
</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 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) */}
|
||||
|
||||
Reference in New Issue
Block a user