From 9a0b16a8f1b552c0b4fb05b17b737b14a94d320a Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 9 Jun 2026 18:16:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=9C=80=E6=B1=82?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=81=E5=8A=A0=E7=8F=AD=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E3=80=81=E6=88=90=E5=91=98/=E8=A7=92=E8=89=B2=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 需求模块:完整 CRUD、状态流转(采纳/拒绝/关闭)、详情抽屉、产品→项目级联选择 - 加班记录:产品→项目→版本三级联动、月份筛选(MonthPicker)、CSV 导出 - 成员管理:左右布局(部门树+成员列表)、手机号脱敏、初始密码自动生成及规则设置 - 角色管理:卡片列表、系统角色保护、CRUD - 通用组件:FilterSelect 下拉、MonthPicker 月份选择器、Pagination 分页 - 样式统一:状态标签加 border、日期输入现代化、筛选组件风格一致 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/admin/members/page.tsx | 409 +++++++++++++++ apps/web/app/admin/roles/page.tsx | 113 ++++ apps/web/app/globals.css | 35 ++ apps/web/app/overtime/page.tsx | 307 +++++++++++ apps/web/app/products/[id]/page.tsx | 41 +- apps/web/app/products/page.tsx | 4 +- apps/web/app/projects/[id]/page.tsx | 95 +--- apps/web/app/projects/page.tsx | 14 +- apps/web/app/requirements/page.tsx | 489 ++++++++++++++++++ apps/web/app/versions/[id]/page.tsx | 248 +++++++++ apps/web/app/versions/page.tsx | 433 +++++++++++++--- apps/web/components/FilterSelect.tsx | 71 +++ apps/web/components/MonthPicker.tsx | 119 +++++ apps/web/components/Pagination.tsx | 95 ++++ apps/web/components/layout/Sidebar.tsx | 9 +- apps/web/components/product/ProductCard.tsx | 4 +- .../components/product/RequirementForm.tsx | 2 +- .../product/RequirementStatusBadge.tsx | 18 +- .../components/product/RequirementTable.tsx | 25 +- .../web/components/requirement/DictDrawer.tsx | 164 ++++++ .../requirement/RequirementDetail.tsx | 160 ++++++ .../requirement/RequirementModal.tsx | 412 +++++++++++++++ apps/web/components/version/CapsuleStages.tsx | 69 +++ apps/web/components/version/HealthTrend.tsx | 79 +++ apps/web/components/version/MemberChips.tsx | 23 + apps/web/lib/derive.ts | 23 +- apps/web/lib/health.ts | 196 +++++++ apps/web/lib/members.ts | 65 +++ apps/web/lib/overtime.ts | 46 ++ apps/web/lib/requirement.ts | 103 ++++ apps/web/lib/risk.ts | 95 ++++ apps/web/lib/version-status.ts | 8 +- apps/web/stores/useMemberStore.ts | 135 +++++ apps/web/stores/useOvertimeStore.ts | 116 +++++ apps/web/stores/useProductStore.ts | 13 +- apps/web/stores/useRequirementStore.ts | 389 ++++++++++++-- 36 files changed, 4355 insertions(+), 272 deletions(-) create mode 100644 apps/web/app/admin/members/page.tsx create mode 100644 apps/web/app/admin/roles/page.tsx create mode 100644 apps/web/app/overtime/page.tsx create mode 100644 apps/web/app/requirements/page.tsx create mode 100644 apps/web/app/versions/[id]/page.tsx create mode 100644 apps/web/components/FilterSelect.tsx create mode 100644 apps/web/components/MonthPicker.tsx create mode 100644 apps/web/components/Pagination.tsx create mode 100644 apps/web/components/requirement/DictDrawer.tsx create mode 100644 apps/web/components/requirement/RequirementDetail.tsx create mode 100644 apps/web/components/requirement/RequirementModal.tsx create mode 100644 apps/web/components/version/CapsuleStages.tsx create mode 100644 apps/web/components/version/HealthTrend.tsx create mode 100644 apps/web/components/version/MemberChips.tsx create mode 100644 apps/web/lib/health.ts create mode 100644 apps/web/lib/members.ts create mode 100644 apps/web/lib/overtime.ts create mode 100644 apps/web/lib/requirement.ts create mode 100644 apps/web/lib/risk.ts create mode 100644 apps/web/stores/useMemberStore.ts create mode 100644 apps/web/stores/useOvertimeStore.ts diff --git a/apps/web/app/admin/members/page.tsx b/apps/web/app/admin/members/page.tsx new file mode 100644 index 0000000..6077bac --- /dev/null +++ b/apps/web/app/admin/members/page.tsx @@ -0,0 +1,409 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react'; +import { useMemberStore } from '@/stores/useMemberStore'; +import { maskPhone, generatePassword } from '@/lib/members'; +import type { Member, Department, PasswordRule } from '@/lib/members'; + +export default function MembersPage() { + const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore(); + const [activeDeptId, setActiveDeptId] = useState(null); + const [showMemberModal, setShowMemberModal] = useState(false); + const [editingMember, setEditingMember] = useState(null); + const [showDeptModal, setShowDeptModal] = useState(false); + const [editingDept, setEditingDept] = useState(null); + const [expandedDepts, setExpandedDepts] = useState>(new Set(['dept-2'])); + const [showPwdRuleModal, setShowPwdRuleModal] = useState(false); + + useEffect(() => { fetchMembers(); }, [fetchMembers]); + + const topDepts = useMemo(() => departments.filter((d) => !d.parentId).sort((a, b) => a.order - b.order), [departments]); + const childDepts = (parentId: string) => departments.filter((d) => d.parentId === parentId).sort((a, b) => a.order - b.order); + + const filteredMembers = useMemo(() => { + if (!activeDeptId) return members; + const deptIds = [activeDeptId, ...departments.filter((d) => d.parentId === activeDeptId).map((d) => d.id)]; + return members.filter((m) => deptIds.includes(m.departmentId)); + }, [members, activeDeptId, departments]); + + const activeDeptName = activeDeptId ? departments.find((d) => d.id === activeDeptId)?.name ?? '全部' : '全部成员'; + const roleName = (roleId: string) => roles.find((r) => r.id === roleId)?.name ?? '-'; + + const toggleExpand = (id: string) => { + setExpandedDepts((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + return ( +
+ {/* Left: Departments */} +
+
+ 部门 + +
+
+ {/* All */} + + {/* Tree */} + {topDepts.map((dept) => { + const children = childDepts(dept.id); + const expanded = expandedDepts.has(dept.id); + const count = members.filter((m) => m.departmentId === dept.id || children.some((c) => c.id === m.departmentId)).length; + return ( +
+
+ {children.length > 0 && ( + + )} + {children.length === 0 && } + setActiveDeptId(dept.id)} className="flex-1">{dept.name} + {count} +
+ + +
+
+ {expanded && children.map((child) => { + const childCount = members.filter((m) => m.departmentId === child.id).length; + return ( +
setActiveDeptId(child.id)} + className={`group flex items-center pl-9 pr-4 py-2 text-[12px] cursor-pointer transition-colors ${activeDeptId === child.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`} + > + {child.name} + {childCount} +
+ + +
+
+ ); + })} +
+ ); + })} +
+
+ + {/* Right: Member list */} +
+
+
+

