feat(需求池): 调整需求池布局与详情展示
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, EFFORT_SHORT, EFFORT_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
|
||||
interface RequirementDetailProps {
|
||||
requirement: Requirement;
|
||||
@@ -10,9 +10,10 @@ interface RequirementDetailProps {
|
||||
types: DictItem[];
|
||||
platforms: DictItem[];
|
||||
sourceTargets: SourceTarget[];
|
||||
requirements: Requirement[];
|
||||
resolveVersionName: (id?: string) => string;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onEdit?: () => void;
|
||||
onAdopt?: () => void;
|
||||
onReject?: () => void;
|
||||
onCloseReq?: () => void;
|
||||
@@ -20,11 +21,11 @@ interface RequirementDetailProps {
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
P0: 'bg-red-500/10 text-red-600',
|
||||
P1: 'bg-orange-500/10 text-orange-600',
|
||||
P2: 'bg-blue-500/10 text-blue-600',
|
||||
P3: 'bg-zinc-100 text-zinc-600',
|
||||
P4: 'bg-zinc-100 text-zinc-500',
|
||||
P0: 'bg-red-50 text-red-600 border-red-200',
|
||||
P1: 'bg-orange-50 text-orange-600 border-orange-200',
|
||||
P2: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
P3: 'bg-zinc-50 text-zinc-600 border-zinc-200',
|
||||
P4: 'bg-zinc-50 text-zinc-500 border-zinc-200',
|
||||
};
|
||||
|
||||
export function RequirementDetail({
|
||||
@@ -33,6 +34,7 @@ export function RequirementDetail({
|
||||
types,
|
||||
platforms,
|
||||
sourceTargets,
|
||||
requirements,
|
||||
resolveVersionName,
|
||||
onClose,
|
||||
onEdit,
|
||||
@@ -41,120 +43,136 @@ export function RequirementDetail({
|
||||
onCloseReq,
|
||||
onDelete,
|
||||
}: RequirementDetailProps) {
|
||||
const canEdit = ['pending_review', 'adopted', 'planned'].includes(req.status);
|
||||
const canEdit = Boolean(onEdit) && ['pending_review', 'adopted', 'planned'].includes(req.status);
|
||||
const projectName = projects.find((p) => p.id === req.projectId)?.name ?? '-';
|
||||
const typeName = types.find((t) => t.id === req.typeId)?.name ?? '-';
|
||||
const platformNames = req.platforms.map((pid) => platforms.find((p) => p.id === pid)?.name ?? pid).join('、') || '-';
|
||||
const sourceName = `${SOURCE_TYPE_LABEL[req.sourceType]}${req.sourceTarget ? ` / ${req.sourceTarget}` : ''}`;
|
||||
const sourceTargetName = sourceTargets.find((item) => item.name === req.sourceTarget)?.name ?? req.sourceTarget ?? '-';
|
||||
const versionName = resolveVersionName(req.versionId);
|
||||
const parentRequirement = req.parentId ? requirements.find((item) => item.id === req.parentId) : undefined;
|
||||
const parentRequirementLabel = parentRequirement ? `${parentRequirement.code} · ${parentRequirement.title}` : req.parentId || '-';
|
||||
const contextItems = [projectName, versionName, typeName].filter((item) => item && item !== '-');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
|
||||
<div className="w-[480px] h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${REQ_STATUS_COLOR[req.status]}`}>
|
||||
{REQ_STATUS_LABEL[req.status]}
|
||||
</span>
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full max-w-xl flex-col border-l border-[var(--line)] bg-[var(--bg-card)] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] px-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="shrink-0 text-[13px] font-semibold text-[var(--ink)]">{req.code}</span>
|
||||
<StatusBadge className={REQ_STATUS_COLOR[req.status]}>{REQ_STATUS_LABEL[req.status]}</StatusBadge>
|
||||
<StatusBadge className={PRIORITY_COLORS[req.priority] ?? PRIORITY_COLORS.P3}>{req.priority}</StatusBadge>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
|
||||
<button onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-[var(--line)]">
|
||||
{canEdit && (
|
||||
<button onClick={onEdit} className="h-7 px-3 rounded-md text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{onAdopt && req.status === 'pending_review' && (
|
||||
<button onClick={onAdopt} className="h-7 px-3 rounded-md text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 transition-colors">
|
||||
采纳
|
||||
</button>
|
||||
)}
|
||||
{onReject && req.status === 'pending_review' && (
|
||||
<button onClick={onReject} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-600 border border-red-200 hover:bg-red-50 transition-colors">
|
||||
拒绝
|
||||
</button>
|
||||
)}
|
||||
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && (
|
||||
<button onClick={onCloseReq} className="h-7 px-3 rounded-md text-[12px] font-medium text-orange-600 border border-orange-200 hover:bg-orange-50 transition-colors">
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && (
|
||||
<button onClick={onDelete} className="h-7 px-3 rounded-md text-[12px] font-medium text-red-500 border border-red-200 hover:bg-red-50 transition-colors">
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-5 py-3">
|
||||
{canEdit && <ActionButton onClick={onEdit}>编辑</ActionButton>}
|
||||
{onAdopt && req.status === 'pending_review' && <ActionButton tone="success" onClick={onAdopt}>采纳</ActionButton>}
|
||||
{onReject && req.status === 'pending_review' && <ActionButton tone="danger" onClick={onReject}>拒绝</ActionButton>}
|
||||
{onCloseReq && (req.status === 'adopted' || req.status === 'planned') && <ActionButton tone="warning" onClick={onCloseReq}>关闭</ActionButton>}
|
||||
{onDelete && ['pending_review', 'adopted', 'rejected', 'closed'].includes(req.status) && <ActionButton tone="danger" onClick={onDelete}>删除</ActionButton>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* 需求概述 */}
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--ink)] leading-snug">{req.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* 需求描述 */}
|
||||
{req.description && (
|
||||
<div>
|
||||
<Label>需求描述</Label>
|
||||
<p className="text-[13px] text-[var(--ink-soft)] whitespace-pre-wrap leading-relaxed">{req.description}</p>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<section className="border-b border-[var(--line)] px-5 py-5">
|
||||
<h2 className="text-[16px] font-semibold leading-snug text-[var(--ink)]">{req.title}</h2>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-[var(--ink-muted)]">
|
||||
{contextItems.map((item) => <ContextPill key={item}>{item}</ContextPill>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
|
||||
<FieldRow label="需求来源" value={`${SOURCE_TYPE_LABEL[req.sourceType]} · ${req.sourceTarget || '-'}`} />
|
||||
<FieldRow label="所属项目" value={projectName} />
|
||||
<FieldRow label="需求类型" value={typeName} />
|
||||
<FieldRow label="支持端" value={platformNames} />
|
||||
<FieldRow label="优先级">
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? ''}`}>
|
||||
{req.priority}
|
||||
</span>
|
||||
</FieldRow>
|
||||
{req.effort && (
|
||||
<FieldRow label="工作量">
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${EFFORT_COLOR[req.effort]}`}>
|
||||
{EFFORT_SHORT[req.effort]}
|
||||
</span>
|
||||
</FieldRow>
|
||||
<DetailSection title="需求描述">
|
||||
{req.description ? (
|
||||
<p className="whitespace-pre-wrap text-[13px] leading-6 text-[var(--ink-soft)]">{req.description}</p>
|
||||
) : (
|
||||
<p className="text-[13px] text-[var(--ink-muted)]">暂无描述</p>
|
||||
)}
|
||||
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* 人员 & 日期 */}
|
||||
<div className="rounded-lg border border-[var(--line)] divide-y divide-[var(--line)]">
|
||||
<FieldRow label="产品负责人" value={req.productOwner || '-'} />
|
||||
<FieldRow label="录入人员" value={req.creator} />
|
||||
<FieldRow label="录入日期" value={req.createdAt.slice(0, 10)} />
|
||||
</div>
|
||||
|
||||
{/* 关联父需求 */}
|
||||
{req.parentId && (
|
||||
<div className="rounded-lg border border-[var(--line)]">
|
||||
<FieldRow label="父需求" value={req.parentId} />
|
||||
<DetailSection title="基础信息">
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
|
||||
<InfoItem label="需求来源" value={sourceName} />
|
||||
<InfoItem label="所属项目" value={projectName} />
|
||||
<InfoItem label="需求类型" value={typeName} />
|
||||
<InfoItem label="支持端" value={platformNames} />
|
||||
<InfoItem label="所属版本" value={versionName} />
|
||||
<InfoItem label="父需求" value={parentRequirementLabel} />
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="记录信息">
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-4">
|
||||
<InfoItem label="产品负责人" value={req.productOwner || '-'} />
|
||||
<InfoItem label="录入人员" value={req.creator} />
|
||||
<InfoItem label="录入日期" value={req.createdAt.slice(0, 10)} />
|
||||
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-[11px] font-medium text-[var(--ink-muted)] mb-1">{children}</div>;
|
||||
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
|
||||
<h3 className="mb-3 text-[12px] font-semibold text-[var(--ink)]">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
|
||||
{children ?? <span className="text-[13px] text-[var(--ink)]">{value}</span>}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-[var(--ink-muted)]">{label}</div>
|
||||
<div className="mt-1 truncate text-[13px] text-[var(--ink)]" title={value}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ className, children }: { className: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextPill({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center rounded-md bg-[var(--bg-subtle)] px-2 py-1 text-[11px] text-[var(--ink-soft)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
children,
|
||||
onClick,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'danger';
|
||||
}) {
|
||||
const toneClass = {
|
||||
neutral: 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-card)]',
|
||||
success: 'border-emerald-200 text-emerald-700 hover:bg-emerald-50',
|
||||
warning: 'border-orange-200 text-orange-700 hover:bg-orange-50',
|
||||
danger: 'border-red-200 text-red-600 hover:bg-red-50',
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className={`h-7 rounded-md border px-3 text-[12px] font-medium transition-colors ${toneClass}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'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 { Requirement, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||
import type { Priority } from '@/lib/derive';
|
||||
import { EFFORT_LABEL, SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||
import { SOURCE_TYPE_LABEL, SOURCE_TARGET_LABEL } from '@/lib/requirement';
|
||||
|
||||
interface RequirementModalProps {
|
||||
open: boolean;
|
||||
@@ -22,7 +22,6 @@ interface RequirementModalProps {
|
||||
}
|
||||
|
||||
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> = {
|
||||
@@ -57,7 +56,6 @@ export function RequirementModal({
|
||||
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('');
|
||||
@@ -86,7 +84,6 @@ export function RequirementModal({
|
||||
setSelectedPlatforms(initial.platforms || []);
|
||||
setTypeId(initial.typeId || '');
|
||||
setPriority(initial.priority || 'P2');
|
||||
setEffort(initial.effort || 'M');
|
||||
setProductOwner(initial.productOwner || '');
|
||||
setParentId(initial.parentId || '');
|
||||
} else {
|
||||
@@ -100,7 +97,6 @@ export function RequirementModal({
|
||||
setSelectedPlatforms([]);
|
||||
setTypeId('');
|
||||
setPriority('P2');
|
||||
setEffort('M');
|
||||
setProductOwner('');
|
||||
setParentId('');
|
||||
}
|
||||
@@ -129,7 +125,7 @@ export function RequirementModal({
|
||||
platforms: selectedPlatforms,
|
||||
typeId: typeId || undefined,
|
||||
priority,
|
||||
effort,
|
||||
effort: initial?.effort ?? 'M',
|
||||
status: initial?.status ?? 'pending_review',
|
||||
creator: initial?.creator || currentUserName || '系统',
|
||||
parentId: parentId || undefined,
|
||||
|
||||
Reference in New Issue
Block a user