feat: 实现需求管理、加班记录、成员/角色管理模块
- 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择 - 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出 - 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置 - 角色管理:卡片列表、系统角色保护、CRUD - 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页 - 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
164
apps/web/components/requirement/DictDrawer.tsx
Normal file
164
apps/web/components/requirement/DictDrawer.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { X, Plus, Pencil, Trash2, Check } from 'lucide-react';
|
||||
import type { DictItem, SourceTarget, SourceType } from '@/lib/requirement';
|
||||
import { SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
|
||||
interface DictDrawerProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
items: DictItem[];
|
||||
onClose: () => void;
|
||||
onAdd: (name: string) => void;
|
||||
onUpdate: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
interface SourceDrawerProps {
|
||||
open: boolean;
|
||||
items: SourceTarget[];
|
||||
onClose: () => void;
|
||||
onAdd: (name: string, sourceType: SourceType) => void;
|
||||
onUpdate: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const SOURCE_TYPES: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
|
||||
|
||||
export function DictDrawer({ open, title, items, onClose, onAdd, onUpdate, onDelete }: DictDrawerProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<DrawerShell title={title} onClose={onClose}>
|
||||
<ItemList items={items} onAdd={onAdd} onUpdate={onUpdate} onDelete={onDelete} />
|
||||
</DrawerShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceDrawer({ open, items, onClose, onAdd, onUpdate, onDelete }: SourceDrawerProps) {
|
||||
const [activeTab, setActiveTab] = useState<SourceType>('customer');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const filtered = items.filter((t) => t.sourceType === activeTab);
|
||||
|
||||
return (
|
||||
<DrawerShell title="来源对象管理" onClose={onClose}>
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-1 px-4 py-2 border-b border-[var(--line)]">
|
||||
{SOURCE_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveTab(type)}
|
||||
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition-colors ${activeTab === type ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] text-[var(--ink-soft)] hover:text-[var(--ink)]'}`}
|
||||
>
|
||||
{SOURCE_TYPE_LABEL[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ItemList
|
||||
items={filtered}
|
||||
onAdd={(name) => onAdd(name, activeTab)}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
placeholder={`添加${SOURCE_TYPE_LABEL[activeTab]}来源对象`}
|
||||
/>
|
||||
</DrawerShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerShell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
|
||||
<div className="w-80 h-full bg-[var(--bg-card)] border-l border-[var(--line)] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--line)]">
|
||||
<span className="text-[13px] font-semibold text-[var(--ink)]">{title}</span>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemList({ items, onAdd, onUpdate, onDelete, placeholder }: {
|
||||
items: { id: string; name: string }[];
|
||||
onAdd: (name: string) => void;
|
||||
onUpdate: (id: string, name: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) return;
|
||||
onAdd(trimmed);
|
||||
setNewName('');
|
||||
};
|
||||
|
||||
const confirmEdit = () => {
|
||||
if (editingId && editingName.trim()) {
|
||||
onUpdate(editingId, editingName.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-[var(--line)]">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||
placeholder={placeholder || '输入名称...'}
|
||||
className="flex-1 h-8 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)]"
|
||||
/>
|
||||
<button onClick={handleAdd} className="h-8 w-8 flex items-center justify-center rounded-lg bg-[var(--accent)] text-white hover:opacity-90">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="group flex items-center px-4 py-2 hover:bg-[var(--bg-subtle)]">
|
||||
{editingId === item.id ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditingId(null); }}
|
||||
autoFocus
|
||||
className="flex-1 h-7 rounded-lg border border-[var(--accent)] bg-[var(--bg-card)] px-2 text-[13px] text-[var(--ink)] outline-none"
|
||||
/>
|
||||
<button onClick={confirmEdit} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--accent)]">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-[13px] text-[var(--ink)]">{item.name}</span>
|
||||
<div className="hidden group-hover:flex items-center gap-1">
|
||||
<button onClick={() => { setEditingId(item.id); setEditingName(item.name); }} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)]">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(item.id)} className="p-1 rounded hover:bg-[var(--bg-card)] text-[var(--ink-muted)] hover:text-red-500">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无数据,请添加</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
apps/web/components/requirement/RequirementDetail.tsx
Normal file
160
apps/web/components/requirement/RequirementDetail.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
interface RequirementDetailProps {
|
||||
requirement: Requirement;
|
||||
projects: { id: string; name: string }[];
|
||||
types: DictItem[];
|
||||
platforms: DictItem[];
|
||||
sourceTargets: SourceTarget[];
|
||||
resolveVersionName: (id?: string) => string;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onAdopt?: () => void;
|
||||
onReject?: () => void;
|
||||
onCloseReq?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export function RequirementDetail({
|
||||
requirement: req,
|
||||
projects,
|
||||
types,
|
||||
platforms,
|
||||
sourceTargets,
|
||||
resolveVersionName,
|
||||
onClose,
|
||||
onEdit,
|
||||
onAdopt,
|
||||
onReject,
|
||||
onCloseReq,
|
||||
onDelete,
|
||||
}: RequirementDetailProps) {
|
||||
const canEdit = ['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('、') || '-';
|
||||
|
||||
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>
|
||||
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]">
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
<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>
|
||||
)}
|
||||
<FieldRow label="所属版本" value={resolveVersionName(req.versionId)} />
|
||||
</div>
|
||||
|
||||
{/* 人员 & 日期 */}
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
</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 FieldRow({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
412
apps/web/components/requirement/RequirementModal.tsx
Normal file
412
apps/web/components/requirement/RequirementModal.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
'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 }[];
|
||||
sourceTargets: SourceTarget[];
|
||||
types: DictItem[];
|
||||
platforms: DictItem[];
|
||||
requirements: Requirement[];
|
||||
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,
|
||||
sourceTargets,
|
||||
types,
|
||||
platforms,
|
||||
requirements,
|
||||
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 [platformDropOpen, setPlatformDropOpen] = useState(false);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => productId ? projects.filter((p) => p.productId === productId) : projects,
|
||||
[projects, productId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
setTitle(initial.title);
|
||||
setDescription(initial.description || '');
|
||||
setSourceType(initial.sourceType || 'customer');
|
||||
setSourceTarget(initial.sourceTarget || '');
|
||||
setProductId(initial.productId || '');
|
||||
setProjectId(initial.projectId || '');
|
||||
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('');
|
||||
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()) return;
|
||||
onSubmit({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
sourceType,
|
||||
sourceTarget: sourceTarget || undefined,
|
||||
productId: productId || undefined,
|
||||
projectId: projectId || undefined,
|
||||
platforms: selectedPlatforms,
|
||||
typeId: typeId || undefined,
|
||||
priority,
|
||||
productOwner: productOwner.trim() || undefined,
|
||||
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.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 所属产品 → 项目 */}
|
||||
<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={productId}
|
||||
onChange={(e) => { setProductId(e.target.value); setProjectId(''); }}
|
||||
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">
|
||||
所属项目
|
||||
</label>
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(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>
|
||||
{filteredProjects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.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>
|
||||
|
||||
{/* 9. 产品负责人 */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||
产品负责人
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={productOwner}
|
||||
onChange={(e) => setProductOwner(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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user