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:
489
apps/web/app/requirements/page.tsx
Normal file
489
apps/web/app/requirements/page.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { flattenProjects } from '@/lib/derive';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const STATUS_TABS: { key: string; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending_review', label: '待评审' },
|
||||
{ key: 'adopted', label: '已采纳' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
{ key: 'planned', label: '已规划' },
|
||||
{ key: 'developing', label: '开发中' },
|
||||
{ key: 'testing', label: '测试中' },
|
||||
{ key: 'released', label: '已上线' },
|
||||
{ key: 'closed', label: '已关闭' },
|
||||
];
|
||||
|
||||
export default function RequirementsPage() {
|
||||
const {
|
||||
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
||||
sourceTargets, types, platforms,
|
||||
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
||||
addType, updateType, deleteType,
|
||||
addPlatform, updatePlatform, deletePlatform,
|
||||
} = useRequirementStore();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [projectFilter, setProjectFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
|
||||
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
|
||||
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
|
||||
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [dateSort, setDateSort] = useState<'desc' | 'asc'>('desc');
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
|
||||
// Resolve version name from overview
|
||||
const resolveVersionName = (versionId?: string): string => {
|
||||
if (!versionId) return '-';
|
||||
for (const product of overview) {
|
||||
const version = product.versions?.find((v: any) => v.id === versionId);
|
||||
if (version) return version.name;
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...requirements];
|
||||
|
||||
// search filter
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter(
|
||||
(r) => r.code.toLowerCase().includes(q) || r.title.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
// status filter
|
||||
if (statusFilter !== 'all') {
|
||||
list = list.filter((r) => r.status === statusFilter);
|
||||
}
|
||||
|
||||
// project filter
|
||||
if (projectFilter !== 'all') {
|
||||
list = list.filter((r) => r.projectId === projectFilter);
|
||||
}
|
||||
|
||||
// priority filter
|
||||
if (priorityFilter !== 'all') {
|
||||
list = list.filter((r) => r.priority === priorityFilter);
|
||||
}
|
||||
|
||||
// type filter
|
||||
if (typeFilter !== 'all') {
|
||||
list = list.filter((r) => r.typeId === typeFilter);
|
||||
}
|
||||
|
||||
// sort by createdAt
|
||||
list.sort((a, b) => {
|
||||
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
return dateSort === 'asc' ? diff : -diff;
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [requirements, search, statusFilter, projectFilter, priorityFilter, typeFilter, dateSort]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
|
||||
const handleEdit = (req: Requirement) => {
|
||||
setViewingReq(null);
|
||||
setEditingReq(req);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (req: Requirement) => {
|
||||
setViewingReq(req);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingReq(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">需求</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||
{requirements.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setDrawerType('source')}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
来源管理
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建需求
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--line)] bg-[var(--bg-card)] px-5 py-3">
|
||||
<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={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索编号或标题"
|
||||
className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${
|
||||
statusFilter === tab.key
|
||||
? 'bg-[var(--accent)] text-white'
|
||||
: 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project dropdown */}
|
||||
<FilterSelect
|
||||
value={projectFilter}
|
||||
onChange={setProjectFilter}
|
||||
options={allProjects.map((p) => ({ value: p.id, label: p.name }))}
|
||||
placeholder="全部项目"
|
||||
allLabel="全部项目"
|
||||
/>
|
||||
|
||||
{/* Priority dropdown */}
|
||||
<FilterSelect
|
||||
value={priorityFilter}
|
||||
onChange={setPriorityFilter}
|
||||
options={['P0', 'P1', 'P2', 'P3', 'P4'].map((p) => ({ value: p, label: p }))}
|
||||
placeholder="全部优先级"
|
||||
allLabel="全部优先级"
|
||||
/>
|
||||
|
||||
{/* Type dropdown */}
|
||||
<FilterSelect
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={types.map((t) => ({ value: t.id, label: t.name }))}
|
||||
placeholder="全部类型"
|
||||
allLabel="全部类型"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
||||
<div className="px-5 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">没有找到匹配的需求</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">需求编号</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">需求概述</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">需求来源</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">所属项目</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">需求类型</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">状态</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">优先级</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">所属版本</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">产品负责人</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">录入人员</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
|
||||
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
录入日期
|
||||
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((req) => (
|
||||
<tr
|
||||
key={req.id}
|
||||
onClick={() => handleRowClick(req)}
|
||||
className="border-b border-[var(--line-soft)] last:border-0 cursor-pointer transition-colors hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
{/* 需求编号 */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--ink)]">{req.code}</span>
|
||||
</td>
|
||||
|
||||
{/* 需求概述 */}
|
||||
<td className="px-4 py-3">
|
||||
<span className="block max-w-[200px] truncate text-[var(--ink)]">{req.title}</span>
|
||||
</td>
|
||||
|
||||
{/* 需求来源 */}
|
||||
<td className="px-4 py-3 text-[13px] text-[var(--ink-soft)]">
|
||||
{SOURCE_TYPE_LABEL[req.sourceType]}: {req.sourceTarget}
|
||||
</td>
|
||||
|
||||
{/* 所属项目 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{allProjects.find((p) => p.id === req.projectId)?.name ?? '-'}
|
||||
</td>
|
||||
|
||||
{/* 需求类型 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{types.find((t) => t.id === req.typeId)?.name ?? '-'}
|
||||
</td>
|
||||
|
||||
{/* 状态 */}
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
</td>
|
||||
|
||||
{/* 优先级 */}
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${PRIORITY_COLORS[req.priority] ?? 'bg-zinc-100 text-zinc-500'}`}>
|
||||
{req.priority}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* 所属版本 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{resolveVersionName(req.versionId)}
|
||||
</td>
|
||||
|
||||
{/* 产品负责人 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{req.productOwner || '-'}
|
||||
</td>
|
||||
|
||||
{/* 录入人员 */}
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
||||
{req.creator}
|
||||
</td>
|
||||
|
||||
{/* 录入日期 */}
|
||||
<td className="px-4 py-3 text-[var(--ink-muted)] tabular-nums">
|
||||
{new Date(req.createdAt).toISOString().slice(0, 10)}
|
||||
</td>
|
||||
|
||||
{/* 操作 */}
|
||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* 编辑:待评审/已采纳/已规划 */}
|
||||
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && (
|
||||
<button
|
||||
onClick={() => handleEdit(req)}
|
||||
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{/* 待评审:采纳/拒绝 */}
|
||||
{req.status === 'pending_review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
||||
className="h-6 px-2 rounded text-[11px] font-medium text-emerald-600 hover:bg-emerald-50 transition-colors"
|
||||
>
|
||||
采纳
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
|
||||
className="h-6 px-2 rounded text-[11px] font-medium text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* 已采纳/已规划:关闭 */}
|
||||
{(req.status === 'adopted' || req.status === 'planned') && (
|
||||
<button
|
||||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
||||
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
{/* 删除:待评审/已采纳/已拒绝/已关闭 */}
|
||||
{(['pending_review', 'adopted', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||||
<button
|
||||
onClick={() => deleteRequirement(req.id)}
|
||||
className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail drawer */}
|
||||
{viewingReq && (
|
||||
<RequirementDetail
|
||||
requirement={viewingReq}
|
||||
projects={allProjects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
types={types}
|
||||
platforms={platforms}
|
||||
sourceTargets={sourceTargets}
|
||||
resolveVersionName={resolveVersionName}
|
||||
onClose={() => setViewingReq(null)}
|
||||
onEdit={() => handleEdit(viewingReq)}
|
||||
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
||||
onCloseReq={() => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); }}
|
||||
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<RequirementModal
|
||||
open={true}
|
||||
initial={editingReq}
|
||||
products={overview.map((p) => ({ id: p.id, name: p.name }))}
|
||||
projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))}
|
||||
sourceTargets={sourceTargets}
|
||||
types={types}
|
||||
platforms={platforms}
|
||||
requirements={requirements}
|
||||
onClose={() => { setShowModal(false); setEditingReq(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editingReq) {
|
||||
updateRequirement(editingReq.id, data);
|
||||
} else {
|
||||
createRequirement(data);
|
||||
}
|
||||
setShowModal(false);
|
||||
setEditingReq(null);
|
||||
}}
|
||||
onOpenDrawer={(type) => setDrawerType(type)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drawer */}
|
||||
{drawerType === 'source' && (
|
||||
<SourceDrawer
|
||||
open={true}
|
||||
items={sourceTargets}
|
||||
onClose={() => setDrawerType(null)}
|
||||
onAdd={addSourceTarget}
|
||||
onUpdate={updateSourceTarget}
|
||||
onDelete={deleteSourceTarget}
|
||||
/>
|
||||
)}
|
||||
{drawerType === 'type' && (
|
||||
<DictDrawer
|
||||
open={true}
|
||||
title="需求类型管理"
|
||||
items={types}
|
||||
onClose={() => setDrawerType(null)}
|
||||
onAdd={addType}
|
||||
onUpdate={updateType}
|
||||
onDelete={deleteType}
|
||||
/>
|
||||
)}
|
||||
{drawerType === 'platform' && (
|
||||
<DictDrawer
|
||||
open={true}
|
||||
title="支持端管理"
|
||||
items={platforms}
|
||||
onClose={() => setDrawerType(null)}
|
||||
onAdd={addPlatform}
|
||||
onUpdate={updatePlatform}
|
||||
onDelete={deletePlatform}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 拒绝原因弹窗 */}
|
||||
{rejectingReq && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setRejectingReq(null)}>
|
||||
<div className="w-full max-w-sm rounded-xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-3">拒绝需求</h3>
|
||||
<p className="text-[12px] text-[var(--ink-muted)] mb-2">需求:{rejectingReq.title}</p>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="请填写拒绝原因..."
|
||||
rows={3}
|
||||
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)] focus:border-[var(--accent)] focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<button onClick={() => setRejectingReq(null)} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!rejectReason.trim()) return;
|
||||
updateRequirement(rejectingReq.id, { status: 'rejected', description: rejectingReq.description + `\n\n【拒绝原因】${rejectReason.trim()}` });
|
||||
setRejectingReq(null);
|
||||
}}
|
||||
disabled={!rejectReason.trim()}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
确认拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user