{activeDeptName}

+ {filteredMembers.length} +
+
+ + +
+
+ +
+ {filteredMembers.length === 0 ? ( +
+

暂无成员

+
+ ) : ( +
+ + + + + + + + + + + + + {filteredMembers.map((m) => ( + + + + + + + + + ))} + +
姓名角色部门手机号企业邮箱操作
{m.name} + {roleName(m.roleId)} + {departments.find((d) => d.id === m.departmentId)?.name ?? '-'}{maskPhone(m.phone)}{m.email} +
+ + +
+
+
+ )} +
+
+ + {/* Member Modal */} + {showMemberModal && ( + setShowMemberModal(false)} + onSubmit={(data) => { + if (editingMember) updateMember(editingMember.id, data); + else createMember(data as any); + setShowMemberModal(false); + }} + /> + )} + + {/* Department Modal */} + {showDeptModal && ( + setShowDeptModal(false)} + onSubmit={(data) => { + if (editingDept) updateDepartment(editingDept.id, data); + else createDepartment(data as any); + setShowDeptModal(false); + }} + /> + )} + + {/* Password Rule Modal */} + {showPwdRuleModal && ( + setShowPwdRuleModal(false)} + onSave={(rule) => { updatePasswordRule(rule); setShowPwdRuleModal(false); }} + /> + )} +
+ ); +} + +function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule, onClose, onSubmit }: { + initial: Member | null; + departments: Department[]; + roles: { id: string; name: string }[]; + defaultDeptId: string | null; + passwordRule: PasswordRule; + onClose: () => void; + onSubmit: (data: any) => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [departmentId, setDepartmentId] = useState(initial?.departmentId ?? defaultDeptId ?? ''); + const [roleId, setRoleId] = useState(initial?.roleId ?? ''); + const [phone, setPhone] = useState(initial?.phone ?? ''); + const [email, setEmail] = useState(initial?.email ?? ''); + const [password] = useState(() => initial?.password ?? generatePassword(passwordRule)); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !departmentId || !roleId) return; + onSubmit({ name: name.trim(), departmentId, roleId, phone: phone.trim(), email: email.trim(), password }); + }; + + return ( +
+
e.stopPropagation()}> +
+

