diff --git a/apps/web/app/overtime/page.tsx b/apps/web/app/overtime/page.tsx index acca731..d27ca89 100644 --- a/apps/web/app/overtime/page.tsx +++ b/apps/web/app/overtime/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { Search, Plus, X, Download } from 'lucide-react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Search, Plus, X, Download, FolderOpen, Building2 } from 'lucide-react'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -17,6 +17,8 @@ import { FilterSelect } from '@/components/FilterSelect'; import { FieldError } from '@/components/FieldError'; import { RouteGuard } from '@/components/auth/Guard'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; +import { isMemberReference } from '@/lib/member-system'; +import type { Department } from '@/lib/members'; export default function OvertimePage() { return ( @@ -40,6 +42,7 @@ function OvertimePageContent() { const [projectFilter, setProjectFilter] = useState('all'); const [reasonFilter, setReasonFilter] = useState('all'); const [monthFilter, setMonthFilter] = useState(''); + const [departmentFilter, setDepartmentFilter] = useState('all'); const [showModal, setShowModal] = useState(false); const [showReasonDrawer, setShowReasonDrawer] = useState(false); @@ -58,7 +61,18 @@ function OvertimePageContent() { departments, }), [records, user, viewerRole, members, departments]); - const filtered = useMemo(() => { + const departmentRows = useMemo(() => buildDepartmentRows(departments), [departments]); + + const getRecordDepartmentId = useMemo(() => { + return (record: OvertimeRecord) => { + const member = members.find((item) => isMemberReference(record.person, item)); + if (member) return member.departmentId; + if (user && isMemberReference(record.person, user)) return user.departmentId; + return 'unknown'; + }; + }, [members, user]); + + const baseFiltered = useMemo(() => { let list = [...visibleRecords]; if (search) list = list.filter((r) => r.person.includes(search)); if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter); @@ -68,6 +82,51 @@ function OvertimePageContent() { return list; }, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]); + const departmentStats = useMemo(() => { + const map = new Map(); + for (const dept of departments) map.set(dept.id, { count: 0, hours: 0 }); + map.set('unknown', { count: 0, hours: 0 }); + + for (const record of baseFiltered) { + const deptId = getRecordDepartmentId(record); + const current = map.get(deptId) ?? { count: 0, hours: 0 }; + current.count += 1; + current.hours += record.duration; + map.set(deptId, current); + } + + for (const dept of departments) { + const childIds = collectDepartmentTreeIds(departments, dept.id); + const total = { count: 0, hours: 0 }; + for (const id of childIds) { + const stat = map.get(id); + if (!stat) continue; + total.count += stat.count; + total.hours += stat.hours; + } + map.set(`${dept.id}:tree`, total); + } + + return map; + }, [baseFiltered, departments, getRecordDepartmentId]); + + const filtered = useMemo(() => { + if (departmentFilter === 'all') return baseFiltered; + if (departmentFilter === 'unknown') { + return baseFiltered.filter((record) => getRecordDepartmentId(record) === 'unknown'); + } + const deptIds = collectDepartmentTreeIds(departments, departmentFilter); + return baseFiltered.filter((record) => deptIds.has(getRecordDepartmentId(record))); + }, [baseFiltered, departmentFilter, departments, getRecordDepartmentId]); + + const selectedDepartmentName = departmentFilter === 'all' + ? '全部部门' + : departmentFilter === 'unknown' + ? '未匹配部门' + : departments.find((dept) => dept.id === departmentFilter)?.name ?? '部门'; + const totalBaseHours = Math.round(baseFiltered.reduce((sum, record) => sum + record.duration, 0) * 10) / 10; + const selectedHours = Math.round(filtered.reduce((sum, record) => sum + record.duration, 0) * 10) / 10; + const unknownStats = departmentStats.get('unknown') ?? { count: 0, hours: 0 }; const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); const handleCreate = () => { setShowModal(true); }; @@ -140,63 +199,121 @@ function OvertimePageContent() { - {/* 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 || '-'}{r.createdAt} -
- -
-
+
+ + +
+
+
+
+

{selectedDepartmentName}

+ {filtered.length} +
+

合计 {selectedHours}h

