feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { Search, Plus, X, Download } from 'lucide-react';
|
import { Search, Plus, X, Download, FolderOpen, Building2 } from 'lucide-react';
|
||||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
@@ -17,6 +17,8 @@ import { FilterSelect } from '@/components/FilterSelect';
|
|||||||
import { FieldError } from '@/components/FieldError';
|
import { FieldError } from '@/components/FieldError';
|
||||||
import { RouteGuard } from '@/components/auth/Guard';
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||||
|
import { isMemberReference } from '@/lib/member-system';
|
||||||
|
import type { Department } from '@/lib/members';
|
||||||
|
|
||||||
export default function OvertimePage() {
|
export default function OvertimePage() {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,7 @@ function OvertimePageContent() {
|
|||||||
const [projectFilter, setProjectFilter] = useState('all');
|
const [projectFilter, setProjectFilter] = useState('all');
|
||||||
const [reasonFilter, setReasonFilter] = useState('all');
|
const [reasonFilter, setReasonFilter] = useState('all');
|
||||||
const [monthFilter, setMonthFilter] = useState('');
|
const [monthFilter, setMonthFilter] = useState('');
|
||||||
|
const [departmentFilter, setDepartmentFilter] = useState('all');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
|
||||||
|
|
||||||
@@ -58,7 +61,18 @@ function OvertimePageContent() {
|
|||||||
departments,
|
departments,
|
||||||
}), [records, user, viewerRole, members, 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];
|
let list = [...visibleRecords];
|
||||||
if (search) list = list.filter((r) => r.person.includes(search));
|
if (search) list = list.filter((r) => r.person.includes(search));
|
||||||
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
|
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
|
||||||
@@ -68,6 +82,51 @@ function OvertimePageContent() {
|
|||||||
return list;
|
return list;
|
||||||
}, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]);
|
}, [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 { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||||
|
|
||||||
const handleCreate = () => { setShowModal(true); };
|
const handleCreate = () => { setShowModal(true); };
|
||||||
@@ -140,16 +199,71 @@ function OvertimePageContent() {
|
|||||||
<MonthPicker value={monthFilter} onChange={setMonthFilter} placeholder="全部月份" />
|
<MonthPicker value={monthFilter} onChange={setMonthFilter} placeholder="全部月份" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
<div className="flex min-h-0 flex-1 overflow-hidden bg-[var(--bg)]">
|
||||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
|
<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>
|
||||||
|
<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 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">暂无加班记录</p>
|
<p className="text-[13px] font-medium text-[var(--ink-soft)]">暂无加班记录</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||||
<table className="w-full text-left text-[13px]">
|
<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)]">
|
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||||
<tr className="border-b border-[var(--line)] 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>
|
||||||
@@ -194,10 +308,13 @@ function OvertimePageContent() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
@@ -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 }: {
|
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
|
||||||
defaultPerson: string;
|
defaultPerson: string;
|
||||||
products: { id: string; name: string }[];
|
products: { id: string; name: string }[];
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateH
|
|||||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
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 { DevTask } from '@/lib/dev-task';
|
||||||
import type { TestCase } from '@/lib/test-case';
|
import type { TestCase } from '@/lib/test-case';
|
||||||
import type { Bug } from '@/lib/bug';
|
import type { Bug } from '@/lib/bug';
|
||||||
@@ -81,9 +83,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
|||||||
requirements: { id: string; versionId?: string }[];
|
requirements: { id: string; versionId?: string }[];
|
||||||
onNavigate: (id: string) => void;
|
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 versionData = useMemo(() => {
|
||||||
const vPlans = plans.filter((p) => p.versionId === version.id);
|
const vPlans = plans.filter((p) => p.versionId === version.id);
|
||||||
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.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 vTCs = testCases.filter((c) => c.versionId === version.id);
|
||||||
const vBugs = bugs.filter((b) => b.versionId === version.id);
|
const vBugs = bugs.filter((b) => b.versionId === version.id);
|
||||||
|
|
||||||
const startDates: string[] = [];
|
return { vPlans, vDevTasks, vTCs, vBugs };
|
||||||
vPlans.forEach((p) => {
|
}, [version.id, plans, devTasks, testCases, bugs, requirements]);
|
||||||
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); });
|
|
||||||
|
|
||||||
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
|
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
|
||||||
const actualStartDisplay = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null);
|
plans: versionData.vPlans,
|
||||||
|
devTasks: versionData.vDevTasks,
|
||||||
|
testCases: versionData.vTCs,
|
||||||
|
bugs: versionData.vBugs,
|
||||||
|
}), [versionData]);
|
||||||
|
|
||||||
// 实际截止:取所有阶段最晚完成
|
const timelineSummary = useMemo(() => buildVersionTimelineSummary({
|
||||||
const endDates: string[] = [];
|
status: version.status,
|
||||||
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
startDate: version.startDate,
|
||||||
vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
|
expectedReleaseDate: version.expectedReleaseDate,
|
||||||
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
releaseDate: version.releaseDate,
|
||||||
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
plans: versionData.vPlans,
|
||||||
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
|
devTasks: versionData.vDevTasks,
|
||||||
|
testCases: versionData.vTCs,
|
||||||
let totalDays = 0;
|
bugs: versionData.vBugs,
|
||||||
if (earliestStart) {
|
}), [version, versionData]);
|
||||||
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 stageProgress = useMemo(() => {
|
const stageProgress = useMemo(() => {
|
||||||
const { vPlans, vDevTasks, vTCs, vBugs } = versionData;
|
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') => {
|
const calcGroupProgress = (group: VersionPlan[], type: 'research' | 'product' | 'ui') => {
|
||||||
if (group.length === 0) return 0;
|
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');
|
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' };
|
sp['bug'] = { percent: bp, status: allClosed ? 'done' : closedBugs > 0 || vBugs.length > 0 ? 'active' : 'idle' };
|
||||||
}
|
}
|
||||||
return sp;
|
return mergeStageProgressWithEffort(sp, stageEffortMetrics);
|
||||||
}, [versionData]);
|
}, [versionData, stageEffortMetrics]);
|
||||||
|
|
||||||
const totalDays = versionData.totalDays;
|
|
||||||
|
|
||||||
const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中';
|
const displayStatus = VERSION_STATUS_LABEL[version.status] ?? '开发中';
|
||||||
const displayBg = VERSION_STATUS_BG[version.status] ?? 'bg-blue-500/10 text-blue-600';
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
<button
|
<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={`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">
|
<span className="flex-1 text-[11px] text-[var(--ink-muted)] flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
{formatVersionOverviewDateTime(timelineSummary.actualStartIso)}
|
||||||
<span className="mx-1">→</span>
|
<span className="mx-1">→</span>
|
||||||
{versionData.actualEnd ?? version.releaseDate ?? '-'}
|
{formatVersionOverviewDateTime(timelineSummary.isTerminalVersion ? timelineSummary.actualEndIso : timelineSummary.expectedReleaseIso)}
|
||||||
<span className="ml-1">共 {totalDays} 天</span>
|
<span className="ml-1">已耗时 {formatActualDuration(timelineSummary.actualHours)}</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`h-3.5 w-3.5 text-[var(--ink-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</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)]">
|
<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">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
{versionData.actualStart ?? version.startDate ?? '-'}
|
{timelineSummary.actualStartIso ? formatVersionOverviewDateTime(timelineSummary.actualStartIso) : '未开始'}
|
||||||
<span className="mx-1">→</span>
|
<span className="mx-1">→</span>
|
||||||
预计 {version.expectedReleaseDate ?? '-'}
|
预计 {timelineSummary.expectedReleaseIso ? formatVersionOverviewDateTime(timelineSummary.expectedReleaseIso) : '未设置'}
|
||||||
{versionData.actualEnd && (
|
|
||||||
<>
|
|
||||||
<span className="mx-1">|</span>
|
<span className="mx-1">|</span>
|
||||||
实际 {versionData.actualEnd}
|
实际 {timelineSummary.isTerminalVersion ? (timelineSummary.actualEndIso ? formatVersionOverviewDateTime(timelineSummary.actualEndIso) : '未记录') : '未完成'}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />已耗时 {totalDays} 天
|
<Clock className="h-3 w-3" />已耗时 {formatActualDuration(timelineSummary.actualHours)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MemberChips members={version.members ?? []} />
|
<MemberChips members={version.members ?? []} />
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ function ProjectsPageContent() {
|
|||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<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) => (
|
{paged.map((proj) => (
|
||||||
<ProjectRow
|
<ProjectRow
|
||||||
key={proj.id}
|
key={proj.id}
|
||||||
@@ -177,7 +177,7 @@ function ProjectRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
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)]" />
|
<FolderKanban size={20} className="shrink-0 text-[var(--ink-muted)]" />
|
||||||
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
<span className="font-medium text-[var(--ink)] min-w-[120px] shrink-0">
|
||||||
@@ -211,8 +211,8 @@ function ProjectRow({
|
|||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-30" 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="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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canActuallyDelete) return;
|
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 type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||||
import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
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 { 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 { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||||
@@ -184,7 +185,7 @@ function RequirementsPageContent() {
|
|||||||
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
|
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
|
||||||
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [dateSort, setDateSort] = useState<'desc' | 'asc'>('desc');
|
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
@@ -268,11 +269,7 @@ function RequirementsPageContent() {
|
|||||||
list = list.filter((r) => r.versionId === versionFilter);
|
list = list.filter((r) => r.versionId === versionFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort by createdAt
|
list = sortRequirementsByCreatedAt(list, dateSort);
|
||||||
list.sort((a, b) => {
|
|
||||||
const diff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
||||||
return dateSort === 'asc' ? diff : -diff;
|
|
||||||
});
|
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
|
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recomme
|
|||||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||||
|
import { isVersionReadonly } from '@/lib/version-status';
|
||||||
|
|
||||||
function formatOverviewDateTime(value?: string | null): string {
|
function formatOverviewDateTime(value?: string | null): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -131,6 +132,7 @@ export default function VersionDetailPage() {
|
|||||||
[version, plans, requirements, devTasks, testCases],
|
[version, plans, requirements, devTasks, testCases],
|
||||||
);
|
);
|
||||||
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
|
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
|
||||||
|
const versionReadonly = version ? isVersionReadonly(version.status) : false;
|
||||||
|
|
||||||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -208,6 +210,7 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
const renderActions = () => {
|
const renderActions = () => {
|
||||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
|
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
|
||||||
|
if (versionReadonly) return null;
|
||||||
if (version.status !== 'released' && version.status !== 'closed') {
|
if (version.status !== 'released' && version.status !== 'closed') {
|
||||||
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
|
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
|
||||||
}
|
}
|
||||||
@@ -499,6 +502,7 @@ export default function VersionDetailPage() {
|
|||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<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="flex items-center justify-between gap-3 mb-2">
|
||||||
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
<div className="text-[11px] text-[var(--ink-muted)] font-medium">参与人员</div>
|
||||||
|
{!versionReadonly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRecommendModal(true)}
|
onClick={() => setShowRecommendModal(true)}
|
||||||
@@ -511,6 +515,7 @@ export default function VersionDetailPage() {
|
|||||||
<Settings className="h-3 w-3" />设置
|
<Settings className="h-3 w-3" />设置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const membersList = version.members ?? [];
|
const membersList = version.members ?? [];
|
||||||
@@ -840,11 +845,17 @@ export default function VersionDetailPage() {
|
|||||||
devTasks={devTasks}
|
devTasks={devTasks}
|
||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
currentUserName={user?.name ?? ''}
|
currentUserName={user?.name ?? ''}
|
||||||
|
readOnly={versionReadonly}
|
||||||
onLink={(ids, addedBy) => {
|
onLink={(ids, addedBy) => {
|
||||||
|
if (versionReadonly) return;
|
||||||
ids.forEach((id) => updateRequirement(id, { versionId: version.id, addedToVersionBy: addedBy }));
|
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) => {
|
onCreateChange={(data) => {
|
||||||
|
if (versionReadonly) return;
|
||||||
createRequirement({
|
createRequirement({
|
||||||
...data,
|
...data,
|
||||||
productId: version.productId,
|
productId: version.productId,
|
||||||
@@ -878,7 +889,9 @@ export default function VersionDetailPage() {
|
|||||||
versionMembers={version.members ?? []}
|
versionMembers={version.members ?? []}
|
||||||
linkedRequirements={projectAdoptedReqs}
|
linkedRequirements={projectAdoptedReqs}
|
||||||
allRequirements={requirements}
|
allRequirements={requirements}
|
||||||
|
readOnly={versionReadonly}
|
||||||
onCreate={(data) => {
|
onCreate={(data) => {
|
||||||
|
if (versionReadonly) return;
|
||||||
createPlan(data);
|
createPlan(data);
|
||||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
@@ -891,13 +904,20 @@ export default function VersionDetailPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onUpdate={(id, data) => {
|
onUpdate={(id, data) => {
|
||||||
|
if (versionReadonly) return;
|
||||||
updatePlan(id, data);
|
updatePlan(id, data);
|
||||||
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
||||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onComplete={completePlan}
|
onComplete={(id, result) => {
|
||||||
onDelete={deletePlan}
|
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}
|
versionId={version.id}
|
||||||
requirementIds={versionReqs.map((r) => r.id)}
|
requirementIds={versionReqs.map((r) => r.id)}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
|
readOnly={versionReadonly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : activeTab === 'testcases' ? (
|
) : activeTab === 'testcases' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
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' ? (
|
) : activeTab === 'bugs' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{showRecommendModal && (
|
{showRecommendModal && !versionReadonly && (
|
||||||
<MemberRecommendationModal
|
<MemberRecommendationModal
|
||||||
groups={memberRecommendationGroups}
|
groups={memberRecommendationGroups}
|
||||||
members={version.members ?? []}
|
members={version.members ?? []}
|
||||||
@@ -943,7 +964,7 @@ export default function VersionDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showReleaseModal && (
|
{showReleaseModal && !versionReadonly && (
|
||||||
<ReleaseVersionModal
|
<ReleaseVersionModal
|
||||||
progress={releaseProgress}
|
progress={releaseProgress}
|
||||||
initialDate={formatLocalDate()}
|
initialDate={formatLocalDate()}
|
||||||
@@ -956,7 +977,7 @@ export default function VersionDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 参与人员设置弹窗 */}
|
{/* 参与人员设置弹窗 */}
|
||||||
{showMemberModal && (
|
{showMemberModal && !versionReadonly && (
|
||||||
<MemberSettingModal
|
<MemberSettingModal
|
||||||
members={version.members ?? []}
|
members={version.members ?? []}
|
||||||
allMembers={memberCandidates}
|
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 { 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 { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||||
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
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 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 }[] = [
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
{ key: 'all', label: '全部', icon: ClipboardList },
|
{ key: 'all', label: '全部', icon: ClipboardList },
|
||||||
@@ -77,8 +81,8 @@ export default function WorkspacePage() {
|
|||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
const versionMap = useMemo(() => {
|
const versionMap = useMemo(() => {
|
||||||
const map = new Map<string, { id: string; name: string; productName: string; projectName: string }>();
|
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 }));
|
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status }));
|
||||||
return map;
|
return map;
|
||||||
}, [allVersions]);
|
}, [allVersions]);
|
||||||
|
|
||||||
@@ -96,7 +100,7 @@ export default function WorkspacePage() {
|
|||||||
// 构建树:只显示跟自己有关的产品/项目/版本
|
// 构建树:只显示跟自己有关的产品/项目/版本
|
||||||
const tree = useMemo(() => {
|
const tree = useMemo(() => {
|
||||||
const myVersionIds = new Set(workItems.map((i) => i.versionId).filter(Boolean));
|
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) => {
|
allVersions.forEach((v) => {
|
||||||
if (!myVersionIds.has(v.id)) return;
|
if (!myVersionIds.has(v.id)) return;
|
||||||
@@ -106,7 +110,7 @@ export default function WorkspacePage() {
|
|||||||
const proj = prod.projects.get(v.projectName)!;
|
const proj = prod.projects.get(v.projectName)!;
|
||||||
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
|
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
|
||||||
if (!proj.versions.find((ver) => ver.id === v.id)) {
|
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 pendingCount = pendingByTab.all;
|
||||||
const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length;
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<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">
|
<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>
|
<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>
|
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} 项</span>
|
||||||
{selectedVersionId && (
|
{selectedVersion && (
|
||||||
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
|
<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)]">
|
||||||
{versionMap.get(selectedVersionId)?.name}
|
<span>{selectedVersion.name}</span>
|
||||||
|
<VersionStatusTag status={selectedVersion.status} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -234,6 +242,7 @@ export default function WorkspacePage() {
|
|||||||
<WorkItemCard
|
<WorkItemCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
versionStatus={versionMap.get(item.versionId)?.status}
|
||||||
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
||||||
onClick={() => setDrawerItem(item)}
|
onClick={() => setDrawerItem(item)}
|
||||||
/>
|
/>
|
||||||
@@ -247,16 +256,16 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
{/* Detail Drawers */}
|
{/* Detail Drawers */}
|
||||||
{drawerItem && (drawerItem.type === 'plan_research' || drawerItem.type === 'plan_product' || drawerItem.type === 'plan_ui') && (
|
{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' && (
|
{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' && (
|
{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' && (
|
{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 && (
|
{bugFromTestCaseId && (
|
||||||
<BugCreateModal testCaseId={bugFromTestCaseId} onClose={() => setBugFromTestCaseId(null)} />
|
<BugCreateModal testCaseId={bugFromTestCaseId} onClose={() => setBugFromTestCaseId(null)} />
|
||||||
@@ -267,7 +276,7 @@ export default function WorkspacePage() {
|
|||||||
|
|
||||||
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
|
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
|
||||||
name: string;
|
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;
|
selectedVersionId: string | null;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -287,7 +296,7 @@ function ProductNode({ name, prod, selectedVersionId, onSelect }: {
|
|||||||
|
|
||||||
function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
|
function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
|
||||||
name: string;
|
name: string;
|
||||||
versions: { id: string; name: string; pendingCount: number }[];
|
versions: TreeVersion[];
|
||||||
selectedVersionId: string | null;
|
selectedVersionId: string | null;
|
||||||
onSelect: (id: string | null) => void;
|
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)]'}`}
|
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>
|
<span className="flex-1 text-left truncate">{v.name}</span>
|
||||||
|
<VersionStatusTag status={v.status} readonlyOnly />
|
||||||
{v.pendingCount > 0 && (
|
{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>
|
<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 statusBadge = getStatusBadge(item);
|
||||||
const isDevTask = item.type === 'devTask';
|
const isDevTask = item.type === 'devTask';
|
||||||
const devTaskRaw = isDevTask ? (item.raw as any) : null;
|
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>{item.projectName}</span>
|
||||||
<span className="text-[var(--line)]">/</span>
|
<span className="text-[var(--line)]">/</span>
|
||||||
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
||||||
|
<VersionStatusTag status={versionStatus} readonlyOnly />
|
||||||
{isDevTask && devTimeRange && (
|
{isDevTask && devTimeRange && (
|
||||||
<>
|
<>
|
||||||
<span className="ml-2 tabular-nums">{devTimeRange}</span>
|
<span className="ml-2 tabular-nums">{devTimeRange}</span>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
|||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||||||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
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 { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +55,7 @@ function XiaobaoWarningContent() {
|
|||||||
snapshots,
|
snapshots,
|
||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
|
insightRequestAttempts,
|
||||||
riskDataLoaded,
|
riskDataLoaded,
|
||||||
saveSnapshot,
|
saveSnapshot,
|
||||||
saveInsight,
|
saveInsight,
|
||||||
@@ -90,9 +91,15 @@ function XiaobaoWarningContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
risks.forEach((risk) => {
|
risks.forEach((risk) => {
|
||||||
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
|
||||||
if (!shouldRequestRiskInsightWithCacheGate(riskDataLoaded, insights, risk, previous)) return;
|
|
||||||
const signature = buildRiskInsightSignature(risk);
|
|
||||||
const key = buildXiaobaoRiskInsightPendingKey(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 (pendingInsightKeys.includes(key)) return;
|
||||||
if (requestedInsightKeysRef.current.has(key)) return;
|
if (requestedInsightKeysRef.current.has(key)) return;
|
||||||
requestedInsightKeysRef.current.add(key);
|
requestedInsightKeysRef.current.add(key);
|
||||||
@@ -114,6 +121,7 @@ function XiaobaoWarningContent() {
|
|||||||
beginInsightUpdate,
|
beginInsightUpdate,
|
||||||
finishInsightUpdate,
|
finishInsightUpdate,
|
||||||
insights,
|
insights,
|
||||||
|
insightRequestAttempts,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
riskDataLoaded,
|
riskDataLoaded,
|
||||||
risks,
|
risks,
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ interface Props {
|
|||||||
bugId: string;
|
bugId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
contextLabel?: string;
|
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 { bugs, changeStatus, transferBug } = useBugStore();
|
||||||
const { testCases } = useTestCaseStore();
|
const { testCases } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
@@ -73,16 +74,19 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
}, [bug.logs, bug.title, members]);
|
}, [bug.logs, bug.title, members]);
|
||||||
|
|
||||||
const handleTransition = (to: BugStatus) => {
|
const handleTransition = (to: BugStatus) => {
|
||||||
|
if (readOnly) return;
|
||||||
if (to === 'fixed') { setShowResolutionInput(true); return; }
|
if (to === 'fixed') { setShowResolutionInput(true); return; }
|
||||||
changeStatus(bug.id, to, operator);
|
changeStatus(bug.id, to, operator);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmFix = () => {
|
const confirmFix = () => {
|
||||||
|
if (readOnly) return;
|
||||||
changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined });
|
changeStatus(bug.id, 'fixed', operator, { resolution: resolution.trim() || undefined });
|
||||||
setShowResolutionInput(false);
|
setShowResolutionInput(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTransfer = () => {
|
const handleTransfer = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (!transferTo) return;
|
if (!transferTo) return;
|
||||||
transferBug(bug.id, transferTo, operator, transferRemark.trim() || undefined);
|
transferBug(bug.id, transferTo, operator, transferRemark.trim() || undefined);
|
||||||
setShowTransfer(false);
|
setShowTransfer(false);
|
||||||
@@ -135,7 +139,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && (
|
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && !readOnly && (
|
||||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
{nextStatuses.map((s) => (
|
{nextStatuses.map((s) => (
|
||||||
@@ -150,11 +154,11 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && (
|
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && !readOnly && (
|
||||||
<div className="text-[11px] text-[var(--ink-muted)] pt-1">当前修复人为 {assigneeName},仅修复人可操作</div>
|
<div className="text-[11px] text-[var(--ink-muted)] pt-1">当前修复人为 {assigneeName},仅修复人可操作</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showResolutionInput && (
|
{showResolutionInput && !readOnly && (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<input value={resolution} onChange={(e) => 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 />
|
<input value={resolution} onChange={(e) => 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 />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -164,7 +168,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTransfer && (
|
{showTransfer && !readOnly && (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--line)]">
|
<div className="space-y-2 pt-1 border-t border-[var(--line)]">
|
||||||
<div className="text-[11px] text-[var(--ink-muted)]">转交给:</div>
|
<div className="text-[11px] text-[var(--ink-muted)]">转交给:</div>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import type { BugStatus, BugSeverity } from '@/lib/bug';
|
|||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BugTab({ versionId, requirementIds }: Props) {
|
export function BugTab({ versionId, requirementIds, readOnly = false }: Props) {
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
@@ -159,7 +160,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
|
|||||||
|
|
||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} onClose={() => setSelectedBugId(null)} />}
|
{selectedBugId && <BugDetailDrawer bugId={selectedBugId} readOnly={readOnly} onClose={() => setSelectedBugId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface Props {
|
|||||||
allTaskIds: string[];
|
allTaskIds: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPlanStartLocal(): string {
|
function defaultPlanStartLocal(): string {
|
||||||
@@ -46,7 +47,7 @@ function defaultPlanEndLocal(): string {
|
|||||||
return isoToLocal(d.toISOString());
|
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 { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
|
||||||
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
||||||
const { categories } = useTaskCategoryStore();
|
const { categories } = useTaskCategoryStore();
|
||||||
@@ -93,12 +94,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
||||||
|
|
||||||
const openPlanInput = () => {
|
const openPlanInput = () => {
|
||||||
|
if (readOnly) return;
|
||||||
setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal());
|
setPlanStartLocal(task.expectedStartAt ? isoToLocal(task.expectedStartAt) : defaultPlanStartLocal());
|
||||||
setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal());
|
setPlanEndLocal(task.expectedEndAt ? isoToLocal(task.expectedEndAt) : defaultPlanEndLocal());
|
||||||
setShowPlanInput(true);
|
setShowPlanInput(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePlan = () => {
|
const handleSavePlan = () => {
|
||||||
|
if (readOnly) return;
|
||||||
const assigneeId = task.assigneeId || currentUserName;
|
const assigneeId = task.assigneeId || currentUserName;
|
||||||
if (!assigneeId) {
|
if (!assigneeId) {
|
||||||
alert('领取前需要先登录或选择负责人');
|
alert('领取前需要先登录或选择负责人');
|
||||||
@@ -115,6 +118,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTransition = (to: DevTaskStatus) => {
|
const handleTransition = (to: DevTaskStatus) => {
|
||||||
|
if (readOnly) return;
|
||||||
if (to === 'in_progress' && !startReady) {
|
if (to === 'in_progress' && !startReady) {
|
||||||
openPlanInput();
|
openPlanInput();
|
||||||
return;
|
return;
|
||||||
@@ -138,17 +142,20 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBlock = () => {
|
const handleBlock = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (!blockReason.trim()) return;
|
if (!blockReason.trim()) return;
|
||||||
setBlocked(task.id, true, blockReason.trim());
|
setBlocked(task.id, true, blockReason.trim());
|
||||||
setShowBlockInput(false);
|
setShowBlockInput(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnblock = () => {
|
const handleUnblock = () => {
|
||||||
|
if (readOnly) return;
|
||||||
setBlocked(task.id, false);
|
setBlocked(task.id, false);
|
||||||
setBlockReason('');
|
setBlockReason('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgressNote = () => {
|
const handleProgressNote = () => {
|
||||||
|
if (readOnly) return;
|
||||||
const note = progressNote.trim();
|
const note = progressNote.trim();
|
||||||
const blocker = progressBlocker.trim();
|
const blocker = progressBlocker.trim();
|
||||||
const delayRisk = progressDelayRisk.trim();
|
const delayRisk = progressDelayRisk.trim();
|
||||||
@@ -184,15 +191,15 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
|
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{task.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{task.status !== 'submitted' && (
|
{!readOnly && task.status !== 'submitted' && (
|
||||||
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => { if (confirm('确定删除此任务?')) { deleteTask(task.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>
|
{!readOnly && <button onClick={() => { if (confirm('确定删除此任务?')) { deleteTask(task.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>}
|
||||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTransfer && (
|
{showTransfer && !readOnly && (
|
||||||
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -239,7 +246,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visibleNextStatuses.length > 0 && !showDelayInput && (
|
{visibleNextStatuses.length > 0 && !showDelayInput && !readOnly && (
|
||||||
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
{visibleNextStatuses.map((s) => (
|
{visibleNextStatuses.map((s) => (
|
||||||
@@ -250,7 +257,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{task.status === 'todo' && !startReady && !showPlanInput && (
|
{task.status === 'todo' && !startReady && !showPlanInput && !readOnly && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
onClick={openPlanInput}
|
onClick={openPlanInput}
|
||||||
@@ -265,7 +272,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPlanInput && (
|
{showPlanInput && !readOnly && (
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
|
||||||
<div className="text-[11px] font-medium text-orange-700">
|
<div className="text-[11px] font-medium text-orange-700">
|
||||||
{needsClaim ? '领取并填写计划' : '填写计划'}
|
{needsClaim ? '领取并填写计划' : '填写计划'}
|
||||||
@@ -305,7 +312,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDelayInput && (
|
{showDelayInput && !readOnly && (
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-2">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
<div className="flex items-center gap-1.5 text-[11px] text-orange-700">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
@@ -326,23 +333,23 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel
|
|||||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
<span>{task.blockReason}</span>
|
<span>{task.blockReason}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline">解除阻塞</button>
|
{!readOnly && <button onClick={handleUnblock} className="text-[11px] text-emerald-600 font-medium hover:underline">解除阻塞</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
showBlockInput ? (
|
showBlockInput && !readOnly ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input value={blockReason} onChange={(e) => 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 />
|
<input value={blockReason} onChange={(e) => 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 />
|
||||||
<button onClick={handleBlock} disabled={!blockReason.trim()} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white disabled:opacity-50">确认</button>
|
<button onClick={handleBlock} disabled={!blockReason.trim()} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white disabled:opacity-50">确认</button>
|
||||||
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
<button onClick={() => setShowBlockInput(false)} className="h-8 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline">标记阻塞</button>
|
!readOnly && <button onClick={() => setShowBlockInput(true)} className="text-[11px] text-red-500 font-medium hover:underline">标记阻塞</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.status !== 'todo' && task.status !== 'submitted' && (
|
{task.status !== 'todo' && task.status !== 'submitted' && !readOnly && (
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 space-y-3">
|
||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">今日进展</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">今日进展</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ interface Props {
|
|||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props) {
|
export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) {
|
||||||
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
|
const { tasks, fetchTasks, deleteTask } = useDevTaskStore();
|
||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
const { fetchWorklogs } = useTaskWorklogStore();
|
const { fetchWorklogs } = useTaskWorklogStore();
|
||||||
@@ -90,11 +91,13 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
};
|
};
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
|
if (!confirm(`确定删除选中的 ${selectedIds.size} 个任务?`)) return;
|
||||||
selectedIds.forEach((id) => deleteTask(id));
|
selectedIds.forEach((id) => deleteTask(id));
|
||||||
@@ -174,21 +177,23 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setFilterBlocked(''); setFilterCategory(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && !readOnly && (
|
||||||
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||||
<Trash2 className="h-3 w-3" />删除{selectedIds.size}项
|
<Trash2 className="h-3 w-3" />删除{selectedIds.size}项
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||||
<Plus className="h-3 w-3" />新建
|
<Plus className="h-3 w-3" />新建
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredTasks.length === 0 ? (
|
{filteredTasks.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的任务' : '暂无开发任务'}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的任务' : '暂无开发任务'}</p>
|
||||||
{!hasFilter && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个任务</button>}
|
{!hasFilter && !readOnly && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个任务</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, reqTasks]) => {
|
||||||
@@ -197,9 +202,11 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
return (
|
return (
|
||||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
||||||
|
{!readOnly && (
|
||||||
<div className="pl-4 flex items-center">
|
<div className="pl-4 flex items-center">
|
||||||
<input type="checkbox" checked={reqTasks.every((t) => selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
<input type="checkbox" checked={reqTasks.every((t) => selectedIds.has(t.id))} onChange={() => { const ids = reqTasks.map((t) => t.id); const allSelected = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
||||||
<span className="h-2 w-2 shrink-0" />
|
<span className="h-2 w-2 shrink-0" />
|
||||||
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code}>{req?.code}</span>
|
<span className="w-16 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code}>{req?.code}</span>
|
||||||
@@ -209,9 +216,11 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
</div>
|
</div>
|
||||||
{reqTasks.map((t) => (
|
{reqTasks.map((t) => (
|
||||||
<div key={t.id} className="flex items-center">
|
<div key={t.id} className="flex items-center">
|
||||||
|
{!readOnly && (
|
||||||
<div className="pl-4 flex items-center">
|
<div className="pl-4 flex items-center">
|
||||||
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
<input type="checkbox" checked={selectedIds.has(t.id)} onChange={() => toggleSelect(t.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<DevTaskRow task={t} category={categoryMap.get(t.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} onClick={() => setSelectedTaskId(t.id)} />
|
<DevTaskRow task={t} category={categoryMap.get(t.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} onClick={() => setSelectedTaskId(t.id)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -224,8 +233,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline }: Props
|
|||||||
|
|
||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{showCreate && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
{showCreate && !readOnly && <DevTaskCreateModal versionId={versionId} requirementIds={requirementIds} versionDeadline={versionDeadline} onClose={() => setShowCreate(false)} />}
|
||||||
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} onClose={() => setSelectedTaskId(null)} />}
|
{selectedTaskId && <DevTaskDetailDrawer taskId={selectedTaskId} allTaskIds={allTaskIds} readOnly={readOnly} onClose={() => setSelectedTaskId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreateBug?: (testCaseId: string) => void;
|
onCreateBug?: (testCaseId: string) => void;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPlanStartLocal(): string {
|
function defaultPlanStartLocal(): string {
|
||||||
@@ -39,7 +40,7 @@ function defaultPlanEndLocal(): string {
|
|||||||
return isoToLocal(d.toISOString());
|
return isoToLocal(d.toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel }: Props) {
|
export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, contextLabel, readOnly = false }: Props) {
|
||||||
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
|
const { testCases, changeStatus, deleteTestCase, updateTestCase } = useTestCaseStore();
|
||||||
const { bugs } = useBugStore();
|
const { bugs } = useBugStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
@@ -75,12 +76,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
||||||
|
|
||||||
const openPlanInput = () => {
|
const openPlanInput = () => {
|
||||||
|
if (readOnly) return;
|
||||||
setPlanStartLocal(tc.plannedTestAt ? isoToLocal(tc.plannedTestAt) : defaultPlanStartLocal());
|
setPlanStartLocal(tc.plannedTestAt ? isoToLocal(tc.plannedTestAt) : defaultPlanStartLocal());
|
||||||
setPlanEndLocal(tc.plannedEndAt ? isoToLocal(tc.plannedEndAt) : defaultPlanEndLocal());
|
setPlanEndLocal(tc.plannedEndAt ? isoToLocal(tc.plannedEndAt) : defaultPlanEndLocal());
|
||||||
setShowPlanInput(true);
|
setShowPlanInput(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePlan = () => {
|
const handleSavePlan = () => {
|
||||||
|
if (readOnly) return;
|
||||||
const assigneeId = tc.assigneeId || currentUserName;
|
const assigneeId = tc.assigneeId || currentUserName;
|
||||||
if (!assigneeId) {
|
if (!assigneeId) {
|
||||||
alert('领取前需要先登录或选择负责人');
|
alert('领取前需要先登录或选择负责人');
|
||||||
@@ -102,6 +105,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
const [showBlockInput, setShowBlockInput] = useState(false);
|
const [showBlockInput, setShowBlockInput] = useState(false);
|
||||||
|
|
||||||
const handleTransition = (to: TestCaseStatus) => {
|
const handleTransition = (to: TestCaseStatus) => {
|
||||||
|
if (readOnly) return;
|
||||||
if (to === 'running' && !startReady) {
|
if (to === 'running' && !startReady) {
|
||||||
openPlanInput();
|
openPlanInput();
|
||||||
return;
|
return;
|
||||||
@@ -112,12 +116,14 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmFail = () => {
|
const confirmFail = () => {
|
||||||
|
if (readOnly) return;
|
||||||
changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
|
changeStatus(tc.id, 'failed', { failReason: failReason.trim() || undefined });
|
||||||
setShowFailInput(false);
|
setShowFailInput(false);
|
||||||
setFailReason('');
|
setFailReason('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmBlock = () => {
|
const confirmBlock = () => {
|
||||||
|
if (readOnly) return;
|
||||||
changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
|
changeStatus(tc.id, 'blocked', { blockReason: blockReason.trim() || undefined });
|
||||||
setShowBlockInput(false);
|
setShowBlockInput(false);
|
||||||
setBlockReason('');
|
setBlockReason('');
|
||||||
@@ -137,16 +143,16 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
|
<span className="text-[14px] font-semibold text-[var(--ink)] truncate max-w-[240px]">{tc.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{tc.status !== 'passed' && (
|
{!readOnly && tc.status !== 'passed' && (
|
||||||
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
<button onClick={() => setShowTransfer(!showTransfer)} className="p-1.5 rounded-lg hover:bg-blue-50 text-[var(--ink-muted)] hover:text-blue-500" title="转交"><ArrowRightLeft className="h-4 w-4" /></button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => { if (relatedBugs.length > 0) { alert('该用例有关联 Bug,无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>
|
{!readOnly && <button onClick={() => { if (relatedBugs.length > 0) { alert('该用例有关联 Bug,无法删除'); return; } if (confirm('确定删除此测试用例?')) { deleteTestCase(tc.id); onClose(); } }} className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--ink-muted)] hover:text-red-500" title="删除"><Trash2 className="h-4 w-4" /></button>}
|
||||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转交 */}
|
{/* 转交 */}
|
||||||
{showTransfer && (
|
{showTransfer && !readOnly && (
|
||||||
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
<div className="mx-5 mt-3 rounded-lg border border-[var(--line)] p-3 flex items-center gap-2">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">转交给:</span>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -157,7 +163,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
labelClassName="max-w-[calc(100%-20px)]"
|
labelClassName="max-w-[calc(100%-20px)]"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => { if (transferTo) { updateTestCase(tc.id, { assigneeId: transferTo }); setShowTransfer(false); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认</button>
|
<button onClick={() => { if (readOnly) return; if (transferTo) { updateTestCase(tc.id, { assigneeId: transferTo }); setShowTransfer(false); setTransferTo(''); } }} disabled={!transferTo} className="h-7 px-2.5 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认</button>
|
||||||
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,7 +189,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]">执行于 {tc.executedAt}</span>}
|
{tc.executedAt && <span className="text-[11px] text-[var(--ink-muted)]">执行于 {tc.executedAt}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && (
|
{visibleNextStatuses.length > 0 && !showFailInput && !showBlockInput && !readOnly && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
{visibleNextStatuses.map((s) => (
|
{visibleNextStatuses.map((s) => (
|
||||||
@@ -194,7 +200,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tc.status === 'pending' && !startReady && !showPlanInput && (
|
{tc.status === 'pending' && !startReady && !showPlanInput && !readOnly && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
onClick={openPlanInput}
|
onClick={openPlanInput}
|
||||||
@@ -209,7 +215,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPlanInput && (
|
{showPlanInput && !readOnly && (
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
|
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 space-y-3">
|
||||||
<div className="text-[11px] font-medium text-orange-700">
|
<div className="text-[11px] font-medium text-orange-700">
|
||||||
{needsClaim ? '领取并填写计划' : '填写计划'}
|
{needsClaim ? '领取并填写计划' : '填写计划'}
|
||||||
@@ -249,7 +255,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFailInput && (
|
{showFailInput && !readOnly && (
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="不通过原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
|
<input value={failReason} onChange={(e) => setFailReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmFail(); }} placeholder="不通过原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-red-400 focus:outline-none" autoFocus />
|
||||||
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认不通过</button>
|
<button onClick={confirmFail} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white">确认不通过</button>
|
||||||
@@ -257,7 +263,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showBlockInput && (
|
{showBlockInput && !readOnly && (
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmBlock(); }} placeholder="阻塞原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
|
<input value={blockReason} onChange={(e) => setBlockReason(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') confirmBlock(); }} placeholder="阻塞原因(可选)" className="flex-1 h-8 rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-orange-400 focus:outline-none" autoFocus />
|
||||||
<button onClick={confirmBlock} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-orange-500 text-white">确认阻塞</button>
|
<button onClick={confirmBlock} className="h-8 px-3 rounded-lg text-[11px] font-medium bg-orange-500 text-white">确认阻塞</button>
|
||||||
@@ -299,7 +305,7 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联 Bug ({relatedBugs.length})</div>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide">关联 Bug ({relatedBugs.length})</div>
|
||||||
{tc.status === 'failed' && onCreateBug && (
|
{tc.status === 'failed' && onCreateBug && !readOnly && (
|
||||||
<button onClick={() => onCreateBug(tc.id)} className="flex items-center gap-1 h-7 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
<button onClick={() => onCreateBug(tc.id)} className="flex items-center gap-1 h-7 px-3 rounded-lg text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||||
<BugIcon className="h-3 w-3" />提 BUG
|
<BugIcon className="h-3 w-3" />提 BUG
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ import type { TestCaseStatus } from '@/lib/test-case';
|
|||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestCaseTab({ versionId, requirementIds }: Props) {
|
export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) {
|
||||||
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const { tasks: devTasks } = useDevTaskStore();
|
const { tasks: devTasks } = useDevTaskStore();
|
||||||
@@ -116,11 +117,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
setSelectedIds(next);
|
setSelectedIds(next);
|
||||||
};
|
};
|
||||||
const handleBatchDelete = () => {
|
const handleBatchDelete = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
const hasBug = Array.from(selectedIds).some((id) => bugs.some((b) => b.testCaseId === id));
|
||||||
if (hasBug) {
|
if (hasBug) {
|
||||||
@@ -137,6 +140,7 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
const handleStartNewRound = () => {
|
const handleStartNewRound = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (!canCreateNextRound) return;
|
if (!canCreateNextRound) return;
|
||||||
const operator = user?.name || '系统';
|
const operator = user?.name || '系统';
|
||||||
createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator)));
|
createTestCases(firstRoundCases.map((testCase) => copyTestCaseToRound(testCase, nextRoundNo, operator)));
|
||||||
@@ -212,11 +216,13 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
{hasFilter && <button onClick={() => { setFilterAssignee(''); setFilterStatus(''); setKeyword(''); setPage(1); }} className="text-[10px] text-[var(--accent)] hover:underline shrink-0">清除</button>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && !readOnly && (
|
||||||
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
<button onClick={handleBatchDelete} className="flex items-center gap-1 h-6 px-2 rounded text-[11px] font-medium bg-red-500 text-white hover:bg-red-600">
|
||||||
<Trash2 className="h-3 w-3" />删除{selectedIds.size}项
|
<Trash2 className="h-3 w-3" />删除{selectedIds.size}项
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartNewRound}
|
onClick={handleStartNewRound}
|
||||||
disabled={!canCreateNextRound}
|
disabled={!canCreateNextRound}
|
||||||
@@ -228,13 +234,15 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
<button onClick={() => setShowCreate(true)} className="flex items-center gap-1 h-6 px-2.5 rounded text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||||
<Plus className="h-3 w-3" />新建
|
<Plus className="h-3 w-3" />新建
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredCases.length === 0 ? (
|
{filteredCases.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
<p className="text-[13px] text-[var(--ink-muted)]">{hasFilter ? '没有匹配的用例' : '暂无测试用例'}</p>
|
||||||
{!hasFilter && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个用例</button>}
|
{!hasFilter && !readOnly && <button onClick={() => setShowCreate(true)} className="mt-3 text-[12px] text-[var(--accent)] hover:underline">创建第一个用例</button>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
Array.from(groupedByReq.entries()).map(([reqId, cases]) => {
|
||||||
@@ -244,9 +252,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
<div key={reqId} className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] overflow-hidden">
|
||||||
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
<div className="flex items-center bg-[var(--bg-subtle)] border-b border-[var(--line)]">
|
||||||
|
{!readOnly && (
|
||||||
<div className="pl-4 flex items-center">
|
<div className="pl-4 flex items-center">
|
||||||
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
<input type="checkbox" checked={cases.every((c) => selectedIds.has(c.id))} onChange={() => { const ids = cases.map((c) => c.id); const allSel = ids.every((id) => selectedIds.has(id)); const next = new Set(selectedIds); if (allSel) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); setSelectedIds(next); }} className="h-3.5 w-3.5 rounded border-[var(--line)]" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
<div className="flex flex-1 min-w-0 items-center gap-2 px-4 py-2">
|
||||||
<span className="h-2 w-2 shrink-0" />
|
<span className="h-2 w-2 shrink-0" />
|
||||||
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
|
<span className="w-14 min-w-0 shrink-0 truncate text-[11px] font-mono text-[var(--ink-muted)]" title={req?.code || '通用'}>{req?.code || '通用'}</span>
|
||||||
@@ -265,9 +275,11 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{cases.map((c) => (
|
{cases.map((c) => (
|
||||||
<div key={c.id} className="flex items-center">
|
<div key={c.id} className="flex items-center">
|
||||||
|
{!readOnly && (
|
||||||
<div className="pl-4 flex items-center">
|
<div className="pl-4 flex items-center">
|
||||||
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
<input type="checkbox" checked={selectedIds.has(c.id)} onChange={() => toggleSelect(c.id)} className="h-3.5 w-3.5 rounded border-[var(--line)]" onClick={(e) => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<TestCaseRow testCase={c} category={categoryMap.get(c.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
<TestCaseRow testCase={c} category={categoryMap.get(c.categoryId)} categoryLabelWidthEm={categoryLabelWidthEm} bugCount={bugCountByCase.get(c.id) ?? 0} onClick={() => setSelectedCaseId(c.id)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -280,9 +292,9 @@ export function TestCaseTab({ versionId, requirementIds }: Props) {
|
|||||||
|
|
||||||
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
{total > 20 && <Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />}
|
||||||
|
|
||||||
{showCreate && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
|
{showCreate && !readOnly && <TestCaseCreateModal versionId={versionId} requirementIds={requirementIds} roundNo={activeRound} onClose={() => setShowCreate(false)} />}
|
||||||
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => setBugForCaseId(id)} />}
|
{selectedCaseId && <TestCaseDetailDrawer testCaseId={selectedCaseId} readOnly={readOnly} onClose={() => setSelectedCaseId(null)} onCreateBug={(id) => { if (!readOnly) setBugForCaseId(id); }} />}
|
||||||
{bugForCaseId && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
{bugForCaseId && !readOnly && <BugCreateModal testCaseId={bugForCaseId} onClose={() => setBugForCaseId(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface Props {
|
|||||||
planId: string;
|
planId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
|
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
|
||||||
@@ -48,7 +49,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = false }: Props) {
|
||||||
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
@@ -74,13 +75,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
? getResearchDirectionProgressSummary(plan).percent
|
? getResearchDirectionProgressSummary(plan).percent
|
||||||
: getRequirementCoverageSummary(plan).percent;
|
: getRequirementCoverageSummary(plan).percent;
|
||||||
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
||||||
const currentUserName = user?.name ?? plan.owner;
|
const currentUserName = user?.name ?? plan.owner;
|
||||||
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||||
const isProductDesignPlan = productPlanKind === 'design';
|
const isProductDesignPlan = productPlanKind === 'design';
|
||||||
const isProductReviewPlan = productPlanKind === 'review';
|
const isProductReviewPlan = productPlanKind === 'review';
|
||||||
|
|
||||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (readOnly) return;
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
@@ -90,6 +92,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
||||||
|
if (readOnly) return;
|
||||||
const next = new Set(reviewFailureTypes);
|
const next = new Set(reviewFailureTypes);
|
||||||
if (next.has(type)) next.delete(type);
|
if (next.has(type)) next.delete(type);
|
||||||
else next.add(type);
|
else next.add(type);
|
||||||
@@ -97,6 +100,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitResult = () => {
|
const handleSubmitResult = () => {
|
||||||
|
if (readOnly) return;
|
||||||
let payload: PlanResultPayload | null = null;
|
let payload: PlanResultPayload | null = null;
|
||||||
|
|
||||||
if (isProductReviewPlan) {
|
if (isProductReviewPlan) {
|
||||||
@@ -140,6 +144,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTransfer = () => {
|
const handleTransfer = () => {
|
||||||
|
if (readOnly) return;
|
||||||
if (!transferTo) return;
|
if (!transferTo) return;
|
||||||
updatePlan(plan.id, { owner: transferTo });
|
updatePlan(plan.id, { owner: transferTo });
|
||||||
setShowTransfer(false);
|
setShowTransfer(false);
|
||||||
@@ -272,7 +277,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transfer Section */}
|
{/* Transfer Section */}
|
||||||
{showTransfer && (
|
{showTransfer && !readOnly && (
|
||||||
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
|
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
|
||||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">转交给</div>
|
<div className="text-[11px] font-medium text-[var(--ink-muted)]">转交给</div>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -289,7 +294,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Complete with result */}
|
{/* Complete with result */}
|
||||||
{showComplete && (
|
{showComplete && !readOnly && (
|
||||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
||||||
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
|
<div className="text-[11px] font-medium text-emerald-700">{getSubmitActionLabel(plan)}</div>
|
||||||
{isProductReviewPlan ? (
|
{isProductReviewPlan ? (
|
||||||
@@ -382,7 +387,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
{plan.status !== 'completed' && (
|
{plan.status !== 'completed' && !readOnly && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||||
{plan.status === 'pending' && (
|
{plan.status === 'pending' && (
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface Props {
|
|||||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||||
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
|
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
||||||
@@ -71,7 +72,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||||
@@ -321,10 +322,11 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
onEditPlan={setEditingPlan}
|
onEditPlan={setEditingPlan}
|
||||||
onOpenComplete={setCompletingPlan}
|
onOpenComplete={setCompletingPlan}
|
||||||
onCreatePlan={() => setShowCreateModal(true)}
|
onCreatePlan={() => setShowCreateModal(true)}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(showCreateModal || editingPlan) && (
|
{(showCreateModal || editingPlan) && !readOnly && (
|
||||||
<PlanFormModal
|
<PlanFormModal
|
||||||
initial={editingPlan}
|
initial={editingPlan}
|
||||||
planType={planType}
|
planType={planType}
|
||||||
@@ -343,7 +345,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{completingPlan && (
|
{completingPlan && !readOnly && (
|
||||||
<CompleteModal
|
<CompleteModal
|
||||||
plan={completingPlan}
|
plan={completingPlan}
|
||||||
onClose={() => setCompletingPlan(null)}
|
onClose={() => setCompletingPlan(null)}
|
||||||
@@ -402,6 +404,7 @@ function ProductUiPlanWorkspace({
|
|||||||
onEditPlan,
|
onEditPlan,
|
||||||
onOpenComplete,
|
onOpenComplete,
|
||||||
onCreatePlan,
|
onCreatePlan,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
typePlans: VersionPlan[];
|
typePlans: VersionPlan[];
|
||||||
planType: VersionPlan['type'];
|
planType: VersionPlan['type'];
|
||||||
@@ -421,6 +424,7 @@ function ProductUiPlanWorkspace({
|
|||||||
onEditPlan: (plan: VersionPlan) => void;
|
onEditPlan: (plan: VersionPlan) => void;
|
||||||
onOpenComplete: (plan: VersionPlan) => void;
|
onOpenComplete: (plan: VersionPlan) => void;
|
||||||
onCreatePlan: () => void;
|
onCreatePlan: () => void;
|
||||||
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
||||||
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
||||||
@@ -428,23 +432,26 @@ function ProductUiPlanWorkspace({
|
|||||||
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (readOnly) return;
|
||||||
typePlans.forEach((plan) => {
|
typePlans.forEach((plan) => {
|
||||||
const { autoStarted } = getPlanRuntime(plan);
|
const { autoStarted } = getPlanRuntime(plan);
|
||||||
if (autoStarted && !plan.actualStartAt) {
|
if (autoStarted && !plan.actualStartAt) {
|
||||||
onUpdate(plan.id, { status: 'in_progress' });
|
onUpdate(plan.id, { status: 'in_progress' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [typePlans, onUpdate]);
|
}, [typePlans, onUpdate, readOnly]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0">
|
<div className="flex h-full min-h-0">
|
||||||
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-[var(--line)] px-4">
|
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-[var(--line)] px-4">
|
||||||
<div className="text-[14px] font-semibold text-[var(--ink)]">任务计划</div>
|
<div className="text-[14px] font-semibold text-[var(--ink)]">任务计划</div>
|
||||||
|
{!readOnly && (
|
||||||
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
新建
|
新建
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 space-y-2 border-b border-[var(--line)] p-3">
|
<div className="shrink-0 space-y-2 border-b border-[var(--line)] p-3">
|
||||||
<div className={`grid gap-2 ${versionDeadline ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
<div className={`grid gap-2 ${versionDeadline ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
@@ -530,6 +537,7 @@ function ProductUiPlanWorkspace({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onEditPlan={onEditPlan}
|
onEditPlan={onEditPlan}
|
||||||
onOpenComplete={onOpenComplete}
|
onOpenComplete={onOpenComplete}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||||
@@ -563,6 +571,7 @@ function ProductUiPlanDetail({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onEditPlan,
|
onEditPlan,
|
||||||
onOpenComplete,
|
onOpenComplete,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
plan: VersionPlan;
|
plan: VersionPlan;
|
||||||
planType: VersionPlan['type'];
|
planType: VersionPlan['type'];
|
||||||
@@ -579,12 +588,13 @@ function ProductUiPlanDetail({
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onEditPlan: (plan: VersionPlan) => void;
|
onEditPlan: (plan: VersionPlan) => void;
|
||||||
onOpenComplete: (plan: VersionPlan) => void;
|
onOpenComplete: (plan: VersionPlan) => void;
|
||||||
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
|
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
|
||||||
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
||||||
const completionState = getPlanCompletionState(plan);
|
const completionState = getPlanCompletionState(plan);
|
||||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
||||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||||
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
||||||
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
||||||
@@ -612,6 +622,7 @@ function ProductUiPlanDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</div>
|
<div className="mt-1 text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{plan.status === 'pending' && !autoStarted && (
|
{plan.status === 'pending' && !autoStarted && (
|
||||||
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
|
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
|
||||||
@@ -632,6 +643,7 @@ function ProductUiPlanDetail({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
||||||
@@ -676,7 +688,7 @@ function ProductUiPlanDetail({
|
|||||||
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="flex min-w-0 items-center gap-1 text-[12px] text-[var(--accent)] hover:underline">
|
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="flex min-w-0 items-center gap-1 text-[12px] text-[var(--accent)] hover:underline">
|
||||||
<span className="truncate">{plan.resultTitle || plan.resultFileName || '查看成果'}</span><ExternalLink className="h-3 w-3 shrink-0" />
|
<span className="truncate">{plan.resultTitle || plan.resultFileName || '查看成果'}</span><ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
{planType === 'product' && version && (
|
{planType === 'product' && version && !readOnly && (
|
||||||
<AiDecomposeButton plan={plan} version={version} />
|
<AiDecomposeButton plan={plan} version={version} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -720,7 +732,7 @@ function ProductUiPlanDetail({
|
|||||||
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{transferPlanId === plan.id && (
|
{transferPlanId === plan.id && !readOnly && (
|
||||||
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
|
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
|
||||||
<span className="text-[11px] text-[var(--ink-muted)]">转交给:</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">转交给:</span>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@@ -735,7 +747,7 @@ function ProductUiPlanDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
{plan.status !== 'completed' && completionState.canSubmitResult && !readOnly && (
|
||||||
<div className="mt-3 flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-3 py-2">
|
<div className="mt-3 flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-3 py-2">
|
||||||
<span className="text-[12px] text-green-700">已满足{getSubmitActionLabel(plan)}条件</span>
|
<span className="text-[12px] text-green-700">已满足{getSubmitActionLabel(plan)}条件</span>
|
||||||
<button onClick={() => onOpenComplete(plan)} className="text-[11px] font-medium text-green-700 underline hover:text-green-900">{getSubmitActionLabel(plan)}</button>
|
<button onClick={() => onOpenComplete(plan)} className="text-[11px] font-medium text-green-700 underline hover:text-green-900">{getSubmitActionLabel(plan)}</button>
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ interface Props {
|
|||||||
onUnlink: (reqId: string) => void;
|
onUnlink: (reqId: string) => void;
|
||||||
onCreateChange: (data: Partial<Requirement>) => void;
|
onCreateChange: (data: Partial<Requirement>) => void;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName }: Props) {
|
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName, readOnly = false }: Props) {
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||||
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
|
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
|
||||||
@@ -31,6 +32,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} 条关联需求</span>
|
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} 条关联需求</span>
|
||||||
|
{!readOnly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
|
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
|
||||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||||
@@ -41,6 +43,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
添加需求
|
添加需求
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{linkedReqs.length === 0 ? (
|
{linkedReqs.length === 0 ? (
|
||||||
@@ -61,7 +64,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
<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)] text-right">操作</th>
|
{!readOnly && <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right">操作</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -95,6 +98,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
|
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
|
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
|
||||||
|
{!readOnly && (
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{isDeveloping ? (
|
{isDeveloping ? (
|
||||||
<span className="text-[11px] text-[var(--ink-muted)]">开发中</span>
|
<span className="text-[11px] text-[var(--ink-muted)]">开发中</span>
|
||||||
@@ -102,6 +106,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">移除</button>
|
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">移除</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -110,7 +115,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAddModal && (
|
{showAddModal && !readOnly && (
|
||||||
<AddRequirementModal
|
<AddRequirementModal
|
||||||
available={availableReqs}
|
available={availableReqs}
|
||||||
onClose={() => setShowAddModal(false)}
|
onClose={() => setShowAddModal(false)}
|
||||||
@@ -118,7 +123,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showChangeModal && (
|
{showChangeModal && !readOnly && (
|
||||||
<ChangeRequirementModal
|
<ChangeRequirementModal
|
||||||
versionMembers={versionMembers}
|
versionMembers={versionMembers}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
snapshots,
|
snapshots,
|
||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
|
insightRequestAttempts,
|
||||||
riskDataLoaded,
|
riskDataLoaded,
|
||||||
fetchRiskData,
|
fetchRiskData,
|
||||||
saveSnapshot,
|
saveSnapshot,
|
||||||
@@ -122,6 +123,7 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
snapshots,
|
snapshots,
|
||||||
insights,
|
insights,
|
||||||
pendingInsightKeys,
|
pendingInsightKeys,
|
||||||
|
insightRequestAttempts,
|
||||||
riskDataLoaded,
|
riskDataLoaded,
|
||||||
saveSnapshot,
|
saveSnapshot,
|
||||||
saveInsight,
|
saveInsight,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface VersionLinks {
|
|||||||
interface ProductOverviewLike {
|
interface ProductOverviewLike {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
projects: { id: string; name: string; description: string; createdAt: string }[];
|
projects: { id: string; name: string; description: string; createdAt: string; status?: string }[];
|
||||||
versions: {
|
versions: {
|
||||||
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
|
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
|
||||||
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
|
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
|
||||||
@@ -37,6 +37,7 @@ export interface ProjectWithContext {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
status?: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
versions: VersionWithContext[];
|
versions: VersionWithContext[];
|
||||||
@@ -52,6 +53,7 @@ export interface VersionWithContext {
|
|||||||
productName: string;
|
productName: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
projectStatus?: string;
|
||||||
currentStage?: Stage;
|
currentStage?: Stage;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
expectedReleaseDate?: string | null;
|
expectedReleaseDate?: string | null;
|
||||||
@@ -74,6 +76,7 @@ export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithCon
|
|||||||
productName: product.name,
|
productName: product.name,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
|
projectStatus: project.status,
|
||||||
}));
|
}));
|
||||||
result.push({
|
result.push({
|
||||||
...project,
|
...project,
|
||||||
@@ -99,6 +102,7 @@ export function flattenVersions(overview: ProductOverviewLike[]): VersionWithCon
|
|||||||
productId: product.id,
|
productId: product.id,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
projectId: project?.id || '',
|
projectId: project?.id || '',
|
||||||
|
projectStatus: project?.status,
|
||||||
projectName: project?.name || '未关联',
|
projectName: project?.name || '未关联',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
63
apps/web/lib/requirement-sort.test.ts
Normal file
63
apps/web/lib/requirement-sort.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import type { Requirement } from './requirement';
|
||||||
|
import { sortRequirementsByCreatedAt } from './requirement-sort';
|
||||||
|
|
||||||
|
const baseRequirement = {
|
||||||
|
description: '',
|
||||||
|
productId: 'product-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
sourceType: 'internal',
|
||||||
|
sourceTarget: '',
|
||||||
|
platforms: [] as string[],
|
||||||
|
typeId: 'type-1',
|
||||||
|
status: 'pending_review',
|
||||||
|
priority: 'P2',
|
||||||
|
effort: 'M',
|
||||||
|
creator: 'tester',
|
||||||
|
};
|
||||||
|
|
||||||
|
function req(id: string, code: string, createdAt: string): Requirement {
|
||||||
|
return {
|
||||||
|
...baseRequirement,
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
title: code,
|
||||||
|
createdAt,
|
||||||
|
} as Requirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sorts requirements by created time descending by default', () => {
|
||||||
|
const requirements = [
|
||||||
|
req('req-100', 'REQ-001', '2026-06-28T09:00:00.000Z'),
|
||||||
|
req('req-300', 'REQ-003', '2026-06-29T09:00:00.000Z'),
|
||||||
|
req('req-200', 'REQ-002', '2026-06-28T18:00:00.000Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(sortRequirementsByCreatedAt(requirements).map((item) => item.id), ['req-300', 'req-200', 'req-100']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses id/code fallback when legacy requirements only have the same date', () => {
|
||||||
|
const requirements = [
|
||||||
|
req('req-1782301644000', 'REQ-010', '2026-06-24'),
|
||||||
|
req('req-1782301644789', 'REQ-011', '2026-06-24'),
|
||||||
|
req('req-old', 'REQ-009', '2026-06-24'),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(sortRequirementsByCreatedAt(requirements).map((item) => item.id), [
|
||||||
|
'req-1782301644789',
|
||||||
|
'req-1782301644000',
|
||||||
|
'req-old',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can sort requirements by created time ascending', () => {
|
||||||
|
const requirements = [
|
||||||
|
req('req-300', 'REQ-003', '2026-06-29T09:00:00.000Z'),
|
||||||
|
req('req-100', 'REQ-001', '2026-06-28T09:00:00.000Z'),
|
||||||
|
req('req-200', 'REQ-002', '2026-06-28T18:00:00.000Z'),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(sortRequirementsByCreatedAt(requirements, 'asc').map((item) => item.id), ['req-100', 'req-200', 'req-300']);
|
||||||
|
});
|
||||||
39
apps/web/lib/requirement-sort.ts
Normal file
39
apps/web/lib/requirement-sort.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Requirement } from './requirement';
|
||||||
|
|
||||||
|
export type RequirementDateSort = 'desc' | 'asc';
|
||||||
|
|
||||||
|
export function sortRequirementsByCreatedAt(
|
||||||
|
requirements: Requirement[],
|
||||||
|
direction: RequirementDateSort = 'desc',
|
||||||
|
): Requirement[] {
|
||||||
|
const multiplier = direction === 'asc' ? 1 : -1;
|
||||||
|
return [...requirements].sort((a, b) => compareRequirementCreatedAt(a, b) * multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRequirementCreatedAt(a: Requirement, b: Requirement): number {
|
||||||
|
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||||
|
if (createdAtDiff !== 0) return createdAtDiff;
|
||||||
|
|
||||||
|
const idDiff = trailingNumber(a.id) - trailingNumber(b.id);
|
||||||
|
if (idDiff !== 0) return idDiff;
|
||||||
|
|
||||||
|
const codeDiff = trailingNumber(a.code) - trailingNumber(b.code);
|
||||||
|
if (codeDiff !== 0) return codeDiff;
|
||||||
|
|
||||||
|
const codeTextDiff = a.code.localeCompare(b.code);
|
||||||
|
if (codeTextDiff !== 0) return codeTextDiff;
|
||||||
|
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimestamp(value: string): number {
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trailingNumber(value: string): number {
|
||||||
|
const matches = value.match(/\d+/g);
|
||||||
|
if (!matches) return 0;
|
||||||
|
const numericValue = Number(matches[matches.length - 1]);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
calcPersonalEffortRanking,
|
calcPersonalEffortRanking,
|
||||||
calcStageEffortMetrics,
|
calcStageEffortMetrics,
|
||||||
calcVersionOverviewEffortTotals,
|
calcVersionOverviewEffortTotals,
|
||||||
|
buildVersionTimelineSummary,
|
||||||
|
formatVersionOverviewDateTime,
|
||||||
|
getVersionCardDefaultExpanded,
|
||||||
|
mergeStageProgressWithEffort,
|
||||||
} from './version-overview';
|
} from './version-overview';
|
||||||
import type { VersionPlan } from './version-plan';
|
import type { VersionPlan } from './version-plan';
|
||||||
import type { DevTask } from './dev-task';
|
import type { DevTask } from './dev-task';
|
||||||
@@ -131,6 +135,60 @@ test('calcStageEffortMetrics returns actual hours and AI estimates per stage', (
|
|||||||
assert.equal(metrics.bug.actualHours, 0.5);
|
assert.equal(metrics.bug.actualHours, 0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mergeStageProgressWithEffort keeps progress state and adds stage effort fields', () => {
|
||||||
|
const metrics = calcStageEffortMetrics({
|
||||||
|
plans: [],
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
actualStartAt: '2026-06-24T09:00:00',
|
||||||
|
actualEndAt: '2026-06-24T14:00:00',
|
||||||
|
estimateHours: 4.5,
|
||||||
|
aiEstimateHours: 3.25,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
testCases: [
|
||||||
|
testCase({
|
||||||
|
startedAt: '2026-06-25T09:00:00',
|
||||||
|
completedAt: '2026-06-25T11:00:00',
|
||||||
|
estimateHours: 2.25,
|
||||||
|
aiEstimateHours: 1.5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bugs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeStageProgressWithEffort(
|
||||||
|
{
|
||||||
|
dev: { percent: 50, status: 'active' },
|
||||||
|
testing: { percent: 100, status: 'done' },
|
||||||
|
},
|
||||||
|
metrics,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(merged.dev.percent, 50);
|
||||||
|
assert.equal(merged.dev.status, 'active');
|
||||||
|
assert.equal(merged.dev.actualHours, 4);
|
||||||
|
assert.equal(merged.dev.estimateHours, 4.5);
|
||||||
|
assert.equal(merged.dev.aiEstimateHours, 3.25);
|
||||||
|
assert.equal(merged.dev.showEstimates, true);
|
||||||
|
assert.equal(merged.testing.percent, 100);
|
||||||
|
assert.equal(merged.testing.status, 'done');
|
||||||
|
assert.equal(merged.testing.actualHours, 2);
|
||||||
|
assert.equal(merged.testing.estimateHours, 2.25);
|
||||||
|
assert.equal(merged.testing.aiEstimateHours, 1.5);
|
||||||
|
assert.equal(merged.requirement.percent, 0);
|
||||||
|
assert.equal(merged.requirement.status, 'idle');
|
||||||
|
assert.equal(merged.requirement.actualHours, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVersionCardDefaultExpanded expands only in-progress versions by default', () => {
|
||||||
|
assert.equal(getVersionCardDefaultExpanded('developing'), true);
|
||||||
|
assert.equal(getVersionCardDefaultExpanded('released'), false);
|
||||||
|
assert.equal(getVersionCardDefaultExpanded('closed'), false);
|
||||||
|
assert.equal(getVersionCardDefaultExpanded('paused'), false);
|
||||||
|
assert.equal(getVersionCardDefaultExpanded('planned'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
|
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
|
||||||
const metrics = calcStageEffortMetrics({
|
const metrics = calcStageEffortMetrics({
|
||||||
plans: [],
|
plans: [],
|
||||||
@@ -188,6 +246,58 @@ test('calcVersionOverviewEffortTotals sums actual hours and overtime records sep
|
|||||||
assert.equal(totals.overtimeHours, 3.3);
|
assert.equal(totals.overtimeHours, 3.3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildVersionTimelineSummary uses the same timing sources as version detail', () => {
|
||||||
|
const summary = buildVersionTimelineSummary({
|
||||||
|
status: 'closed',
|
||||||
|
startDate: '2026-06-20',
|
||||||
|
expectedReleaseDate: '2026-06-27',
|
||||||
|
releaseDate: '2026-06-30T10:00:00',
|
||||||
|
plans: [
|
||||||
|
plan({
|
||||||
|
type: 'research',
|
||||||
|
actualStartAt: '2026-06-22T09:00:00',
|
||||||
|
completedAt: '2026-06-22T18:00:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
devTasks: [
|
||||||
|
devTask({
|
||||||
|
actualStartAt: '2026-06-23T09:00:00',
|
||||||
|
actualEndAt: '2026-06-23T12:00:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
testCases: [
|
||||||
|
testCase({
|
||||||
|
startedAt: '2026-06-24T09:00:00',
|
||||||
|
completedAt: '2026-06-24T11:00:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bugs: [
|
||||||
|
bug({
|
||||||
|
status: 'closed',
|
||||||
|
createdAt: '2026-06-25T10:00:00',
|
||||||
|
resolvedAt: '2026-06-25T16:00:00',
|
||||||
|
closedAt: undefined,
|
||||||
|
updatedAt: '2026-06-25T17:00:00',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now: new Date('2026-06-26T18:00:00'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(summary.actualStartIso, '2026-06-22T09:00:00');
|
||||||
|
assert.equal(summary.expectedReleaseIso, '2026-06-27');
|
||||||
|
assert.equal(summary.actualReleaseIso, '2026-06-30T10:00:00');
|
||||||
|
assert.equal(summary.actualEndIso, '2026-06-25T16:00:00');
|
||||||
|
assert.equal(summary.isTerminalVersion, true);
|
||||||
|
assert.equal(summary.actualHours, 30);
|
||||||
|
assert.equal(summary.overdueDays, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatVersionOverviewDateTime matches the version detail display format', () => {
|
||||||
|
assert.equal(formatVersionOverviewDateTime('2026-06-22T09:30:00'), '2026-06-22 09:30');
|
||||||
|
assert.equal(formatVersionOverviewDateTime('2026-06-27'), '2026-06-27');
|
||||||
|
assert.equal(formatVersionOverviewDateTime(null), '-');
|
||||||
|
});
|
||||||
|
|
||||||
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
|
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
|
||||||
const overtimeRecords: OvertimeRecord[] = [
|
const overtimeRecords: OvertimeRecord[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { Stage } from './stage';
|
import { STAGES, type Stage } from './stage';
|
||||||
import type { VersionPlan } from './version-plan';
|
import type { VersionPlan } from './version-plan';
|
||||||
import type { DevTask } from './dev-task';
|
import type { DevTask } from './dev-task';
|
||||||
import type { TestCase } from './test-case';
|
import type { TestCase } from './test-case';
|
||||||
import type { Bug, BugSeverity } from './bug';
|
import type { Bug, BugSeverity } from './bug';
|
||||||
import type { OvertimeRecord } from './overtime';
|
import type { OvertimeRecord } from './overtime';
|
||||||
|
import type { VersionStatus } from './version-status';
|
||||||
import { getActualHours as getDevTaskActualHours } from './dev-task';
|
import { getActualHours as getDevTaskActualHours } from './dev-task';
|
||||||
import { getTestCaseActualHours } from './test-case';
|
import { getTestCaseActualHours } from './test-case';
|
||||||
import { getBugActualHours } from './bug';
|
import { getBugActualHours } from './bug';
|
||||||
import { calcActualElapsedHours } from './work-hours';
|
import { calcActualElapsedHours } from './work-hours';
|
||||||
|
import { formatDateTime } from './format';
|
||||||
|
|
||||||
export interface StageEffortMetric {
|
export interface StageEffortMetric {
|
||||||
actualHours: number;
|
actualHours: number;
|
||||||
@@ -16,6 +18,13 @@ export interface StageEffortMetric {
|
|||||||
showEstimates?: boolean;
|
showEstimates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StageProgressState {
|
||||||
|
percent: number;
|
||||||
|
status: 'idle' | 'active' | 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StageProgressWithEffort = StageProgressState & StageEffortMetric;
|
||||||
|
|
||||||
export interface PersonalEffortItem {
|
export interface PersonalEffortItem {
|
||||||
name: string;
|
name: string;
|
||||||
actualHours: number;
|
actualHours: number;
|
||||||
@@ -37,6 +46,16 @@ export interface VersionOverviewEffortTotals {
|
|||||||
overtimeHours: number;
|
overtimeHours: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VersionTimelineSummary {
|
||||||
|
actualStartIso: string | null;
|
||||||
|
expectedReleaseIso: string | null;
|
||||||
|
actualReleaseIso: string | null;
|
||||||
|
actualEndIso: string | null;
|
||||||
|
isTerminalVersion: boolean;
|
||||||
|
actualHours: number;
|
||||||
|
overdueDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
function roundHalf(hours: number): number {
|
function roundHalf(hours: number): number {
|
||||||
return Math.round(hours * 2) / 2;
|
return Math.round(hours * 2) / 2;
|
||||||
}
|
}
|
||||||
@@ -117,6 +136,100 @@ export function calcStageEffortMetrics(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeStageProgressWithEffort(
|
||||||
|
progress: Partial<Record<Stage, StageProgressState>>,
|
||||||
|
effortMetrics: Record<Stage, StageEffortMetric>,
|
||||||
|
): Record<Stage, StageProgressWithEffort> {
|
||||||
|
return STAGES.reduce((acc, stage) => {
|
||||||
|
const state = progress[stage.key];
|
||||||
|
acc[stage.key] = {
|
||||||
|
percent: state?.percent ?? 0,
|
||||||
|
status: state?.status ?? 'idle',
|
||||||
|
...effortMetrics[stage.key],
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<Stage, StageProgressWithEffort>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVersionCardDefaultExpanded(status: VersionStatus): boolean {
|
||||||
|
return status === 'developing';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVersionOverviewDateTime(value?: string | null): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
return value.includes('T') ? formatDateTime(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVersionTimelineSummary(input: {
|
||||||
|
status: VersionStatus;
|
||||||
|
startDate?: string | null;
|
||||||
|
expectedReleaseDate?: string | null;
|
||||||
|
releaseDate?: string | null;
|
||||||
|
plans: VersionPlan[];
|
||||||
|
devTasks: DevTask[];
|
||||||
|
testCases: TestCase[];
|
||||||
|
bugs: Bug[];
|
||||||
|
now?: Date;
|
||||||
|
}): VersionTimelineSummary {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
const startDates: string[] = [];
|
||||||
|
|
||||||
|
input.plans.forEach((plan) => {
|
||||||
|
if (plan.actualStartAt) {
|
||||||
|
startDates.push(plan.actualStartAt);
|
||||||
|
} else if (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= now) {
|
||||||
|
startDates.push(plan.startTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.devTasks.forEach((task) => {
|
||||||
|
if (task.actualStartAt) startDates.push(task.actualStartAt);
|
||||||
|
});
|
||||||
|
input.testCases.forEach((testCase) => {
|
||||||
|
if (testCase.startedAt) startDates.push(testCase.startedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (input.startDate ?? null);
|
||||||
|
|
||||||
|
const endDates: string[] = [];
|
||||||
|
input.plans.forEach((plan) => {
|
||||||
|
if (plan.completedAt) endDates.push(plan.completedAt);
|
||||||
|
});
|
||||||
|
input.devTasks.forEach((task) => {
|
||||||
|
if (task.actualEndAt) endDates.push(task.actualEndAt);
|
||||||
|
});
|
||||||
|
input.testCases.forEach((testCase) => {
|
||||||
|
if (testCase.completedAt) endDates.push(testCase.completedAt);
|
||||||
|
});
|
||||||
|
input.bugs.forEach((bug) => {
|
||||||
|
if (bug.closedAt) endDates.push(bug.closedAt);
|
||||||
|
else if (bug.resolvedAt) endDates.push(bug.resolvedAt);
|
||||||
|
else if ((bug.status === 'closed' || bug.status === 'rejected') && bug.updatedAt) endDates.push(bug.updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
|
||||||
|
const isTerminalVersion = input.status === 'released' || input.status === 'closed';
|
||||||
|
const actualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
||||||
|
|
||||||
|
let overdueDays = 0;
|
||||||
|
if (input.expectedReleaseDate && input.releaseDate) {
|
||||||
|
const endDate = new Date(input.releaseDate);
|
||||||
|
const deadlineDate = new Date(input.expectedReleaseDate);
|
||||||
|
endDate.setHours(0, 0, 0, 0);
|
||||||
|
deadlineDate.setHours(0, 0, 0, 0);
|
||||||
|
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualStartIso,
|
||||||
|
expectedReleaseIso: input.expectedReleaseDate ?? null,
|
||||||
|
actualReleaseIso: input.releaseDate ?? null,
|
||||||
|
actualEndIso,
|
||||||
|
isTerminalVersion,
|
||||||
|
actualHours,
|
||||||
|
overdueDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function calcVersionOverviewEffortTotals(input: {
|
export function calcVersionOverviewEffortTotals(input: {
|
||||||
plans: VersionPlan[];
|
plans: VersionPlan[];
|
||||||
devTasks: DevTask[];
|
devTasks: DevTask[];
|
||||||
|
|||||||
25
apps/web/lib/version-status.test.ts
Normal file
25
apps/web/lib/version-status.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getVersionReadonlyNotice, isVersionReadonly } from './version-status';
|
||||||
|
|
||||||
|
test('isVersionReadonly locks released closed and paused versions', () => {
|
||||||
|
assert.equal(isVersionReadonly('released'), true);
|
||||||
|
assert.equal(isVersionReadonly('closed'), true);
|
||||||
|
assert.equal(isVersionReadonly('paused'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isVersionReadonly allows planned and developing versions to be edited', () => {
|
||||||
|
assert.equal(isVersionReadonly('planned'), false);
|
||||||
|
assert.equal(isVersionReadonly('developing'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVersionReadonlyNotice explains locked version task actions', () => {
|
||||||
|
assert.equal(getVersionReadonlyNotice('released'), '已发布版本不可编辑或删除任务');
|
||||||
|
assert.equal(getVersionReadonlyNotice('paused'), '已暂停版本不可编辑或删除任务');
|
||||||
|
assert.equal(getVersionReadonlyNotice('closed'), '已关闭版本不可编辑或删除任务');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getVersionReadonlyNotice stays empty for editable versions', () => {
|
||||||
|
assert.equal(getVersionReadonlyNotice('developing'), null);
|
||||||
|
assert.equal(getVersionReadonlyNotice('planned'), null);
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
|
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
|
||||||
|
|
||||||
|
const READONLY_VERSION_STATUSES = new Set<VersionStatus>(['released', 'closed', 'paused']);
|
||||||
|
|
||||||
|
export function isVersionReadonly(status: VersionStatus): boolean {
|
||||||
|
return READONLY_VERSION_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
||||||
developing: '进行中',
|
developing: '进行中',
|
||||||
planned: '规划中',
|
planned: '规划中',
|
||||||
@@ -8,6 +14,11 @@ export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
|||||||
closed: '已关闭',
|
closed: '已关闭',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getVersionReadonlyNotice(status: VersionStatus): string | null {
|
||||||
|
if (!isVersionReadonly(status)) return null;
|
||||||
|
return `${VERSION_STATUS_LABEL[status]}版本不可编辑或删除任务`;
|
||||||
|
}
|
||||||
|
|
||||||
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
|
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
|
||||||
developing: 'bg-blue-500',
|
developing: 'bg-blue-500',
|
||||||
planned: 'bg-orange-500',
|
planned: 'bg-orange-500',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findPreviousRiskSnapshot,
|
findPreviousRiskSnapshot,
|
||||||
getReusableInsight,
|
getReusableInsight,
|
||||||
shouldRequestRiskInsight,
|
shouldRequestRiskInsight,
|
||||||
|
shouldRequestRiskInsightWithRequestGate,
|
||||||
shouldRequestRiskInsightWithCacheGate,
|
shouldRequestRiskInsightWithCacheGate,
|
||||||
shouldRequestRiskInsightWithCooldown,
|
shouldRequestRiskInsightWithCooldown,
|
||||||
} from './xiaobao-risk-ai';
|
} from './xiaobao-risk-ai';
|
||||||
@@ -283,6 +284,22 @@ test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cach
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shouldRequestRiskInsightWithRequestGate skips repeat requests during request cooldown without cache', () => {
|
||||||
|
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRequestRiskInsightWithRequestGate({
|
||||||
|
riskCacheLoaded: true,
|
||||||
|
cache: [],
|
||||||
|
current,
|
||||||
|
previous: snapshot({ riskScore: 45 }),
|
||||||
|
lastRequestedAt: '2026-06-29T10:30:00.000Z',
|
||||||
|
now: new Date('2026-06-29T11:00:00.000Z'),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
|
||||||
const base = buildRiskInsightSignature(risk({
|
const base = buildRiskInsightSignature(risk({
|
||||||
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type RiskInsightCurrent = Pick<
|
|||||||
'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend'
|
'riskLevel' | 'riskScore' | 'confidence' | 'forecastReleaseDate' | 'signals' | 'trend'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type RiskInsightPrevious = Pick<
|
export type RiskInsightPrevious = Pick<
|
||||||
XiaobaoRiskSnapshot,
|
XiaobaoRiskSnapshot,
|
||||||
| 'riskScore'
|
| 'riskScore'
|
||||||
| 'confidence'
|
| 'confidence'
|
||||||
@@ -103,6 +103,35 @@ export function shouldRequestRiskInsightWithCacheGate(
|
|||||||
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
return shouldRequestRiskInsightWithCooldown(current, previous, latestInsight, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RiskInsightRequestGateInput {
|
||||||
|
riskCacheLoaded: boolean;
|
||||||
|
cache: XiaobaoRiskInsightCacheItem[];
|
||||||
|
current: XiaobaoVersionRisk;
|
||||||
|
previous?: RiskInsightPrevious;
|
||||||
|
lastRequestedAt?: string;
|
||||||
|
now?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequestRiskInsightWithRequestGate(input: RiskInsightRequestGateInput): boolean {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
if (isRiskInsightRequestCoolingDown(input.lastRequestedAt, now)) return false;
|
||||||
|
return shouldRequestRiskInsightWithCacheGate(
|
||||||
|
input.riskCacheLoaded,
|
||||||
|
input.cache,
|
||||||
|
input.current,
|
||||||
|
input.previous,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRiskInsightRequestCoolingDown(lastRequestedAt: string | undefined, now: Date = new Date()): boolean {
|
||||||
|
if (!lastRequestedAt) return false;
|
||||||
|
const requestedAt = getTime(lastRequestedAt);
|
||||||
|
const nowTime = now.getTime();
|
||||||
|
if (!Number.isFinite(requestedAt) || !Number.isFinite(nowTime)) return false;
|
||||||
|
return nowTime - requestedAt < RISK_INSIGHT_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
versionId: risk.versionId,
|
versionId: risk.versionId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
|||||||
import { buildRiskInsightSignature } from './xiaobao-risk-ai';
|
import { buildRiskInsightSignature } from './xiaobao-risk-ai';
|
||||||
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
import { attachXiaobaoRiskSuggestion } from './xiaobao-risk-suggestion';
|
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from './xiaobao-risk-suggestion';
|
||||||
|
|
||||||
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +91,7 @@ test('attachXiaobaoRiskSuggestion provides a rule suggestion when AI cache is em
|
|||||||
test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one is updating', () => {
|
test('attachXiaobaoRiskSuggestion keeps previous AI suggestion while a new one is updating', () => {
|
||||||
const previous = risk({ riskScore: 52, riskLevel: 'attention' });
|
const previous = risk({ riskScore: 52, riskLevel: 'attention' });
|
||||||
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
const current = risk({ riskScore: 82, riskLevel: 'at_risk', delayDays: 1 });
|
||||||
const pendingKey = `${current.versionId}:${buildRiskInsightSignature(current)}`;
|
const pendingKey = buildXiaobaoRiskInsightPendingKey(current);
|
||||||
|
|
||||||
const result = attachXiaobaoRiskSuggestion(current, {
|
const result = attachXiaobaoRiskSuggestion(current, {
|
||||||
insights: [insight(previous)],
|
insights: [insight(previous)],
|
||||||
@@ -125,3 +125,50 @@ test('attachXiaobaoRiskSuggestion uses the current AI suggestion when the signat
|
|||||||
assert.equal(result.aiInsightUpdating, false);
|
assert.equal(result.aiInsightUpdating, false);
|
||||||
assert.equal(result.aiInsight?.summary, '新的小宝建议');
|
assert.equal(result.aiInsight?.summary, '新的小宝建议');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildXiaobaoRiskInsightPendingKey stays stable for refresh-only risk drift', () => {
|
||||||
|
const before = risk({
|
||||||
|
riskLevel: 'likely_delayed',
|
||||||
|
riskScore: 100,
|
||||||
|
confidence: 65,
|
||||||
|
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
|
||||||
|
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 },
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 12,
|
||||||
|
totalActivityCount: 6,
|
||||||
|
todayActualHours: 1.5,
|
||||||
|
lastActivityAt: '2026-06-29T03:37:15.903Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const after = risk({
|
||||||
|
riskLevel: 'likely_delayed',
|
||||||
|
riskScore: 100,
|
||||||
|
confidence: 65,
|
||||||
|
forecastReleaseDate: '2026-07-15T06:21:38.597Z',
|
||||||
|
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 0 },
|
||||||
|
dailyEvidence: {
|
||||||
|
todayDeliveries: [],
|
||||||
|
todayProgress: [
|
||||||
|
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },
|
||||||
|
],
|
||||||
|
todayCreations: [],
|
||||||
|
todayRisks: [],
|
||||||
|
progressNotes: [],
|
||||||
|
needsProgressItems: [],
|
||||||
|
recentActivityCount: 13,
|
||||||
|
totalActivityCount: 6,
|
||||||
|
todayActualHours: 3,
|
||||||
|
lastActivityAt: '2026-06-29T06:21:38.597Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(buildXiaobaoRiskInsightPendingKey(before), buildXiaobaoRiskInsightPendingKey(after));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { buildRiskInsightSignature, findLatestRiskInsightForVersion, getReusableInsight } from './xiaobao-risk-ai';
|
import {
|
||||||
|
buildRiskInsightDisplaySignature,
|
||||||
|
buildRiskInsightSignature,
|
||||||
|
findLatestRiskInsightForVersion,
|
||||||
|
getReusableInsight,
|
||||||
|
} from './xiaobao-risk-ai';
|
||||||
import type { XiaobaoRiskInsight, XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
import type { XiaobaoRiskInsight, XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
||||||
import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk';
|
import type { RiskReason, XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
import { sanitizeRiskInsight } from './xiaobao-warning-view';
|
import { sanitizeRiskInsight } from './xiaobao-warning-view';
|
||||||
@@ -18,7 +23,7 @@ const RISK_LEVEL_LABEL: Record<XiaobaoVersionRisk['riskLevel'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string {
|
export function buildXiaobaoRiskInsightPendingKey(risk: XiaobaoVersionRisk): string {
|
||||||
return `${risk.versionId}:${buildRiskInsightSignature(risk)}`;
|
return `${risk.versionId}:${buildRiskInsightDisplaySignature(risk)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachXiaobaoRiskSuggestion(
|
export function attachXiaobaoRiskSuggestion(
|
||||||
|
|||||||
@@ -88,6 +88,25 @@ test('filterXiaobaoWarningVersions lets managers see every unfinished version',
|
|||||||
assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']);
|
assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('filterXiaobaoWarningVersions excludes paused released closed versions and paused projects', () => {
|
||||||
|
const versions = [
|
||||||
|
version({ id: 'ver-1', status: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-2', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-4', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
version({ id: 'ver-5', status: 'developing', projectStatus: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoWarningVersions(versions, { canManage: true, userName: 'Alice' }).map((item) => item.id),
|
||||||
|
['ver-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterXiaobaoWarningVersions(versions, { canManage: false, userName: 'Alice' }).map((item) => item.id),
|
||||||
|
['ver-2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => {
|
test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => {
|
||||||
const result = filterXiaobaoWarningVersions(
|
const result = filterXiaobaoWarningVersions(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
|||||||
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
|
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
|
||||||
import { WORK_HOURS } from './work-hours';
|
import { WORK_HOURS } from './work-hours';
|
||||||
|
|
||||||
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
const WARNING_VERSION_STATUSES = new Set(['planned', 'developing']);
|
||||||
|
const PAUSED_PROJECT_STATUSES = new Set(['paused']);
|
||||||
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
||||||
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
||||||
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
||||||
@@ -41,7 +42,8 @@ export function filterXiaobaoWarningVersions(
|
|||||||
filter: XiaobaoWarningVersionFilter,
|
filter: XiaobaoWarningVersionFilter,
|
||||||
): VersionWithContext[] {
|
): VersionWithContext[] {
|
||||||
return versions.filter((version) => {
|
return versions.filter((version) => {
|
||||||
if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false;
|
if (!WARNING_VERSION_STATUSES.has(version.status)) return false;
|
||||||
|
if (version.projectStatus && PAUSED_PROJECT_STATUSES.has(version.projectStatus)) return false;
|
||||||
if (filter.canManage) return true;
|
if (filter.canManage) return true;
|
||||||
if (!filter.userName) return false;
|
if (!filter.userName) return false;
|
||||||
return (version.members ?? []).some((member) => member.name === filter.userName);
|
return (version.members ?? []).some((member) => member.name === filter.userName);
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
status: data.status ?? 'pending_review',
|
status: data.status ?? 'pending_review',
|
||||||
id: `req-${Date.now()}`,
|
id: `req-${Date.now()}`,
|
||||||
code,
|
code,
|
||||||
createdAt: new Date().toISOString().slice(0, 10),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const updated = [...requirements, newReq];
|
const updated = [...requirements, newReq];
|
||||||
set({ requirements: updated });
|
set({ requirements: updated });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface XiaobaoRiskState {
|
|||||||
snapshots: XiaobaoRiskSnapshot[];
|
snapshots: XiaobaoRiskSnapshot[];
|
||||||
insights: XiaobaoRiskInsightCacheItem[];
|
insights: XiaobaoRiskInsightCacheItem[];
|
||||||
pendingInsightKeys: string[];
|
pendingInsightKeys: string[];
|
||||||
|
insightRequestAttempts: Record<string, string>;
|
||||||
riskDataLoaded: boolean;
|
riskDataLoaded: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
fetchRiskData: () => Promise<void>;
|
fetchRiskData: () => Promise<void>;
|
||||||
@@ -46,6 +47,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
snapshots: [],
|
snapshots: [],
|
||||||
insights: [],
|
insights: [],
|
||||||
pendingInsightKeys: [],
|
pendingInsightKeys: [],
|
||||||
|
insightRequestAttempts: {},
|
||||||
riskDataLoaded: false,
|
riskDataLoaded: false,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
|
||||||
@@ -99,7 +101,13 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
beginInsightUpdate: (key) => {
|
beginInsightUpdate: (key) => {
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (get().pendingInsightKeys.includes(key)) return;
|
if (get().pendingInsightKeys.includes(key)) return;
|
||||||
set({ pendingInsightKeys: [...get().pendingInsightKeys, key] });
|
set({
|
||||||
|
pendingInsightKeys: [...get().pendingInsightKeys, key],
|
||||||
|
insightRequestAttempts: {
|
||||||
|
...get().insightRequestAttempts,
|
||||||
|
[key]: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
finishInsightUpdate: (key) => {
|
finishInsightUpdate: (key) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user