{initial ? '编辑成员' : '新建成员'}

+ +
+
+
+ + setName(e.target.value)} required placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+
+ + +
+
+ + +
+
+
+ + setPhone(e.target.value)} placeholder="11位手机号" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + setEmail(e.target.value)} placeholder="name@company.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ +
+ {password} + {!initial && ( + 自动生成 + )} +
+
+
+ + +
+
+
+
+ ); +} + +function DeptModal({ initial, departments, onClose, onSubmit }: { + initial: Department | null; + departments: Department[]; + onClose: () => void; + onSubmit: (data: any) => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [parentId, setParentId] = useState(initial?.parentId ?? ''); + + const topDepts = departments.filter((d) => !d.parentId); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + onSubmit({ name: name.trim(), parentId: parentId || undefined, order: departments.length + 1 }); + }; + + return ( +
+
e.stopPropagation()}> +
+

{initial ? '编辑部门' : '新建部门'}

+ +
+
+
+ + setName(e.target.value)} required placeholder="请输入部门名称" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +function PasswordRuleModal({ rule, onClose, onSave }: { + rule: PasswordRule; + onClose: () => void; + onSave: (rule: PasswordRule) => void; +}) { + const [prefix, setPrefix] = useState(rule.prefix); + const [length, setLength] = useState(rule.length); + const [includeUppercase, setIncludeUppercase] = useState(rule.includeUppercase); + const [includeLowercase, setIncludeLowercase] = useState(rule.includeLowercase); + const [includeNumbers, setIncludeNumbers] = useState(rule.includeNumbers); + const [includeSpecial, setIncludeSpecial] = useState(rule.includeSpecial); + + const currentRule: PasswordRule = { prefix, length, includeUppercase, includeLowercase, includeNumbers, includeSpecial }; + const preview = generatePassword(currentRule); + + return ( +
+
e.stopPropagation()}> +
+

初始密码规则设置

+ +
+
+
+
+ + setPrefix(e.target.value)} placeholder="如 Ftb" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + setLength(Number(e.target.value))} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+
+ +
+ + + + +
+
+
+ +
+ {preview} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/roles/page.tsx b/apps/web/app/admin/roles/page.tsx new file mode 100644 index 0000000..b3420f3 --- /dev/null +++ b/apps/web/app/admin/roles/page.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Plus, Pencil, Trash2, X, Shield } from 'lucide-react'; +import { useMemberStore } from '@/stores/useMemberStore'; +import type { RoleItem } from '@/lib/members'; + +export default function RolesPage() { + const { roles, fetchMembers, createRole, updateRole, deleteRole } = useMemberStore(); + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + + useEffect(() => { fetchMembers(); }, [fetchMembers]); + + return ( +
+
+
+

角色管理

+ {roles.length} +
+ +
+ +
+
+ {roles.map((role) => ( +
+
+
+ +
+
+
+ {role.name} + {role.isSystem && ( + 系统 + )} +
+ {role.description && ( +

{role.description}

+ )} +
+
+ {!role.isSystem && ( +
+ + +
+ )} +
+ ))} +
+
+ + {/* Modal */} + {showModal && ( + setShowModal(false)} + onSubmit={(data) => { + if (editing) updateRole(editing.id, data); + else createRole(data); + setShowModal(false); + }} + /> + )} +
+ ); +} + +function RoleModal({ initial, onClose, onSubmit }: { + initial: RoleItem | null; + onClose: () => void; + onSubmit: (data: { name: string; description?: string }) => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [description, setDescription] = useState(initial?.description ?? ''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + onSubmit({ name: name.trim(), description: description.trim() || undefined }); + }; + + return ( +
+
e.stopPropagation()}> +
+

{initial ? '编辑角色' : '新建角色'}

+ +
+
+
+ + setName(e.target.value)} required placeholder="如:项目经理" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + setDescription(e.target.value)} placeholder="可选" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 22b04f4..f54a9aa 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -44,4 +44,39 @@ html, body { font-family: 'JetBrains Mono', monospace; } +/* Modern date/time input styling */ +input[type="date"], +input[type="datetime-local"], +input[type="month"], +input[type="time"] { + appearance: none; + -webkit-appearance: none; + position: relative; + font-family: 'Inter', system-ui, sans-serif; + color: var(--ink); +} + +input[type="date"]::-webkit-calendar-picker-indicator, +input[type="datetime-local"]::-webkit-calendar-picker-indicator, +input[type="month"]::-webkit-calendar-picker-indicator, +input[type="time"]::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; + padding: 2px; + border-radius: 4px; + transition: opacity 0.15s, background 0.15s; +} + +input[type="date"]::-webkit-calendar-picker-indicator:hover, +input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover, +input[type="month"]::-webkit-calendar-picker-indicator:hover, +input[type="time"]::-webkit-calendar-picker-indicator:hover { + opacity: 1; + background: var(--bg-subtle); +} + +input[type="date"]::-webkit-inner-spin-button, +input[type="datetime-local"]::-webkit-inner-spin-button { + display: none; +} diff --git a/apps/web/app/overtime/page.tsx b/apps/web/app/overtime/page.tsx new file mode 100644 index 0000000..ec4d8c4 --- /dev/null +++ b/apps/web/app/overtime/page.tsx @@ -0,0 +1,307 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react'; +import { useOvertimeStore } from '@/stores/useOvertimeStore'; +import { useProductStore } from '@/stores/useProductStore'; +import { flattenProjects, flattenVersions } from '@/lib/derive'; +import { calcDuration } from '@/lib/overtime'; +import type { OvertimeRecord } from '@/lib/overtime'; +import { Pagination, usePagination } from '@/components/Pagination'; +import { DictDrawer } from '@/components/requirement/DictDrawer'; +import { MonthPicker } from '@/components/MonthPicker'; +import { FilterSelect } from '@/components/FilterSelect'; + +export default function OvertimePage() { + const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); + const { overview, fetchOverview } = useProductStore(); + const allProjects = useMemo(() => flattenProjects(overview), [overview]); + const allVersions = useMemo(() => flattenVersions(overview), [overview]); + + const [search, setSearch] = useState(''); + const [projectFilter, setProjectFilter] = useState('all'); + const [reasonFilter, setReasonFilter] = useState('all'); + const [monthFilter, setMonthFilter] = useState(''); + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [showReasonDrawer, setShowReasonDrawer] = useState(false); + + useEffect(() => { fetchOverview(); }, [fetchOverview]); + useEffect(() => { fetchRecords(); }, [fetchRecords]); + + const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-'; + const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-'; + const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-'; + + const filtered = useMemo(() => { + let list = [...records]; + if (search) list = list.filter((r) => r.person.includes(search)); + if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter); + if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter); + if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter); + list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); + return list; + }, [records, search, projectFilter, reasonFilter, monthFilter]); + + const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); + + const handleCreate = () => { setEditing(null); setShowModal(true); }; + const handleEdit = (r: OvertimeRecord) => { setEditing(r); setShowModal(true); }; + + const handleExport = () => { + const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注']; + const rows = filtered.map((r) => [ + projectName(r.projectId), + versionName(r.versionId), + r.person, + r.startTime.replace('T', ' '), + r.endTime.replace('T', ' '), + String(r.duration), + reasonName(r.reasonId), + r.remark || '', + ]); + const bom = ''; + const csv = [header.join(','), ...rows.map((row) => row.map((c) => `"${c.replace(/"/g, '""')}"`).join(','))].join('\r\n'); + const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `加班记录${monthFilter || '_全部'}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+
+

加班记录

+ {records.length} +
+
+ + + +
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} placeholder="搜索人员" className="h-8 w-48 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" /> +
+ ({ value: p.id, label: p.name }))} + placeholder="全部项目" + allLabel="全部项目" + /> + ({ value: r.id, label: r.name }))} + placeholder="全部原因" + allLabel="全部原因" + /> + +
+ + {/* Table */} +
+ {filtered.length === 0 ? ( +
+

暂无加班记录

+
+ ) : ( + <> +
+ + + + + + + + + + + + + + + + {paged.map((r) => ( + + + + + + + + + + + + ))} + +
项目版本加班人开始时间结束时间时长加班原因备注操作
{projectName(r.projectId)}{versionName(r.versionId)}{r.person}{r.startTime.replace('T', ' ')}{r.endTime.replace('T', ' ')} + = 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}> + {r.duration}h + + + + {reasonName(r.reasonId)} + + {r.remark || '-'} +
+ + +
+
+
+ + + )} +
+ + {/* Modal */} + {showModal && ( + ({ id: p.id, name: p.name }))} + projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))} + versions={allVersions} + reasons={reasons} + onClose={() => setShowModal(false)} + onSubmit={(data) => { + if (editing) updateRecord(editing.id, data); + else createRecord(data as any); + setShowModal(false); + }} + /> + )} + + {/* Reason Drawer */} + {showReasonDrawer && ( + setShowReasonDrawer(false)} onAdd={addReason} onUpdate={updateReason} onDelete={deleteReason} /> + )} +
+ ); +} + +function OvertimeModal({ initial, products, projects, versions, reasons, onClose, onSubmit }: { + initial: OvertimeRecord | null; + products: { id: string; name: string }[]; + projects: { id: string; name: string; productId: string }[]; + versions: { id: string; name: string; projectId?: string }[]; + reasons: { id: string; name: string }[]; + onClose: () => void; + onSubmit: (data: any) => void; +}) { + const [productId, setProductId] = useState(''); + const [projectId, setProjectId] = useState(initial?.projectId ?? ''); + const [versionId, setVersionId] = useState(initial?.versionId ?? ''); + const [person, setPerson] = useState(initial?.person ?? ''); + const [startTime, setStartTime] = useState(initial?.startTime ?? ''); + const [endTime, setEndTime] = useState(initial?.endTime ?? ''); + const [reasonId, setReasonId] = useState(initial?.reasonId ?? ''); + const [remark, setRemark] = useState(initial?.remark ?? ''); + + // 初始编辑时反推 productId + useEffect(() => { + if (initial?.projectId) { + const proj = projects.find((p) => p.id === initial.projectId); + if (proj) setProductId(proj.productId); + } + }, [initial, projects]); + + const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0; + const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects; + const filteredVersions = projectId ? versions.filter((v) => (v as any).projectId === projectId) : []; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return; + onSubmit({ projectId, versionId: versionId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined }); + }; + + return ( +
+
e.stopPropagation()}> +
+

{initial ? '编辑加班记录' : '新建加班记录'}

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + setPerson(e.target.value)} required placeholder="姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+
+ + setStartTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ + setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" /> +
+
+ {duration > 0 && ( +
+ 时长:= 4 ? 'text-red-600' : duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>{duration} 小时 +
+ )} +
+ + +
+
+ +