+
+
+ +
+ {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 || '-'}{r.createdAt} +
+ +
+
+
+
+ + + )} +
+
{/* Modal */} @@ -224,6 +341,71 @@ function OvertimePageContent() { ); } +function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set { + const ids = new Set([departmentId]); + let changed = true; + while (changed) { + changed = false; + for (const department of departments) { + if (department.parentId && ids.has(department.parentId) && !ids.has(department.id)) { + ids.add(department.id); + changed = true; + } + } + } + return ids; +} + +function buildDepartmentRows(departments: Department[]): Array<{ department: Department; depth: number }> { + const rows: Array<{ department: Department; depth: number }> = []; + const childrenByParent = new Map(); + for (const department of departments) { + const key = department.parentId ?? ''; + const children = childrenByParent.get(key) ?? []; + children.push(department); + childrenByParent.set(key, children); + } + + const append = (parentId: string, depth: number) => { + const children = [...(childrenByParent.get(parentId) ?? [])].sort((a, b) => a.order - b.order); + for (const child of children) { + rows.push({ department: child, depth }); + append(child.id, depth + 1); + } + }; + + append('', 0); + return rows; +} + +function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClick }: { + active: boolean; + label: string; + count: number; + hours: number; + depth?: number; + icon?: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: { defaultPerson: string; products: { id: string; name: string }[]; diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index b86f9a8..68c8a42 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -19,6 +19,8 @@ import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateH import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan'; +import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview'; +import { formatActualDuration } from '@/lib/work-hours'; import type { DevTask } from '@/lib/dev-task'; import type { TestCase } from '@/lib/test-case'; import type { Bug } from '@/lib/bug'; @@ -81,9 +83,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ requirements: { id: string; versionId?: string }[]; onNavigate: (id: string) => void; }) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status)); + + useEffect(() => { + setExpanded(getVersionCardDefaultExpanded(version.status)); + }, [version.status]); - // 与版本详情一致:取所有阶段最早的实际开始 const versionData = useMemo(() => { const vPlans = plans.filter((p) => p.versionId === version.id); const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id)); @@ -91,41 +96,31 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ const vTCs = testCases.filter((c) => c.versionId === version.id); const vBugs = bugs.filter((b) => b.versionId === version.id); - const startDates: string[] = []; - vPlans.forEach((p) => { - if (p.actualStartAt) startDates.push(p.actualStartAt); - else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime); - }); - vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); }); - vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); + return { vPlans, vDevTasks, vTCs, vBugs }; + }, [version.id, plans, devTasks, testCases, bugs, requirements]); - const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate; - const actualStartDisplay = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null); + const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({ + plans: versionData.vPlans, + devTasks: versionData.vDevTasks, + testCases: versionData.vTCs, + bugs: versionData.vBugs, + }), [versionData]); - // 实际截止:取所有阶段最晚完成 - const endDates: string[] = []; - vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); - vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); }); - vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); - vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); - const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null; - - let totalDays = 0; - if (earliestStart) { - const start = new Date(earliestStart); - start.setHours(0, 0, 0, 0); - const end = version.status === 'released' && version.releaseDate ? new Date(version.releaseDate) : new Date(); - end.setHours(0, 0, 0, 0); - totalDays = Math.max(0, Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))); - } - - return { vPlans, vDevTasks, vTCs, vBugs, totalDays, actualStart: actualStartDisplay, actualEnd: actualEndDisplay }; - }, [version, plans, devTasks, testCases, bugs, requirements]); + const timelineSummary = useMemo(() => buildVersionTimelineSummary({ + status: version.status, + startDate: version.startDate, + expectedReleaseDate: version.expectedReleaseDate, + releaseDate: version.releaseDate, + plans: versionData.vPlans, + devTasks: versionData.vDevTasks, + testCases: versionData.vTCs, + bugs: versionData.vBugs, + }), [version, versionData]); // 状态胶囊数据 — 与版本详情一致 const stageProgress = useMemo(() => { const { vPlans, vDevTasks, vTCs, vBugs } = versionData; - const sp: any = {}; + const sp: Partial> = {}; const calcGroupProgress = (group: VersionPlan[], type: 'research' | 'product' | 'ui') => { if (group.length === 0) return 0; @@ -184,10 +179,8 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ const allClosed = vBugs.every((b) => b.status === 'closed' || b.status === 'rejected'); sp['bug'] = { percent: bp, status: allClosed ? 'done' : closedBugs > 0 || vBugs.length > 0 ? 'active' : 'idle' }; } - return sp; - }, [versionData]); - - const totalDays = versionData.totalDays; + return mergeStageProgressWithEffort(sp, stageEffortMetrics); + }, [versionData, stageEffortMetrics]); const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中'; const displayBg = VERSION_STATUS_BG[version.status] ?? 'bg-blue-500/10 text-blue-600'; @@ -204,7 +197,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ ); } - if (version.status === 'released') { + if (!getVersionCardDefaultExpanded(version.status)) { return (
@@ -249,18 +242,14 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
- {versionData.actualStart ?? version.startDate ?? '-'} + {timelineSummary.actualStartIso ? formatVersionOverviewDateTime(timelineSummary.actualStartIso) : '未开始'} - 预计 {version.expectedReleaseDate ?? '-'} - {versionData.actualEnd && ( - <> - | - 实际 {versionData.actualEnd} - - )} + 预计 {timelineSummary.expectedReleaseIso ? formatVersionOverviewDateTime(timelineSummary.expectedReleaseIso) : '未设置'} + | + 实际 {timelineSummary.isTerminalVersion ? (timelineSummary.actualEndIso ? formatVersionOverviewDateTime(timelineSummary.actualEndIso) : '未记录') : '未完成'} - 已耗时 {totalDays} 天 + 已耗时 {formatActualDuration(timelineSummary.actualHours)}
diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx index 38ad358..1b8409c 100644 --- a/apps/web/app/projects/page.tsx +++ b/apps/web/app/projects/page.tsx @@ -120,7 +120,7 @@ function ProjectsPageContent() { ) : (
-
+
{paged.map((proj) => ( @@ -211,8 +211,8 @@ function ProjectRow({ {menuOpen && ( <> -
setMenuOpen(false)} /> -
+
setMenuOpen(false)} /> +
- -
+ {!versionReadonly && ( +
+ + +
+ )}
{(() => { const membersList = version.members ?? []; @@ -840,11 +845,17 @@ export default function VersionDetailPage() { devTasks={devTasks} versionMembers={version.members ?? []} currentUserName={user?.name ?? ''} + readOnly={versionReadonly} onLink={(ids, addedBy) => { + if (versionReadonly) return; ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy })); }} - onUnlink={(id) => updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined })} + onUnlink={(id) => { + if (versionReadonly) return; + updateRequirement(id, { versionId: undefined, addedToVersionBy: undefined }); + }} onCreateChange={(data) => { + if (versionReadonly) return; createRequirement({ ...data, productId: version.productId, @@ -878,7 +889,9 @@ export default function VersionDetailPage() { versionMembers={version.members ?? []} linkedRequirements={projectAdoptedReqs} allRequirements={requirements} + readOnly={versionReadonly} onCreate={(data) => { + if (versionReadonly) return; createPlan(data); if ((pt === 'product') && data.linkedRequirementIds?.length) { data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner })); @@ -891,13 +904,20 @@ export default function VersionDetailPage() { } }} onUpdate={(id, data) => { + if (versionReadonly) return; updatePlan(id, data); if ((pt === 'product') && data.linkedRequirementIds && data.owner) { data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner })); } }} - onComplete={completePlan} - onDelete={deletePlan} + onComplete={(id, result) => { + if (versionReadonly) return; + return completePlan(id, result); + }} + onDelete={(id) => { + if (versionReadonly) return; + deletePlan(id); + }} /> ); })() @@ -909,18 +929,19 @@ export default function VersionDetailPage() { versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} versionDeadline={version.expectedReleaseDate ?? undefined} + readOnly={versionReadonly} /> ); })() ) : activeTab === 'testcases' ? ( (() => { const versionReqs = requirements.filter((r) => r.versionId === version.id); - return r.id)} />; + return r.id)} readOnly={versionReadonly} />; })() ) : activeTab === 'bugs' ? ( (() => { const versionReqs = requirements.filter((r) => r.versionId === version.id); - return r.id)} />; + return r.id)} readOnly={versionReadonly} />; })() ) : (
@@ -929,7 +950,7 @@ export default function VersionDetailPage() { )}
- {showRecommendModal && ( + {showRecommendModal && !versionReadonly && ( )} - {showReleaseModal && ( + {showReleaseModal && !versionReadonly && ( }>; const TABS: { key: TabKey; label: string; icon: any }[] = [ { key: 'all', label: '全部', icon: ClipboardList }, @@ -77,8 +81,8 @@ export default function WorkspacePage() { const allVersions = useMemo(() => flattenVersions(overview), [overview]); const versionMap = useMemo(() => { - const map = new Map(); - allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName })); + const map = new Map(); + allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status })); return map; }, [allVersions]); @@ -96,7 +100,7 @@ export default function WorkspacePage() { // 构建树:只显示跟自己有关的产品/项目/版本 const tree = useMemo(() => { const myVersionIds = new Set(workItems.map((i) => i.versionId).filter(Boolean)); - const productMap = new Map }>(); + const productMap: ProductTree = new Map(); allVersions.forEach((v) => { if (!myVersionIds.has(v.id)) return; @@ -106,7 +110,7 @@ export default function WorkspacePage() { const proj = prod.projects.get(v.projectName)!; const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length; if (!proj.versions.find((ver) => ver.id === v.id)) { - proj.versions.push({ id: v.id, name: v.name, pendingCount: pending }); + proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending }); } }); @@ -150,6 +154,9 @@ export default function WorkspacePage() { const pendingCount = pendingByTab.all; const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length; + const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined; + const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined; + const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false; return (
@@ -216,9 +223,10 @@ export default function WorkspacePage() {

{TABS.find((t) => t.key === activeTab)?.label}

{filteredItems.length} 项 - {selectedVersionId && ( - - {versionMap.get(selectedVersionId)?.name} + {selectedVersion && ( + + {selectedVersion.name} + )}
@@ -234,6 +242,7 @@ export default function WorkspacePage() { item.versionId && router.push(`/versions/${item.versionId}`)} onClick={() => setDrawerItem(item)} /> @@ -247,16 +256,16 @@ export default function WorkspacePage() { {/* Detail Drawers */} {drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && ( - setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> + setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> )} {drawerItem && drawerItem.type === 'devTask' && ( - t.id)} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> + t.id)} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> )} {drawerItem && drawerItem.type === 'testCase' && ( - setDrawerItem(null)} onCreateBug={(tcId) => { setDrawerItem(null); setBugFromTestCaseId(tcId); }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> + setDrawerItem(null)} onCreateBug={(tcId) => { if (!drawerReadOnly) { setDrawerItem(null); setBugFromTestCaseId(tcId); } }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> )} {drawerItem && drawerItem.type === 'bug' && ( - setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> + setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} /> )} {bugFromTestCaseId && ( setBugFromTestCaseId(null)} /> @@ -267,7 +276,7 @@ export default function WorkspacePage() { function ProductNode({ name, prod, selectedVersionId, onSelect }: { name: string; - prod: { name: string; projects: Map }; + prod: { name: string; projects: Map }; selectedVersionId: string | null; onSelect: (id: string | null) => void; }) { @@ -287,7 +296,7 @@ function ProductNode({ name, prod, selectedVersionId, onSelect }: { function ProjectNode({ name, versions, selectedVersionId, onSelect }: { name: string; - versions: { id: string; name: string; pendingCount: number }[]; + versions: TreeVersion[]; selectedVersionId: string | null; onSelect: (id: string | null) => void; }) { @@ -306,6 +315,7 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: { className={`w-full flex items-center gap-1.5 ml-5 px-2 py-1 rounded text-[11px] transition-colors ${selectedVersionId === v.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`} > {v.name} + {v.pendingCount > 0 && ( {v.pendingCount} )} @@ -315,7 +325,27 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: { ); } -function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) { +function VersionStatusTag({ status, readonlyOnly = false }: { status?: VersionStatus; readonlyOnly?: boolean }) { + if (!status) return null; + const readonlyNotice = getVersionReadonlyNotice(status); + if (readonlyOnly && !readonlyNotice) return null; + + return ( + + {VERSION_STATUS_LABEL[status]} + + ); +} + +function WorkItemCard({ item, versionStatus, onNavigate, onClick }: { + item: WorkItem; + versionStatus?: VersionStatus; + onNavigate: () => void; + onClick: () => void; +}) { const statusBadge = getStatusBadge(item); const isDevTask = item.type === 'devTask'; const devTaskRaw = isDevTask ? (item.raw as any) : null; @@ -358,6 +388,7 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat {item.projectName} / + {isDevTask && devTimeRange && ( <> {devTimeRange} diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 04e4fb0..51cb798 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -9,7 +9,7 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useAuthStore } from '@/stores/useAuthStore'; import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore'; import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; -import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; +import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion'; import { @@ -55,6 +55,7 @@ function XiaobaoWarningContent() { snapshots, insights, pendingInsightKeys, + insightRequestAttempts, riskDataLoaded, saveSnapshot, saveInsight, @@ -90,9 +91,15 @@ function XiaobaoWarningContent() { useEffect(() => { risks.forEach((risk) => { const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today); - if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return; - const signature = buildRiskInsightSignature(risk); const key = buildXiaobaoRiskInsightPendingKey(risk); + if (!shouldRequestRiskInsightWithRequestGate({ + riskCacheLoaded: riskDataLoaded, + cache: insights, + current: risk, + previous, + lastRequestedAt: insightRequestAttempts[key], + })) return; + const signature = buildRiskInsightSignature(risk); if (pendingInsightKeys.includes(key)) return; if (requestedInsightKeysRef.current.has(key)) return; requestedInsightKeysRef.current.add(key); @@ -114,6 +121,7 @@ function XiaobaoWarningContent() { beginInsightUpdate, finishInsightUpdate, insights, + insightRequestAttempts, pendingInsightKeys, riskDataLoaded, risks, diff --git a/apps/web/components/bug/BugDetailDrawer.tsx b/apps/web/components/bug/BugDetailDrawer.tsx index 034cbaa..bd0f6d3 100644 --- a/apps/web/components/bug/BugDetailDrawer.tsx +++ b/apps/web/components/bug/BugDetailDrawer.tsx @@ -27,9 +27,10 @@ interface Props { bugId: string; onClose: () => void; contextLabel?: string; + readOnly?: boolean; } -export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { +export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false }: Props) { const { bugs, changeStatus, transferBug } = useBugStore(); const { testCases } = useTestCaseStore(); const { requirements } = useRequirementStore(); @@ -73,16 +74,19 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { }, [bug.logs, bug.title, members]); const handleTransition = (to: BugStatus) => { + if (readOnly) return; if (to === 'fixed') { setShowResolutionInput(true); return; } changeStatus(bug.id, to, operator); }; const confirmFix = () => { + if (readOnly) return; changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined }); setShowResolutionInput(false); }; const handleTransfer = () => { + if (readOnly) return; if (!transferTo) return; transferBug(bug.id, transferTo, operator, transferRemark.trim() || undefined); setShowTransfer(false); @@ -135,7 +139,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { {bug.priority}
- {nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && ( + {nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && !readOnly && (
{nextStatuses.map((s) => ( @@ -150,11 +154,11 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) { )}
)} - {nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && ( + {nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && !readOnly && (
当前修复人为 {assigneeName},仅修复人可操作
)} - {showResolutionInput && ( + {showResolutionInput && !readOnly && (
setResolution(e.target.value)} placeholder="修复说明" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" autoFocus />
@@ -164,7 +168,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
)} - {showTransfer && ( + {showTransfer && !readOnly && (
转交给:
20 && } - {selectedBugId && setSelectedBugId(null)} />} + {selectedBugId && setSelectedBugId(null)} />}
); } diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 1bd1a4b..70b76f0 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -32,6 +32,7 @@ interface Props { allTaskIds: string[]; onClose: () => void; contextLabel?: string; + readOnly?: boolean; } function defaultPlanStartLocal(): string { @@ -46,7 +47,7 @@ function defaultPlanEndLocal(): string { return isoToLocal(d.toISOString()); } -export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }: Props) { +export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) { const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore(); const addProgressNote = useWorkActivityStore((s) => s.addProgressNote); const { categories } = useTaskCategoryStore(); @@ -93,12 +94,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0; const openPlanInput = () => { + if (readOnly) return; setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal()); setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal()); setShowPlanInput(true); }; const handleSavePlan = () => { + if (readOnly) return; const assigneeId = task.assigneeId || currentUserName; if (!assigneeId) { alert('领取前需要先登录或选择负责人'); @@ -115,6 +118,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }; const handleTransition = (to: DevTaskStatus) => { + if (readOnly) return; if (to === 'in_progress' && !startReady) { openPlanInput(); return; @@ -138,17 +142,20 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel }; const handleBlock = () => { + if (readOnly) return; if (!blockReason.trim()) return; setBlocked(task.id, true, blockReason.trim()); setShowBlockInput(false); }; const handleUnblock = () => { + if (readOnly) return; setBlocked(task.id, false); setBlockReason(''); }; const handleProgressNote = () => { + if (readOnly) return; const note = progressNote.trim(); const blocker = progressBlocker.trim(); const delayRisk = progressDelayRisk.trim(); @@ -184,15 +191,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel {task.title}
- {task.status !== 'submitted' && ( + {!readOnly && task.status !== 'submitted' && ( )} - + {!readOnly && }
- {showTransfer && ( + {showTransfer && !readOnly && (
转交给: - {visibleNextStatuses.length > 0 && !showDelayInput && ( + {visibleNextStatuses.length > 0 && !showDelayInput && !readOnly && (
{visibleNextStatuses.map((s) => ( @@ -250,7 +257,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
)} - {task.status === 'todo' && !startReady && !showPlanInput && ( + {task.status === 'todo' && !startReady && !showPlanInput && !readOnly && (
+ {!readOnly && }
) : ( - showBlockInput ? ( + showBlockInput && !readOnly ? (
setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleBlock(); }} placeholder="阻塞原因" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
) : ( - + !readOnly && ) )}
- {task.status !== 'todo' && task.status !== 'submitted' && ( + {task.status !== 'todo' && task.status !== 'submitted' && !readOnly && (
今日进展