feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -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<string, { count: number; hours: number }>();
|
||||
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() {
|
||||
<MonthPicker value={monthFilter} onChange={setMonthFilter} placeholder="全部月份" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)] 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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="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 bg-[var(--bg-subtle)]">
|
||||
<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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
|
||||
<td className="px-4 py-3 text-[var(--ink)]">{projectName(r.projectId)}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{versionName(r.versionId)}</td>
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{r.person}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.startTime.replace('T', ' ')}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.endTime.replace('T', ' ')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 font-medium tabular-nums ${r.duration >= 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>
|
||||
{r.duration}h
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
|
||||
{reasonName(r.reasonId)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-muted)]">{r.createdAt}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden bg-[var(--bg)]">
|
||||
<aside className="flex w-[240px] shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="border-b border-[var(--line)] px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-[var(--ink)]">
|
||||
<Building2 className="h-3.5 w-3.5 text-[var(--accent)]" strokeWidth={2} />
|
||||
部门
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
<div className="mt-1 text-[11px] tabular-nums text-[var(--ink-muted)]">{baseFiltered.length} 条 / {totalBaseHours}h</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
<DepartmentButton
|
||||
active={departmentFilter === 'all'}
|
||||
label="全部部门"
|
||||
count={baseFiltered.length}
|
||||
hours={totalBaseHours}
|
||||
icon={<FolderOpen className="h-3.5 w-3.5" strokeWidth={2} />}
|
||||
onClick={() => setDepartmentFilter('all')}
|
||||
/>
|
||||
{departmentRows.map(({ department, depth }) => {
|
||||
const stat = departmentStats.get(`${department.id}:tree`) ?? { count: 0, hours: 0 };
|
||||
return (
|
||||
<DepartmentButton
|
||||
key={department.id}
|
||||
active={departmentFilter === department.id}
|
||||
label={department.name}
|
||||
count={stat.count}
|
||||
hours={Math.round(stat.hours * 10) / 10}
|
||||
depth={depth}
|
||||
onClick={() => setDepartmentFilter(department.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{unknownStats.count > 0 && (
|
||||
<DepartmentButton
|
||||
active={departmentFilter === 'unknown'}
|
||||
label="未匹配部门"
|
||||
count={unknownStats.count}
|
||||
hours={Math.round(unknownStats.hours * 10) / 10}
|
||||
onClick={() => setDepartmentFilter('unknown')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-[14px] font-semibold text-[var(--ink)]">{selectedDepartmentName}</h2>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{filtered.length}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] tabular-nums text-[var(--ink-muted)]">合计 {selectedHours}h</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto 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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||
<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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[var(--line-soft)] last:border-0 transition-colors hover:bg-[var(--bg-subtle)]">
|
||||
<td className="px-4 py-3 text-[var(--ink)]">{projectName(r.projectId)}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{versionName(r.versionId)}</td>
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{r.person}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.startTime.replace('T', ' ')}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-soft)]">{r.endTime.replace('T', ' ')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 font-medium tabular-nums ${r.duration >= 4 ? 'text-red-600' : r.duration >= 2 ? 'text-orange-600' : 'text-[var(--ink)]'}`}>
|
||||
{r.duration}h
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded-md bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
|
||||
{reasonName(r.reasonId)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-muted)]">{r.createdAt}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -224,6 +341,71 @@ function OvertimePageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set<string> {
|
||||
const ids = new Set<string>([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<string, Department[]>();
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-left text-[12px] transition-colors ${
|
||||
active ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
|
||||
}`}
|
||||
style={{ paddingLeft: `${16 + depth * 16}px` }}
|
||||
>
|
||||
<span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-md ${active ? 'bg-white/70' : 'bg-[var(--bg-subtle)]'}`}>
|
||||
{icon ?? <Building2 className="h-3.5 w-3.5" strokeWidth={2} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-[var(--ink-muted)]">{count}</span>
|
||||
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-[var(--ink-muted)]">{hours}h</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
|
||||
defaultPerson: string;
|
||||
products: { id: string; name: string }[];
|
||||
|
||||
@@ -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<Record<Stage, { percent: number; status: 'idle' | 'active' | 'done' }>> = {};
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||
<button
|
||||
@@ -216,10 +209,10 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
|
||||
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
||||
{formatVersionOverviewDateTime(timelineSummary.actualStartIso)}
|
||||
<span className="mx-1">→</span>
|
||||
{versionData.actualEnd ?? version.releaseDate ?? '-'}
|
||||
<span className="ml-1">共 {totalDays} 天</span>
|
||||
{formatVersionOverviewDateTime(timelineSummary.isTerminalVersion ? timelineSummary.actualEndIso : timelineSummary.expectedReleaseIso)}
|
||||
<span className="ml-1">已耗时 {formatActualDuration(timelineSummary.actualHours)}</span>
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
@@ -249,18 +242,14 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
||||
{timelineSummary.actualStartIso ? formatVersionOverviewDateTime(timelineSummary.actualStartIso) : '未开始'}
|
||||
<span className="mx-1">→</span>
|
||||
预计 {version.expectedReleaseDate ?? '-'}
|
||||
{versionData.actualEnd && (
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
实际 {versionData.actualEnd}
|
||||
</>
|
||||
)}
|
||||
预计 {timelineSummary.expectedReleaseIso ? formatVersionOverviewDateTime(timelineSummary.expectedReleaseIso) : '未设置'}
|
||||
<span className="mx-1">|</span>
|
||||
实际 {timelineSummary.isTerminalVersion ? (timelineSummary.actualEndIso ? formatVersionOverviewDateTime(timelineSummary.actualEndIso) : '未记录') : '未完成'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />已耗时 {totalDays} 天
|
||||
<Clock className="h-3 w-3" />已耗时 {formatActualDuration(timelineSummary.actualHours)}
|
||||
</span>
|
||||
</div>
|
||||
<MemberChips members={version.members ?? []} />
|
||||
|
||||
@@ -120,7 +120,7 @@ function ProjectsPageContent() {
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<div className="overflow-visible rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
{paged.map((proj) => (
|
||||
<ProjectRow
|
||||
key={proj.id}
|
||||
@@ -177,7 +177,7 @@ function ProjectRow({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0 cursor-pointer"
|
||||
className={`relative w-full flex items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-[var(--bg-hover)] border-b border-[var(--line-soft)] last:border-b-0 cursor-pointer ${menuOpen ? 'z-40' : 'z-0'}`}
|
||||
>
|
||||
<FolderKanban size={20} className="shrink-0 text-[var(--ink-muted)]" />
|
||||
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
||||
@@ -211,8 +211,8 @@ function ProjectRow({
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<div className="fixed inset-0 z-30" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canActuallyDelete) return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/req
|
||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||
import { buildRequirementScopeTree, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||
@@ -184,7 +185,7 @@ function RequirementsPageContent() {
|
||||
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');
|
||||
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
@@ -268,11 +269,7 @@ function RequirementsPageContent() {
|
||||
list = list.filter((r) => r.versionId === versionFilter);
|
||||
}
|
||||
|
||||
// sort by createdAt
|
||||
list.sort((a, b) => {
|
||||
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
return dateSort === 'asc' ? diff : -diff;
|
||||
});
|
||||
list = sortRequirementsByCreatedAt(list, dateSort);
|
||||
|
||||
return list;
|
||||
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recomme
|
||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||
import { isVersionReadonly } from '@/lib/version-status';
|
||||
|
||||
function formatOverviewDateTime(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -131,6 +132,7 @@ export default function VersionDetailPage() {
|
||||
[version, plans, requirements, devTasks, testCases],
|
||||
);
|
||||
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
|
||||
const versionReadonly = version ? isVersionReadonly(version.status) : false;
|
||||
|
||||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||
useEffect(() => {
|
||||
@@ -208,6 +210,7 @@ export default function VersionDetailPage() {
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
|
||||
if (versionReadonly) return null;
|
||||
if (version.status !== 'released' && version.status !== 'closed') {
|
||||
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
|
||||
}
|
||||
@@ -499,18 +502,20 @@ export default function VersionDetailPage() {
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRecommendModal(true)}
|
||||
disabled={!recommendationDataReady}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-violet-600 hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />{recommendationDataReady ? 'AI推荐' : '加载中'}
|
||||
</button>
|
||||
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
|
||||
<Settings className="h-3 w-3" />设置
|
||||
</button>
|
||||
</div>
|
||||
{!versionReadonly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRecommendModal(true)}
|
||||
disabled={!recommendationDataReady}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-violet-600 hover:underline disabled:cursor-not-allowed disabled:text-[var(--ink-muted)] disabled:no-underline"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />{recommendationDataReady ? 'AI推荐' : '加载中'}
|
||||
</button>
|
||||
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
|
||||
<Settings className="h-3 w-3" />设置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
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 <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||||
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
|
||||
})()
|
||||
) : activeTab === 'bugs' ? (
|
||||
(() => {
|
||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
||||
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} />;
|
||||
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
|
||||
})()
|
||||
) : (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
||||
@@ -929,7 +950,7 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRecommendModal && (
|
||||
{showRecommendModal && !versionReadonly && (
|
||||
<MemberRecommendationModal
|
||||
groups={memberRecommendationGroups}
|
||||
members={version.members ?? []}
|
||||
@@ -943,7 +964,7 @@ export default function VersionDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReleaseModal && (
|
||||
{showReleaseModal && !versionReadonly && (
|
||||
<ReleaseVersionModal
|
||||
progress={releaseProgress}
|
||||
initialDate={formatLocalDate()}
|
||||
@@ -956,7 +977,7 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
|
||||
{/* 参与人员设置弹窗 */}
|
||||
{showMemberModal && (
|
||||
{showMemberModal && !versionReadonly && (
|
||||
<MemberSettingModal
|
||||
members={version.members ?? []}
|
||||
allMembers={memberCandidates}
|
||||
|
||||
@@ -27,8 +27,12 @@ import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
|
||||
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status';
|
||||
|
||||
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
|
||||
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
|
||||
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
|
||||
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
|
||||
|
||||
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<string, { id: string; name: string; productName: string; projectName: string }>();
|
||||
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName }));
|
||||
const map = new Map<string, WorkspaceVersionContext>();
|
||||
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<string, { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> }>();
|
||||
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 (
|
||||
<div className="flex h-full">
|
||||
@@ -216,9 +223,10 @@ export default function WorkspacePage() {
|
||||
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<h2 className="text-[14px] font-semibold text-[var(--ink)]">{TABS.find((t) => t.key === activeTab)?.label}</h2>
|
||||
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} 项</span>
|
||||
{selectedVersionId && (
|
||||
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
|
||||
{versionMap.get(selectedVersionId)?.name}
|
||||
{selectedVersion && (
|
||||
<span className="ml-3 inline-flex items-center gap-1.5 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
|
||||
<span>{selectedVersion.name}</span>
|
||||
<VersionStatusTag status={selectedVersion.status} />
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
@@ -234,6 +242,7 @@ export default function WorkspacePage() {
|
||||
<WorkItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
versionStatus={versionMap.get(item.versionId)?.status}
|
||||
onNavigate={() => 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') && (
|
||||
<PlanDetailDrawer planId={drawerItem.id} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
<PlanDetailDrawer planId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
)}
|
||||
{drawerItem && drawerItem.type === 'devTask' && (
|
||||
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
<DevTaskDetailDrawer taskId={drawerItem.id} allTaskIds={devTasks.map((t) => t.id)} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
)}
|
||||
{drawerItem && drawerItem.type === 'testCase' && (
|
||||
<TestCaseDetailDrawer testCaseId={drawerItem.id} onClose={() => setDrawerItem(null)} onCreateBug={(tcId) => { setDrawerItem(null); setBugFromTestCaseId(tcId); }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
<TestCaseDetailDrawer testCaseId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} onCreateBug={(tcId) => { if (!drawerReadOnly) { setDrawerItem(null); setBugFromTestCaseId(tcId); } }} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
)}
|
||||
{drawerItem && drawerItem.type === 'bug' && (
|
||||
<BugDetailDrawer bugId={drawerItem.id} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
<BugDetailDrawer bugId={drawerItem.id} readOnly={drawerReadOnly} onClose={() => setDrawerItem(null)} contextLabel={`${drawerItem.productName} / ${drawerItem.projectName} / ${drawerItem.versionName}`} />
|
||||
)}
|
||||
{bugFromTestCaseId && (
|
||||
<BugCreateModal testCaseId={bugFromTestCaseId} onClose={() => setBugFromTestCaseId(null)} />
|
||||
@@ -267,7 +276,7 @@ export default function WorkspacePage() {
|
||||
|
||||
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
|
||||
name: string;
|
||||
prod: { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> };
|
||||
prod: { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> };
|
||||
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)]'}`}
|
||||
>
|
||||
<span className="flex-1 text-left truncate">{v.name}</span>
|
||||
<VersionStatusTag status={v.status} readonlyOnly />
|
||||
{v.pendingCount > 0 && (
|
||||
<span className="text-[9px] bg-red-500 text-white rounded-full px-1.5 min-w-[16px] text-center">{v.pendingCount}</span>
|
||||
)}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none ${VERSION_STATUS_BG[status]}`}
|
||||
title={readonlyNotice ?? VERSION_STATUS_LABEL[status]}
|
||||
>
|
||||
{VERSION_STATUS_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
<span>{item.projectName}</span>
|
||||
<span className="text-[var(--line)]">/</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
||||
<VersionStatusTag status={versionStatus} readonlyOnly />
|
||||
{isDevTask && devTimeRange && (
|
||||
<>
|
||||
<span className="ml-2 tabular-nums">{devTimeRange}</span>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user