feat(版本): 完善研发计划与预警已读

关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 16:48:58 +08:00
parent 04520bd8c5
commit 3d3d56697a
41 changed files with 1701 additions and 121 deletions

View File

@@ -11,6 +11,7 @@ export const APP_DATA_KEYS = [
'work-activities',
'xiaobao-risk-insights',
'xiaobao-risk-snapshots',
'xiaobao-warning-views',
'overtime',
] as const;

View File

@@ -6,8 +6,9 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { flattenProjects, flattenVersions } from '@/lib/derive';
import { calcDuration } from '@/lib/overtime';
import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime';
import type { OvertimeRecord } from '@/lib/overtime';
import { Pagination, usePagination } from '@/components/Pagination';
import { DictDrawer } from '@/components/requirement/DictDrawer';
@@ -29,7 +30,9 @@ function OvertimePageContent() {
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
const { overview, fetchOverview } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { departments, members, roles, fetchMembers } = useMemberStore();
const user = useAuthStore((s) => s.user);
const viewerRole = useMemo(() => roles.find((role) => role.id === user?.roleId), [roles, user?.roleId]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
@@ -43,20 +46,27 @@ function OvertimePageContent() {
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-';
const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-';
const visibleRecords = useMemo(() => filterOvertimeRecordsForViewer(records, {
viewer: user,
viewerRole,
members,
departments,
}), [records, user, viewerRole, members, departments]);
const filtered = useMemo(() => {
let list = [...records];
let list = [...visibleRecords];
if (search) list = list.filter((r) => r.person.includes(search));
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter);
if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter);
list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
return list;
}, [records, search, projectFilter, reasonFilter, monthFilter]);
}, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -90,7 +100,7 @@ function OvertimePageContent() {
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]"></h1>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{records.length}</span>
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{visibleRecords.length}</span>
</div>
<div className="flex items-center gap-2">
<button onClick={handleExport} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
@@ -27,12 +27,14 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
import { hasPermission } from '@/lib/permissions';
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
import { formatDateTime } from '@/lib/format';
import { formatDateTime, formatLocalDate } from '@/lib/format';
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
import { getRequirementCoverageSummary } from '@/lib/version-plan';
import { buildVersionProgressMap } from '@/lib/version-progress';
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
function formatOverviewDateTime(value?: string | null): string {
if (!value) return '-';
@@ -104,6 +106,7 @@ export default function VersionDetailPage() {
}, [visibleTabs, activeTab]);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRecommendModal, setShowRecommendModal] = useState(false);
const [showReleaseModal, setShowReleaseModal] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
@@ -123,6 +126,11 @@ export default function VersionDetailPage() {
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const releaseProgressMap = useMemo<Record<string, number>>(
() => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {},
[version, plans, requirements, devTasks, testCases],
);
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
useEffect(() => {
@@ -199,7 +207,10 @@ export default function VersionDetailPage() {
});
const renderActions = () => {
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
if (version.status !== 'released' && version.status !== 'closed') {
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
}
if (version.status === 'planned') {
buttons.push({ label: '删除', action: () => {
if (confirm('确认删除该版本关联的需求会回到需求池版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
@@ -229,7 +240,7 @@ export default function VersionDetailPage() {
<button
key={btn.label}
onClick={btn.action}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{btn.label}
</button>
@@ -441,10 +452,11 @@ export default function VersionDetailPage() {
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
const deadline = version.expectedReleaseDate;
const actualReleaseDate = version.releaseDate;
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
let overdueDays = 0;
if (deadline && isTerminalVersion && actualEndIso) {
const endDate = new Date(actualEndIso);
if (deadline && actualReleaseDate) {
const endDate = new Date(actualReleaseDate);
const deadlineDate = new Date(deadline);
endDate.setHours(0, 0, 0, 0);
deadlineDate.setHours(0, 0, 0, 0);
@@ -453,7 +465,8 @@ export default function VersionDetailPage() {
const metrics = [
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
{ label: '预计截止', value: deadline ?? '未设置' },
{ label: '期望发版', value: deadline ? formatOverviewDateTime(deadline) : '未设置' },
{ label: '实际发版日期', value: actualReleaseDate ? formatOverviewDateTime(actualReleaseDate) : '未发布' },
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
@@ -462,7 +475,7 @@ export default function VersionDetailPage() {
return (
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-7">
{metrics.map((item) => (
<OverviewMetric
key={item.label}
@@ -476,7 +489,7 @@ export default function VersionDetailPage() {
</div>
{overdueDays > 0 && (
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
{overdueDays}
{overdueDays}
</div>
)}
</div>
@@ -930,6 +943,18 @@ export default function VersionDetailPage() {
/>
)}
{showReleaseModal && (
<ReleaseVersionModal
progress={releaseProgress}
initialDate={formatLocalDate()}
onSubmit={(releaseDate) => {
updateVersion(version.productId, version.id, { status: 'released', releaseDate });
setShowReleaseModal(false);
}}
onClose={() => setShowReleaseModal(false)}
/>
)}
{/* 参与人员设置弹窗 */}
{showMemberModal && (
<MemberSettingModal
@@ -946,6 +971,79 @@ export default function VersionDetailPage() {
);
}
function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: {
progress: number;
initialDate: string;
onSubmit: (releaseDate: string) => void;
onClose: () => void;
}) {
const [releaseDate, setReleaseDate] = useState(initialDate);
const warning = getReleaseProgressWarning(progress);
const canSubmit = canSubmitReleaseForm(releaseDate);
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
onSubmit(releaseDate.trim());
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
<form
onSubmit={handleSubmit}
className="w-full max-w-md rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-md"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
<div>
<h3 className="text-[15px] font-semibold text-[var(--ink)]"></h3>
<p className="mt-1 text-[11px] text-[var(--ink-muted)]"></p>
</div>
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
{warning ? (
<div className="flex gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] leading-5 text-amber-800">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
</div>
) : (
<div className="rounded-xl border border-emerald-100 bg-emerald-50 px-3 py-2.5 text-[12px] font-medium text-emerald-700">
100%
</div>
)}
<label className="block">
<span className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-[var(--ink-soft)]">
<Calendar className="h-3.5 w-3.5" />
</span>
<input
type="date"
value={releaseDate}
onChange={(event) => setReleaseDate(event.target.value)}
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] outline-none focus:border-[var(--accent)]"
required
/>
</label>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-5 py-4">
<button type="button" onClick={onClose} className="h-8 rounded-md border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button type="submit" disabled={!canSubmit} className="h-8 rounded-md bg-emerald-600 px-4 text-[12px] font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50">
</button>
</div>
</form>
</div>
);
}
function OverviewMetric({ label, value, icon, sub, tone }: {
label: string;
value: string | number;

View File

@@ -20,6 +20,14 @@ import { buildVersionProgressMap } from '@/lib/version-progress';
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
import {
VERSION_DEVELOPMENT_TYPE_OPTIONS,
canSubmitNewVersionForm,
getRecommendedExpectedReleaseDate,
getRecommendedWorkDays,
getVersionDevelopmentTypeOption,
type VersionDevelopmentType,
} from '@/lib/version-form';
import { Pagination, usePagination } from '@/components/Pagination';
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
@@ -462,7 +470,7 @@ type IterationType = 'major' | 'minor' | 'patch';
interface NewVersionModalProps {
overview: any[];
onClose: () => void;
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string; members?: any[] }) => void;
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate: string; members?: any[] }) => void;
currentUserName: string;
}
@@ -510,6 +518,8 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
const [projectName, setProjectName] = useState('');
const [iterationType, setIterationType] = useState<IterationType | ''>('');
const [versionNumber, setVersionNumber] = useState('');
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
const [status] = useState<VersionStatus>('developing');
const [priority, setPriority] = useState<Priority>('P2');
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
@@ -526,6 +536,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
setProjectName('');
setIterationType('');
setVersionNumber('');
setDevelopmentType('');
setProductDesignCompleted(false);
setExpectedReleaseDate('');
};
// When project changes
@@ -535,6 +548,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
setProjectName(proj?.name ?? '');
setIterationType('');
setVersionNumber('');
setDevelopmentType('');
setProductDesignCompleted(false);
setExpectedReleaseDate('');
};
// When iteration type changes, auto-generate version number
@@ -552,7 +568,33 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
setVersionNumber(cleaned);
};
const canSubmit = productId && projectId && versionNumber && !submitting;
const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => {
setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned }));
};
const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => {
setDevelopmentType(type);
applyRecommendedReleaseDate(type, productDesignCompleted);
};
const handleProductDesignCompletedChange = (completed: boolean) => {
setProductDesignCompleted(completed);
if (developmentType) applyRecommendedReleaseDate(developmentType, completed);
};
const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined;
const recommendedWorkDays = selectedDevelopmentType
? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted })
: 0;
const canSubmit = canSubmitNewVersionForm({
productId,
projectId,
versionNumber,
developmentType,
expectedReleaseDate,
submitting,
});
const handleSubmit = async () => {
if (!canSubmit) return;
@@ -562,7 +604,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
name: `${projectName}V${versionNumber}`,
status,
priority,
...(expectedReleaseDate ? { expectedReleaseDate } : {}),
expectedReleaseDate,
members: currentUserName ? [{ role: 'product', name: currentUserName }] : [],
});
onClose();
@@ -573,7 +615,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-[15px] font-semibold text-[var(--ink)]"></h2>
<button onClick={onClose} className="rounded-lg p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors">
@@ -641,15 +683,71 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
</div>
</div>
{/* 截止日期 */}
{/* 项目开发类型 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<div className="grid grid-cols-2 gap-2">
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => handleDevelopmentTypeChange(opt.key)}
type="button"
className={`rounded-lg border px-3 py-2 text-left transition-colors ${developmentType === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
<span className="block text-[12px] font-medium">{opt.label}</span>
<span className="mt-0.5 block text-[10px] text-[var(--ink-muted)]">{opt.description}</span>
</button>
))}
</div>
</div>
{/* 产品设计是否已完成 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"></label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleProductDesignCompletedChange(false)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${!productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
</button>
<button
type="button"
onClick={() => handleProductDesignCompletedChange(true)}
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
>
</button>
</div>
</div>
{/* 期望发版日期 */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]"><span className="text-red-500">*</span></label>
<input
type="date"
value={expectedReleaseDate}
onChange={(e) => setExpectedReleaseDate(e.target.value)}
required
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
{selectedDevelopmentType && (
<p className="mt-1.5 text-[11px] leading-5 text-[var(--ink-muted)]">
{recommendedWorkDays}
{selectedDevelopmentType.stages.map((stage, index) => {
const skipped = productDesignCompleted && stage.key === 'product_design';
return (
<span key={stage.key}>
{index > 0 && ' + '}
<span className={skipped ? 'text-[var(--ink-muted)] line-through' : ''}>
{stage.label}{stage.workDays}
</span>
</span>
);
})}
</p>
)}
</div>
</div>

View File

@@ -6,11 +6,18 @@ import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lu
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useAuthStore } from '@/stores/useAuthStore';
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
import {
filterXiaobaoRiskWarnings,
formatRemainingWork,
isXiaobaoWarningUpdated,
sanitizeRiskInsight,
} from '@/lib/xiaobao-warning-view';
import { formatDateTime } from '@/lib/format';
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
@@ -42,6 +49,7 @@ export default function XiaobaoWarningPage() {
function XiaobaoWarningContent() {
const router = useRouter();
const canManage = useHasPermission('xiaobao.warning:manage');
const user = useAuthStore((s) => s.user);
const {
risks,
snapshots,
@@ -54,12 +62,17 @@ function XiaobaoWarningContent() {
finishInsightUpdate,
today,
} = useXiaobaoWarningRisks({ loadRiskCache: true });
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const savedSnapshotKeysRef = useRef(new Set<string>());
const requestedInsightKeysRef = useRef(new Set<string>());
useEffect(() => {
if (user?.id) fetchReadStates();
}, [fetchReadStates, user?.id]);
useEffect(() => {
risks.forEach((risk) => {
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
@@ -139,6 +152,15 @@ function XiaobaoWarningContent() {
projectId: selectedProjectId || undefined,
}), [risksWithInsight, selectedProductId, selectedProjectId]);
const updatedRiskIds = useMemo(() => {
if (!user?.id || !readStateLoaded) return new Set<string>();
return new Set(
filteredRisks
.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, user.id))
.map((risk) => risk.versionId),
);
}, [filteredRisks, readStateLoaded, readStates, user?.id]);
useEffect(() => {
if (filteredRisks.length === 0) {
setSelectedRiskId(null);
@@ -156,6 +178,12 @@ function XiaobaoWarningContent() {
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
: 0;
const selectRisk = (risk: XiaobaoVersionRisk) => {
setSelectedRiskId(risk.versionId);
if (!user?.id) return;
markRiskRead(user.id, risk).catch(() => {});
};
return (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
@@ -224,7 +252,8 @@ function XiaobaoWarningContent() {
key={risk.versionId}
active={risk.versionId === selectedRiskId}
risk={risk}
onClick={() => setSelectedRiskId(risk.versionId)}
updated={updatedRiskIds.has(risk.versionId)}
onClick={() => selectRisk(risk)}
/>
))}
</div>

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
import { useHasPermission } from '@/components/auth/Guard';
@@ -7,8 +8,15 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
import { getWorkspacePendingCount } from '@/lib/workspace-engine';
import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view';
import { buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
import {
filterXiaobaoRiskWarnings,
getXiaobaoWarningRiskCount,
getXiaobaoWarningUnreadUpdateCount,
} from '@/lib/xiaobao-warning-view';
type NavItemConfig = {
label: string;
@@ -180,17 +188,31 @@ function NavItem({ item, active, onNavigate, workspacePendingCount }: {
function XiaobaoRiskNavBadge() {
const { risks } = useXiaobaoWarningRisks();
const user = useAuthStore((s) => s.user);
const pendingInsightKeys = useXiaobaoRiskStore((s) => s.pendingInsightKeys);
const { readStates, readStateLoaded, fetchReadStates } = useXiaobaoWarningReadStore();
useEffect(() => {
if (user?.id) fetchReadStates();
}, [fetchReadStates, user?.id]);
const count = getXiaobaoWarningRiskCount(risks);
if (count <= 0) return null;
return <NavCountBadge count={count} />;
const warningRisks = filterXiaobaoRiskWarnings(risks).map((risk) => (
pendingInsightKeys.includes(buildXiaobaoRiskInsightPendingKey(risk))
? { ...risk, aiInsightUpdating: true }
: risk
));
const unreadUpdateCount = readStateLoaded ? getXiaobaoWarningUnreadUpdateCount(warningRisks, readStates, user?.id) : 0;
return <NavCountBadge count={count} tone={unreadUpdateCount > 0 ? 'update' : 'risk'} />;
}
function NavCountBadge({ count }: { count: number }) {
function NavCountBadge({ count, tone = 'risk' }: { count: number; tone?: 'risk' | 'update' }) {
if (count <= 0) return null;
const toneClass = tone === 'update' ? 'bg-blue-600' : 'bg-red-600';
return (
<span className="ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] font-semibold leading-none text-white">
<span className={`ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold leading-none text-white ${toneClass}`}>
{count > 99 ? '99+' : count}
</span>
);

View File

@@ -1,24 +1,24 @@
'use client';
import { useState } from 'react';
import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { X, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import {
calcPlanProgress,
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
} from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
interface Props {
planId: string;
@@ -70,23 +70,16 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent;
const progress = plan.type === 'research'
? getResearchDirectionProgressSummary(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 canToggle = canTogglePlanChecklist(plan);
const canEditCoverage = canEditPlanRequirementCoverage(plan);
const currentUserName = user?.name ?? plan.owner;
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleToggleTask = (task: PlanTask) => {
if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -214,43 +207,35 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
{/* Research Tasks */}
{isResearch && plan.tasks && plan.tasks.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canToggle}
onClick={() => handleToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
</div>
))}
</div>
{plan.type === 'research' && (
<PlanResearchDirectionPanel
plan={plan}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
)}
{linkedReqs.length > 0 && (
<div>
<PlanRequirementCoveragePanel
plan={plan}
requirements={linkedReqs}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
{plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={linkedReqs} />
) : (
<PlanRequirementCoveragePanel
plan={plan}
requirements={linkedReqs}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
</div>
)}
{!isResearch && (
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
)}
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
{/* Result */}
{plan.status === 'completed' && plan.resultUrl && (

View File

@@ -4,14 +4,17 @@ import { useState } from 'react';
import { FilterSelect } from '@/components/FilterSelect';
import { formatDateTime } from '@/lib/format';
import type { Requirement } from '@/lib/requirement';
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import type { PlanTask, RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
import {
canSaveRequirementCoverageDraft,
canOpenRequirementCoverageRecord,
getResearchDirectionProgressSummary,
getResearchDirectionStatus,
getRequirementCoverage,
getRequirementCoverageStatus,
getRequirementCoverageSummary,
REQUIREMENT_COVERAGE_LABEL,
updateResearchDirectionProgress,
updateRequirementCoverage,
} from '@/lib/version-plan';
@@ -25,6 +28,17 @@ interface CoverageProps {
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
}
interface DirectionProps {
plan: VersionPlan;
canEdit: boolean;
currentUserName: string;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
}
interface LinkedRequirementReferenceProps {
requirements: RequirementOption[];
}
interface LogTimelineProps {
logs?: Array<VersionPlanLog | VersionPlanLogView>;
className?: string;
@@ -65,6 +79,7 @@ function getLogTone(log: VersionPlanLog): { badge: string } {
function getLogTypeLabel(log: VersionPlanLog): string {
if (log.type === 'ai_decompose') return 'AI 拆解';
if (log.type === 'research_direction_progress') return '调研方向';
if (log.type === 'requirement_progress') return '需求进度';
return '系统记录';
}
@@ -224,6 +239,165 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
);
}
export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onUpdate }: DirectionProps) {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [completedContent, setCompletedContent] = useState('');
const [remainingContent, setRemainingContent] = useState('');
const tasks = plan.tasks ?? [];
const summary = getResearchDirectionProgressSummary(plan);
if (tasks.length === 0) return null;
const openEditor = (task: PlanTask) => {
setEditingTaskId(task.id);
setCompletedContent(task.completedContent ?? '');
setRemainingContent(task.remainingContent ?? '');
};
const closeEditor = () => {
setEditingTaskId(null);
setCompletedContent('');
setRemainingContent('');
};
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
const patch = updateResearchDirectionProgress(plan, {
taskId: task.id,
status,
completedContent: status === 'partial' ? completedContent : undefined,
remainingContent: status === 'partial' ? remainingContent : undefined,
updatedBy: currentUserName,
});
onUpdate(plan.id, patch);
closeEditor();
};
return (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
{summary.completed} / {summary.total}
{summary.partial > 0 && <span className="ml-2 text-amber-700"> {summary.partial}</span>}
</div>
</div>
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
</div>
</div>
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
{tasks.map((task) => {
const status = getResearchDirectionStatus(task);
const isEditing = editingTaskId === task.id;
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
return (
<div key={task.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<div className="flex min-w-0 items-start gap-2">
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
{REQUIREMENT_COVERAGE_LABEL[status]}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
{(task.completedContent || task.remainingContent) && (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
{task.completedContent && <div className="line-clamp-2">{task.completedContent}</div>}
{task.remainingContent && <div className="line-clamp-2 text-amber-700">{task.remainingContent}</div>}
</div>
)}
</div>
{canEdit && (canOpenRecord || isEditing) && (
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => isEditing ? closeEditor() : openEditor(task)}
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{isEditing ? '收起' : '记录'}
</button>
</div>
)}
</div>
{isEditing && (
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
<textarea
value={completedContent}
onChange={(event) => setCompletedContent(event.target.value)}
rows={2}
placeholder="本次已完成的调研内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<textarea
value={remainingContent}
onChange={(event) => setRemainingContent(event.target.value)}
rows={2}
placeholder="剩余未完成的调研内容"
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => saveDirection(task, 'completed')}
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
>
</button>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={closeEditor}
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
>
</button>
<button
type="button"
onClick={() => saveDirection(task, 'partial')}
disabled={!canSavePartial}
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
export function PlanLinkedRequirementReferenceList({ requirements }: LinkedRequirementReferenceProps) {
if (requirements.length === 0) return null;
return (
<div className="mt-3 space-y-2">
<div>
<div className="text-[11px] font-semibold text-[var(--ink-muted)]"></div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]"></div>
</div>
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2">
{requirements.map((req) => (
<div key={req.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600"></span>}
</div>
))}
</div>
</div>
);
}
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
const [selectedMonth, setSelectedMonth] = useState('all');
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());

View File

@@ -10,6 +10,8 @@ import {
calcPlanProgress,
sortPlansNewestFirst,
getPlanLogsForPlans,
getResearchDirectionPresetOptions,
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
@@ -20,7 +22,7 @@ import { FieldError } from '@/components/FieldError';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { AiDecomposeButton } from './AiDecomposeButton';
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
import type { VersionWithContext } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
@@ -80,8 +82,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
const totalDuration = calcTotalDuration(typePlans);
return (
<div className={planType === 'research' ? 'space-y-4' : '-m-5 h-[calc(100vh-98px)] min-h-[520px]'}>
{planType === 'research' && (
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
{false && planType === 'research' && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
@@ -95,11 +97,11 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
</div>
)}
{typePlans.length === 0 && planType === 'research' ? (
{false && typePlans.length === 0 && planType === 'research' ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
) : planType === 'research' ? (
) : false && planType === 'research' ? (
<div className="space-y-3">
{typePlans.map((plan) => {
const now = new Date().toISOString();
@@ -402,7 +404,7 @@ function ProductUiPlanWorkspace({
onCreatePlan,
}: {
typePlans: VersionPlan[];
planType: 'product' | 'ui';
planType: VersionPlan['type'];
totalDuration: string;
versionDeadline?: string;
version?: VersionWithContext;
@@ -461,7 +463,9 @@ function ProductUiPlanWorkspace({
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{typePlans.map((plan) => {
const { effectiveStatus } = getPlanRuntime(plan);
const summary = getRequirementCoverageSummary(plan);
const summary = plan.type === 'research'
? getResearchDirectionProgressSummary(plan)
: getRequirementCoverageSummary(plan);
const isSelected = plan.id === selectedPlanIdOrFirst;
return (
<button
@@ -561,7 +565,7 @@ function ProductUiPlanDetail({
onOpenComplete,
}: {
plan: VersionPlan;
planType: 'product' | 'ui';
planType: VersionPlan['type'];
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
@@ -691,15 +695,27 @@ function ProductUiPlanDetail({
</div>
)}
{selectedRequirements.length > 0 && (
<PlanRequirementCoveragePanel
{plan.type === 'research' && (
<PlanResearchDirectionPanel
plan={plan}
requirements={selectedRequirements}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)}
{selectedRequirements.length > 0 && (
plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={selectedRequirements} />
) : (
<PlanRequirementCoveragePanel
plan={plan}
requirements={selectedRequirements}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
@@ -756,6 +772,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
[linkedRequirements, allRequirements, selectedReqs],
);
const researchDirectionPresetOptions = useMemo(() => getResearchDirectionPresetOptions(tasks), [tasks]);
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
@@ -908,7 +925,23 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
<span className="text-[10px] text-[var(--ink-muted)] ml-1"></span>
</label>
{/* 预设选项 */}
{tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{researchDirectionPresetOptions.map((option) => (
<button
key={option.title}
type="button"
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: option.title, status: 'pending' }]);
}}
className={`h-6 px-2.5 rounded-md text-[11px] border transition-colors ${option.disabled ? 'cursor-not-allowed border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)] opacity-70' : 'border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}
>
+ {option.title}
</button>
))}
</div>
{false && tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
<button

View File

@@ -22,10 +22,12 @@ const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
export function XiaobaoWarningCard({
risk,
active = false,
updated = false,
onClick,
}: {
risk: XiaobaoVersionRisk;
active?: boolean;
updated?: boolean;
onClick: () => void;
}) {
return (
@@ -50,9 +52,16 @@ export function XiaobaoWarningCard({
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
</p>
</div>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
{RISK_LEVEL_LABEL[risk.riskLevel]}
</span>
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
{updated && (
<span className="rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-semibold text-blue-700">
</span>
)}
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
{RISK_LEVEL_LABEL[risk.riskLevel]}
</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">

View File

@@ -13,6 +13,7 @@ export const WORK_ACTIVITY_ACTION_LABEL: Record<WorkActivityAction, string> = {
version_plan_started: '开始计划',
version_plan_completed: '完成计划',
version_plan_requirement_progress: '需求进度',
version_plan_research_direction_progress: '调研方向',
dev_task_created: '新建开发任务',
dev_task_started: '开始开发',
dev_task_self_testing: '进入自测',

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
filterOvertimeRecordsForViewer,
type OvertimeRecord,
} from './overtime';
import type { Department, Member, RoleItem } from './members';
import type { AuthUser } from './auth-user';
const departments: Department[] = [
{ id: 'dept-tech', name: 'Tech', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-front', name: 'Frontend', parentId: 'dept-tech', order: 1, createdAt: '2024-01-01' },
{ id: 'dept-back', name: 'Backend', parentId: 'dept-tech', order: 2, createdAt: '2024-01-01' },
{ id: 'dept-product', name: 'Product', order: 2, createdAt: '2024-01-01' },
];
const members: Member[] = [
member('m-lead', 'Lead', 'dept-tech', 'role-lead'),
member('m-front', 'Frontend Alice', 'dept-front', 'role-dev'),
member('m-back', 'Backend Bob', 'dept-back', 'role-dev'),
member('m-pm', 'PM Carol', 'dept-product', 'role-pm'),
];
const records: OvertimeRecord[] = [
record('ot-front', 'Frontend Alice'),
record('ot-back', 'Backend Bob'),
record('ot-pm', 'PM Carol'),
];
test('filterOvertimeRecordsForViewer limits non-admin viewers to their department tree', () => {
const visible = filterOvertimeRecordsForViewer(records, {
viewer: toAuthUser(members[0]),
viewerRole: role('role-lead', ['overtime:view']),
members,
departments,
});
assert.deepEqual(visible.map((item) => item.id), ['ot-front', 'ot-back']);
});
test('filterOvertimeRecordsForViewer lets wildcard admin view every department', () => {
const visible = filterOvertimeRecordsForViewer(records, {
viewer: toAuthUser(members[0]),
viewerRole: role('role-admin', ['*']),
members,
departments,
});
assert.deepEqual(visible.map((item) => item.id), ['ot-front', 'ot-back', 'ot-pm']);
});
function member(id: string, name: string, departmentId: string, roleId: string): Member {
return {
id,
name,
departmentId,
roleId,
phone: '',
email: '',
password: '',
createdAt: '2024-01-01',
};
}
function record(id: string, person: string): OvertimeRecord {
return {
id,
person,
projectId: 'project-1',
startTime: '2026-06-01T19:00',
endTime: '2026-06-01T21:00',
duration: 2,
reasonId: 'reason-1',
createdAt: '2026-06-01',
};
}
function role(id: string, permissions: string[]): RoleItem {
return {
id,
name: id,
createdAt: '2024-01-01',
permissions,
};
}
function toAuthUser(member: Member): AuthUser {
return {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
}

View File

@@ -1,3 +1,8 @@
import type { AuthUser } from './auth-user';
import type { Department, Member, RoleItem } from './members';
import { isMemberReference } from './member-system';
import { hasPermission } from './permissions';
export interface OvertimeRecord {
id: string;
projectId: string;
@@ -12,6 +17,13 @@ export interface OvertimeRecord {
createdAt: string;
}
interface OvertimeVisibilityContext {
viewer: AuthUser | null | undefined;
viewerRole: RoleItem | undefined;
members: Member[];
departments: Department[];
}
export type OvertimeReason =
| 'requirement_change'
| 'requirement_add'
@@ -105,3 +117,46 @@ export function calcDuration(start: string, end: string): number {
return Math.round(total * 10) / 10;
}
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 resolveRecordMember(
record: OvertimeRecord,
members: Member[],
viewer: AuthUser,
): Pick<Member, 'id' | 'name' | 'departmentId'> | null {
const member = members.find((item) => isMemberReference(record.person, item));
if (member) return member;
if (isMemberReference(record.person, viewer)) {
return { id: viewer.id, name: viewer.name, departmentId: viewer.departmentId };
}
return null;
}
export function filterOvertimeRecordsForViewer(
records: OvertimeRecord[],
context: OvertimeVisibilityContext,
): OvertimeRecord[] {
const { viewer, viewerRole, members, departments } = context;
if (!viewer || !viewerRole || !hasPermission(viewerRole, 'overtime:view')) return [];
if (viewerRole.permissions.includes('*')) return records;
const visibleDepartmentIds = collectDepartmentTreeIds(departments, viewer.departmentId);
return records.filter((record) => {
const member = resolveRecordMember(record, members, viewer);
return !!member && visibleDepartmentIds.has(member.departmentId);
});
}

View File

@@ -105,8 +105,26 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
'xiaobao.warning:view',
'overtime:view', 'overtime:create',
],
'role-lead': [
...VIEW_ONLY_BASE,
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view',
'version.devtask:view', 'version.devtask:manage',
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'xiaobao.warning:view',
'overtime:view', 'overtime:create', 'overtime:export',
],
};
export const DEFAULT_ROLE_PRESETS: RoleItem[] = [
{ id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS['role-admin'] },
{ id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-pm'] },
{ id: 'role-lead', name: '组长', description: '管理本组任务和本部门加班记录', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-lead'] },
{ id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-dev'] },
{ id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-test'] },
{ id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-design'] },
];
export function hasPermission(role: RoleItem | undefined, permission: string): boolean {
if (!role) return false;
if (role.permissions.includes('*')) return true;

View File

@@ -1,8 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
import { mergePresetRolePermissions } from './role-permission-migration';
test('default group leader role has business permissions without admin modules', () => {
const permissions = DEFAULT_ROLE_PERMISSIONS['role-lead'];
assert.ok(permissions);
assert.ok(permissions.includes('overtime:view'));
assert.ok(!permissions.includes('*'));
assert.equal(permissions.some((p) => p.startsWith('member:')), false);
assert.equal(permissions.some((p) => p.startsWith('role:')), false);
});
test('mergePresetRolePermissions adds the built-in group leader role to old role data', () => {
const roles: RoleItem[] = [
{
id: 'role-pm',
name: 'Product Manager',
createdAt: '2024-01-01',
permissions: ['product:view'],
},
];
const result = mergePresetRolePermissions(roles);
const lead = result.roles.find((role) => role.id === 'role-lead');
assert.equal(result.changed, true);
assert.ok(lead);
assert.equal(lead.name, '组长');
assert.deepEqual(lead.permissions, DEFAULT_ROLE_PERMISSIONS['role-lead']);
});
test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
const roles: RoleItem[] = [
{

View File

@@ -1,14 +1,18 @@
import type { RoleItem } from './members';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from './permissions';
const XIAOBAO_WARNING_ROLE_PERMISSIONS: Record<string, string[]> = {
'role-pm': ['xiaobao.warning:view', 'xiaobao.warning:manage'],
'role-dev': ['xiaobao.warning:view'],
'role-test': ['xiaobao.warning:view'],
'role-design': ['xiaobao.warning:view'],
'role-lead': ['xiaobao.warning:view'],
};
export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
let changed = false;
const presetRoleIds = new Set(Object.keys(DEFAULT_ROLE_PERMISSIONS));
const shouldEnsureNewPresets = roles.some((role) => presetRoleIds.has(role.id));
const next = roles.map((role) => {
const xiaobaoPermissions = XIAOBAO_WARNING_ROLE_PERMISSIONS[role.id];
if (!xiaobaoPermissions || role.permissions.includes('*')) return role;
@@ -17,5 +21,14 @@ export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem
changed = true;
return { ...role, permissions: merged };
});
if (shouldEnsureNewPresets && !next.some((role) => role.id === 'role-lead')) {
const groupLeader = DEFAULT_ROLE_PRESETS.find((role) => role.id === 'role-lead');
if (groupLeader) {
next.splice(Math.min(2, next.length), 0, groupLeader);
changed = true;
}
}
return { roles: next, changed };
}

View File

@@ -13,6 +13,7 @@ export type ServerDataKey =
| 'work-activities'
| 'xiaobao-risk-insights'
| 'xiaobao-risk-snapshots'
| 'xiaobao-warning-views'
| 'overtime';
interface ServerDataResponse<T> {

View File

@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
VERSION_DEVELOPMENT_TYPE_OPTIONS,
canSubmitNewVersionForm,
getRecommendedExpectedReleaseDate,
getVersionDevelopmentTypeOption,
} from './version-form';
test('canSubmitNewVersionForm requires expected release date', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '',
submitting: false,
}), false);
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), true);
});
test('canSubmitNewVersionForm requires project development type', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: '',
expectedReleaseDate: '2026-07-15',
submitting: false,
}), false);
});
test('canSubmitNewVersionForm blocks duplicate submits', () => {
assert.equal(canSubmitNewVersionForm({
productId: 'prod-1',
projectId: 'proj-1',
versionNumber: '1.0',
developmentType: 'agile',
expectedReleaseDate: '2026-07-15',
submitting: true,
}), false);
});
test('version development type estimates include product design development and testing', () => {
const agile = getVersionDevelopmentTypeOption('agile');
assert.deepEqual(agile?.stages.map((stage) => stage.label), ['产品设计', '开发', '测试']);
assert.equal(agile?.totalWorkDays, 13);
assert.ok(VERSION_DEVELOPMENT_TYPE_OPTIONS.some((option) => option.key === 'waterfall'));
});
test('getRecommendedExpectedReleaseDate skips non-workdays from the stage total', () => {
assert.equal(getRecommendedExpectedReleaseDate('hotfix', new Date('2026-06-30T09:00:00')), '2026-07-02');
assert.equal(getRecommendedExpectedReleaseDate('agile', new Date('2026-06-30T09:00:00')), '2026-07-16');
});
test('getRecommendedExpectedReleaseDate excludes product design when it is already completed', () => {
assert.equal(
getRecommendedExpectedReleaseDate('agile', new Date('2026-06-30T09:00:00'), { productDesignCompleted: true }),
'2026-07-14',
);
});

View File

@@ -0,0 +1,115 @@
import { isChinaWorkday } from './china-workday-calendar';
import { formatLocalDate } from './format';
export type VersionDevelopmentType = 'agile' | 'standard' | 'waterfall' | 'hotfix';
export interface VersionDevelopmentStageEstimate {
key: 'product_design' | 'development' | 'testing';
label: '产品设计' | '开发' | '测试';
workDays: number;
}
export interface VersionDevelopmentTypeOption {
key: VersionDevelopmentType;
label: string;
description: string;
stages: VersionDevelopmentStageEstimate[];
totalWorkDays: number;
}
export interface NewVersionFormState {
productId: string;
projectId: string;
versionNumber: string;
developmentType: VersionDevelopmentType | '';
expectedReleaseDate: string;
submitting: boolean;
}
export const VERSION_DEVELOPMENT_TYPE_OPTIONS: VersionDevelopmentTypeOption[] = [
makeDevelopmentTypeOption('agile', '敏捷迭代', '适合常规双周迭代', [
{ key: 'product_design', label: '产品设计', workDays: 2 },
{ key: 'development', label: '开发', workDays: 8 },
{ key: 'testing', label: '测试', workDays: 3 },
]),
makeDevelopmentTypeOption('standard', '常规项目', '适合需求较完整的普通版本', [
{ key: 'product_design', label: '产品设计', workDays: 3 },
{ key: 'development', label: '开发', workDays: 12 },
{ key: 'testing', label: '测试', workDays: 4 },
]),
makeDevelopmentTypeOption('waterfall', '瀑布项目', '适合方案先行的大范围交付', [
{ key: 'product_design', label: '产品设计', workDays: 5 },
{ key: 'development', label: '开发', workDays: 20 },
{ key: 'testing', label: '测试', workDays: 7 },
]),
makeDevelopmentTypeOption('hotfix', '紧急修复', '适合线上缺陷或很小范围改动', [
{ key: 'product_design', label: '产品设计', workDays: 0 },
{ key: 'development', label: '开发', workDays: 2 },
{ key: 'testing', label: '测试', workDays: 1 },
]),
];
export function canSubmitNewVersionForm(state: NewVersionFormState): boolean {
return Boolean(
state.productId &&
state.projectId &&
state.versionNumber &&
state.developmentType &&
state.expectedReleaseDate &&
!state.submitting,
);
}
export function getVersionDevelopmentTypeOption(type: VersionDevelopmentType): VersionDevelopmentTypeOption | undefined {
return VERSION_DEVELOPMENT_TYPE_OPTIONS.find((option) => option.key === type);
}
export function getRecommendedExpectedReleaseDate(
type: VersionDevelopmentType,
baseDate: Date = new Date(),
options: { productDesignCompleted?: boolean } = {},
): string {
const developmentType = getVersionDevelopmentTypeOption(type);
if (!developmentType) return '';
const totalWorkDays = getRecommendedWorkDays(developmentType, options);
return addChinaWorkDays(baseDate, totalWorkDays);
}
export function getRecommendedWorkDays(
option: VersionDevelopmentTypeOption,
options: { productDesignCompleted?: boolean } = {},
): number {
return option.stages.reduce((sum, stage) => {
if (stage.key === 'product_design' && options.productDesignCompleted) return sum;
return sum + stage.workDays;
}, 0);
}
function makeDevelopmentTypeOption(
key: VersionDevelopmentType,
label: string,
description: string,
stages: VersionDevelopmentStageEstimate[],
): VersionDevelopmentTypeOption {
return {
key,
label,
description,
stages,
totalWorkDays: stages.reduce((sum, stage) => sum + stage.workDays, 0),
};
}
function addChinaWorkDays(baseDate: Date, workDays: number): string {
const cursor = new Date(baseDate);
cursor.setHours(0, 0, 0, 0);
let remaining = Math.max(1, Math.round(workDays));
while (remaining > 0) {
if (isChinaWorkday(cursor)) remaining -= 1;
if (remaining <= 0) break;
cursor.setDate(cursor.getDate() + 1);
}
return formatLocalDate(cursor);
}

View File

@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as versionPlan from './version-plan';
import type { VersionPlan } from './version-plan';
function plan(patch: Partial<VersionPlan>): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: '调研方案',
owner: 'PM',
startTime: '2026-06-30T09:00',
endTime: '2026-06-30T18:00',
status: 'in_progress',
createdAt: '2026-06-30',
addedBy: 'PM',
...patch,
};
}
test('summarizes research direction progress from direction task status', () => {
const getResearchDirectionProgressSummary = (versionPlan as any).getResearchDirectionProgressSummary as undefined | ((item: VersionPlan) => {
total: number;
completed: number;
partial: number;
notStarted: number;
percent: number;
});
assert.equal(typeof getResearchDirectionProgressSummary, 'function');
const summary = getResearchDirectionProgressSummary!(plan({
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'completed' },
{ id: 'task-2', title: '用户访谈', status: 'in_progress' },
{ id: 'task-3', title: '数据调研', status: 'pending' },
],
}));
assert.deepEqual(summary, {
total: 3,
completed: 1,
partial: 1,
notStarted: 1,
percent: 33,
});
});
test('updates research direction progress and creates a direction log', () => {
const updateResearchDirectionProgress = (versionPlan as any).updateResearchDirectionProgress as undefined | ((item: VersionPlan, input: {
taskId: string;
status: string;
completedContent?: string;
remainingContent?: string;
updatedBy: string;
updatedAt: string;
}) => any);
assert.equal(typeof updateResearchDirectionProgress, 'function');
const next = updateResearchDirectionProgress!(plan({
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' },
{ id: 'task-2', title: '用户访谈', status: 'pending' },
],
}), {
taskId: 'task-1',
status: 'partial',
completedContent: '完成竞品登录流程对比',
remainingContent: '补充支付流程差异',
updatedBy: 'PM',
updatedAt: '2026-06-30T10:00:00.000Z',
});
assert.equal(next.tasks?.[0]?.status, 'in_progress');
assert.equal(next.tasks?.[0]?.completedContent, '完成竞品登录流程对比');
assert.equal(next.tasks?.[0]?.remainingContent, '补充支付流程差异');
assert.equal(next.logs?.[0]?.type, 'research_direction_progress');
assert.equal(next.logs?.[0]?.directionTaskId, 'task-1');
assert.equal(next.logs?.[0]?.directionTitle, '竞品分析');
assert.equal(next.logs?.[0]?.coverageStatus, 'partial');
});

View File

@@ -22,14 +22,15 @@ function plan(patch: Partial<VersionPlan>): VersionPlan {
};
}
test('does not allow research result submission when subtasks are incomplete', () => {
test('research requires direction completion instead of linked requirement coverage', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
linkedRequirementIds: [],
linkedRequirementIds: ['r1'],
}));
assert.equal(state.canSubmitResult, false);
assert.ok(state.missingReasons.includes('子任务未全部完成'));
assert.ok(state.missingReasons.includes('调研方向未全部完成'));
assert.equal(state.missingReasons.includes('关联需求未全部覆盖'), false);
});
test('requires product requirement coverage when linked requirements exist', () => {
@@ -99,7 +100,7 @@ test('ui plan does not require task checklist', () => {
assert.equal(state.canSubmitResult, true);
});
test('research requires tasks and result but not requirement coverage', () => {
test('research requires completed directions and result but not linked requirement coverage', () => {
const state = getPlanCompletionState(plan({
type: 'research',
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],

View File

@@ -73,8 +73,8 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
const requirementCompleted = requirementSummary.completed;
const missingReasons: string[] = [];
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少子任务');
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成');
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少调研方向');
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('调研方向未全部完成');
if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
missingReasons.push('关联需求未全部覆盖');
}

View File

@@ -193,3 +193,16 @@ test('hides coverage record action after a requirement is completed', () => {
assert.equal(canOpenRequirementCoverageRecord!('completed', true), false);
assert.equal(canOpenRequirementCoverageRecord!('partial', false), false);
});
test('keeps research direction presets visible while disabling selected presets', () => {
const getResearchDirectionPresetOptions = (versionPlan as any).getResearchDirectionPresetOptions as undefined | ((
tasks: Array<{ title: string }>,
) => Array<{ title: string; disabled: boolean }>);
assert.equal(typeof getResearchDirectionPresetOptions, 'function');
const options = getResearchDirectionPresetOptions!([{ title: '竞品分析' }]);
assert.ok(options.length >= 6);
assert.equal(options.find((option) => option.title === '竞品分析')?.disabled, true);
assert.equal(options.find((option) => option.title === '用户访谈')?.disabled, false);
});

View File

@@ -4,7 +4,7 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
export type ProductPlanReviewResult = 'passed' | 'failed';
export type RequirementCoverageStatus = 'not_started' | 'partial' | 'completed';
export type VersionPlanLogType = 'requirement_progress' | 'ai_decompose' | 'system';
export type VersionPlanLogType = 'requirement_progress' | 'research_direction_progress' | 'ai_decompose' | 'system';
export type AiDecomposeLogStatus = 'started' | 'completed' | 'error';
export type ProductPlanReviewFailureType =
| 'requirement_mismatch'
@@ -45,6 +45,30 @@ export interface PlanTask {
id: string;
title: string;
status: PlanTaskStatus;
completedContent?: string;
remainingContent?: string;
updatedAt?: string;
updatedBy?: string;
}
export const RESEARCH_DIRECTION_PRESETS = [
'\u7ade\u54c1\u5206\u6790',
'\u7528\u6237\u8bbf\u8c08',
'\u6570\u636e\u8c03\u7814',
'\u6280\u672f\u53ef\u884c\u6027\u5206\u6790',
'\u5e02\u573a\u8c03\u7814',
'\u9700\u6c42\u5206\u6790',
];
export function getResearchDirectionPresetOptions(tasks: Array<Pick<PlanTask, 'title'>>): Array<{
title: string;
disabled: boolean;
}> {
const selectedTitles = new Set(tasks.map((task) => task.title.trim()).filter(Boolean));
return RESEARCH_DIRECTION_PRESETS.map((title) => ({
title,
disabled: selectedTitles.has(title),
}));
}
export interface VersionPlanRequirementCoverage {
@@ -69,6 +93,8 @@ export interface VersionPlanLog {
coverageStatus?: RequirementCoverageStatus;
completedContent?: string;
remainingContent?: string;
directionTaskId?: string;
directionTitle?: string;
aiTarget?: AgentDecomposeTarget;
aiStatus?: AiDecomposeLogStatus;
}
@@ -84,6 +110,15 @@ export interface RequirementCoverageUpdateInput {
requirementTitle?: string;
}
export interface ResearchDirectionProgressUpdateInput {
taskId: string;
status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>;
completedContent?: string;
remainingContent?: string;
updatedAt?: string;
updatedBy: string;
}
export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
id?: string;
createdAt?: string;
@@ -100,6 +135,33 @@ export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, strin
completed: '完全完成',
};
export function getResearchDirectionStatus(task: Pick<PlanTask, 'status'>): RequirementCoverageStatus {
if (task.status === 'completed') return 'completed';
if (task.status === 'in_progress') return 'partial';
return 'not_started';
}
export function getResearchDirectionProgressSummary(plan: VersionPlan): {
total: number;
completed: number;
partial: number;
notStarted: number;
percent: number;
} {
const tasks = plan.tasks ?? [];
const total = tasks.length;
const completed = tasks.filter((task) => getResearchDirectionStatus(task) === 'completed').length;
const partial = tasks.filter((task) => getResearchDirectionStatus(task) === 'partial').length;
const notStarted = Math.max(total - completed - partial, 0);
return {
total,
completed,
partial,
notStarted,
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
};
}
export interface VersionPlan {
id: string;
versionId: string;
@@ -275,6 +337,55 @@ export function updateRequirementCoverage(
};
}
export function updateResearchDirectionProgress(
plan: VersionPlan,
input: ResearchDirectionProgressUpdateInput,
): Pick<VersionPlan, 'tasks' | 'logs'> {
const tasks = plan.tasks ?? [];
const target = tasks.find((task) => task.id === input.taskId);
if (!target) {
return {
tasks: plan.tasks,
logs: plan.logs,
};
}
const updatedAt = input.updatedAt ?? new Date().toISOString();
const nextStatus: PlanTaskStatus = input.status === 'completed' ? 'completed' : 'in_progress';
const completedContent = input.status === 'partial' ? input.completedContent?.trim() || undefined : undefined;
const remainingContent = input.status === 'partial' ? input.remainingContent?.trim() || undefined : undefined;
const nextTasks = tasks.map((task) => task.id === input.taskId
? {
...task,
status: nextStatus,
completedContent,
remainingContent,
updatedAt,
updatedBy: input.updatedBy,
}
: task);
const detail = [
completedContent ? `已完成:${completedContent}` : '',
remainingContent ? `剩余:${remainingContent}` : '',
].filter(Boolean).join('\n');
return {
tasks: nextTasks,
logs: appendPlanLog(plan, {
type: 'research_direction_progress',
createdAt: updatedAt,
actor: input.updatedBy,
title: `${target.title}更新为${REQUIREMENT_COVERAGE_LABEL[input.status]}`,
detail: detail || undefined,
directionTaskId: target.id,
directionTitle: target.title,
coverageStatus: input.status,
completedContent,
remainingContent,
}),
};
}
function getPlanCreatedAtTime(plan: VersionPlan): number {
const time = new Date(plan.createdAt).getTime();
return Number.isFinite(time) ? time : 0;

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canSubmitReleaseForm, getReleaseProgressWarning } from './version-release';
test('getReleaseProgressWarning warns when version progress is below 100 percent', () => {
assert.equal(
getReleaseProgressWarning(87),
'当前版本进度 87%,仍有未完成事项。确认发版会将版本标记为已发布。',
);
});
test('getReleaseProgressWarning returns empty when version progress is complete', () => {
assert.equal(getReleaseProgressWarning(100), '');
});
test('canSubmitReleaseForm requires release date', () => {
assert.equal(canSubmitReleaseForm(''), false);
assert.equal(canSubmitReleaseForm('2026-07-15'), true);
});

View File

@@ -0,0 +1,14 @@
function normalizeReleaseProgress(progress: number): number {
if (!Number.isFinite(progress)) return 0;
return Math.min(100, Math.max(0, Math.round(progress)));
}
export function getReleaseProgressWarning(progress: number): string {
const normalizedProgress = normalizeReleaseProgress(progress);
if (normalizedProgress >= 100) return '';
return `当前版本进度 ${normalizedProgress}%,仍有未完成事项。确认发版会将版本标记为已发布。`;
}
export function canSubmitReleaseForm(releaseDate: string): boolean {
return releaseDate.trim().length > 0;
}

View File

@@ -73,6 +73,32 @@ export function makeVersionPlanRequirementProgressActivity(plan: VersionPlan, lo
};
}
export function makeVersionPlanResearchDirectionProgressActivity(plan: VersionPlan, log: VersionPlanLog): WorkActivityDraft | undefined {
if (log.type !== 'research_direction_progress') return undefined;
if (plan.type !== 'research') return undefined;
const directionTitle = log.directionTitle || '调研方向';
const statusLabel = log.coverageStatus ? REQUIREMENT_COVERAGE_LABEL[log.coverageStatus] : '进度更新';
return {
actorId: log.actor,
sourceType: 'version_plan',
sourceId: plan.id,
action: 'version_plan_research_direction_progress',
category: 'progress',
title: plan.title,
summary: `推进调研方向:${directionTitle}${statusLabel}`,
occurredAt: log.createdAt,
metadata: {
directionTaskId: log.directionTaskId,
directionTitle: log.directionTitle,
coverageStatus: log.coverageStatus,
completedContent: log.completedContent,
remainingContent: log.remainingContent,
},
};
}
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
return {
actorId,

View File

@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { VersionPlan } from './version-plan';
import * as workActivityFactory from './work-activity-factory';
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
return {
id: 'plan-1',
versionId: 'version-1',
type: 'research',
title: '支付链路调研',
owner: 'PM',
startTime: '2026-06-30T09:00',
endTime: '2026-06-30T18:00',
status: 'in_progress',
createdAt: '2026-06-30',
addedBy: 'PM',
...patch,
};
}
test('makeVersionPlanResearchDirectionProgressActivity records direction progress details', () => {
const makeVersionPlanResearchDirectionProgressActivity = (workActivityFactory as any).makeVersionPlanResearchDirectionProgressActivity as undefined | Function;
assert.equal(typeof makeVersionPlanResearchDirectionProgressActivity, 'function');
const activity = makeVersionPlanResearchDirectionProgressActivity!(plan(), {
id: 'log-1',
type: 'research_direction_progress',
createdAt: '2026-06-30T10:00:00.000Z',
actor: 'PM',
title: '竞品分析更新为部分完成',
directionTaskId: 'task-1',
directionTitle: '竞品分析',
coverageStatus: 'partial',
completedContent: '完成登录流程对比',
remainingContent: '补充支付流程差异',
});
assert.equal(activity?.action, 'version_plan_research_direction_progress');
assert.equal(activity?.category, 'progress');
assert.equal(activity?.sourceType, 'version_plan');
assert.equal(activity?.sourceId, 'plan-1');
assert.equal(activity?.occurredAt, '2026-06-30T10:00:00.000Z');
assert.equal(activity?.summary, '推进调研方向:竞品分析(部分完成)');
assert.equal(activity?.metadata?.directionTaskId, 'task-1');
assert.equal(activity?.metadata?.completedContent, '完成登录流程对比');
assert.equal(activity?.metadata?.remainingContent, '补充支付流程差异');
});

View File

@@ -7,6 +7,7 @@ export type WorkActivityAction =
| 'version_plan_started'
| 'version_plan_completed'
| 'version_plan_requirement_progress'
| 'version_plan_research_direction_progress'
| 'dev_task_created'
| 'dev_task_started'
| 'dev_task_self_testing'

View File

@@ -279,6 +279,89 @@ test('getWorkspaceDailyReport counts actual hours from today activity sources',
assert.equal(report.totalCount, 2);
});
test('getWorkspaceDailyReport does not double count overlapping activity time ranges', () => {
const report = getWorkspaceDailyReport({
activities: [
{
id: 'act-overlap-a',
actorId: 'Alice',
date: '2026-06-26',
occurredAt: '2026-06-26T02:10:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-overlap-a',
action: 'dev_task_started',
category: 'progress',
title: 'Overlap A',
summary: 'Started development: Overlap A',
},
{
id: 'act-overlap-b',
actorId: 'Alice',
date: '2026-06-26',
occurredAt: '2026-06-26T02:20:00.000Z',
sourceType: 'dev_task',
sourceId: 'task-overlap-b',
action: 'dev_task_started',
category: 'progress',
title: 'Overlap B',
summary: 'Started development: Overlap B',
},
],
worklogs: [],
workItems: [
{
id: 'task-overlap-a',
type: 'devTask',
title: 'Overlap A',
status: 'submitted',
completed: true,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
},
raw: {
id: 'task-overlap-a',
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
status: 'submitted',
updatedAt: '2026-06-26T04:00:00.000Z',
} as any,
},
{
id: 'task-overlap-b',
type: 'devTask',
title: 'Overlap B',
status: 'submitted',
completed: true,
productName: 'FTB',
projectName: 'Project Management',
versionName: 'V1.0',
versionId: 'version-1',
extra: {
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
},
raw: {
id: 'task-overlap-b',
actualStartAt: '2026-06-26T02:00:00.000Z',
actualEndAt: '2026-06-26T04:00:00.000Z',
status: 'submitted',
updatedAt: '2026-06-26T04:00:00.000Z',
} as any,
},
],
userId: 'Alice',
date: '2026-06-26',
});
assert.equal(report.totalHours, 2);
assert.equal(report.totalCount, 2);
});
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
const report = getWorkspaceDailyReport({
activities: [],

View File

@@ -120,12 +120,13 @@ export function getWorkspaceDailyReport({
...items.map((item) => item.taskId),
]);
const worklogTaskIds = new Set(items.map((item) => item.taskId));
const activityHours = Array.from(new Set(reportActivities.map((activity) => activity.sourceId)))
.filter((sourceId) => !worklogTaskIds.has(sourceId))
.reduce((sum, sourceId) => {
const item = workItemMap.get(sourceId);
return sum + calcWorkItemHoursForDate(item, date, now);
}, 0);
const activityHours = calcActivityHoursForDate(
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))),
workItemMap,
worklogTaskIds,
date,
now,
);
const needsProgressItems = workItems
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
@@ -297,25 +298,64 @@ function getTestCaseTerminalFallback(
return undefined;
}
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
function calcActivityHoursForDate(
sourceIds: string[],
workItemMap: Map<string, WorkItem>,
excludedSourceIds: Set<string>,
date: string,
now: Date,
): number {
const intervals = sourceIds
.filter((sourceId) => !excludedSourceIds.has(sourceId))
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
.filter(isTimeInterval);
return calcMergedIntervalHours(intervals);
}
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
const interval = getActualInterval(item, now);
if (!interval) return 0;
if (!interval) return undefined;
const day = getLocalDateBounds(date);
if (!day) return 0;
if (!day) return undefined;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return 0;
if (overlapEnd <= overlapStart) return undefined;
return calcActualElapsedHours(
new Date(overlapStart).toISOString(),
new Date(overlapEnd).toISOString(),
);
return { start: overlapStart, end: overlapEnd };
}
function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
return Boolean(interval);
}
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
let current = { ...sorted[0] };
for (const interval of sorted.slice(1)) {
if (interval.start <= current.end) {
current.end = Math.max(current.end, interval.end);
} else {
merged.push(current);
current = { ...interval };
}
}
merged.push(current);
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
new Date(interval.start).toISOString(),
new Date(interval.end).toISOString(),
), 0);
}
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {

View File

@@ -374,6 +374,7 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 12,
todayActualHours: 1.5,
lastActivityAt: '2026-06-29T03:37:15.903Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
@@ -390,6 +391,7 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 13,
todayActualHours: 3,
lastActivityAt: '2026-06-29T06:21:38.597Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },

View File

@@ -144,6 +144,14 @@ export function buildRiskInsightSignature(risk: XiaobaoVersionRisk): string {
});
}
export function buildRiskInsightDisplaySignature(risk: XiaobaoVersionRisk): string {
return normalizeRiskInsightDisplaySignature(buildRiskInsightSignature(risk)) ?? buildRiskInsightSignature(risk);
}
export function normalizeRiskInsightDisplaySignature(signature: string): string | undefined {
return buildDisplayCompatibilityKey(signature);
}
export function getReusableInsight(
cache: XiaobaoRiskInsightCacheItem[],
risk: XiaobaoVersionRisk,
@@ -365,7 +373,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
todayActualHours: normalizeNumber(dailyEvidence.todayActualHours),
},
});
} catch {
@@ -436,10 +443,6 @@ function normalizeInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
}
function normalizeNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 10) / 10 : null;
}
function getTime(value: string): number {
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : Number.NaN;

View File

@@ -48,6 +48,20 @@ test('buildRiskSignature changes when critical bug count changes without open bu
assert.notEqual(base, changed);
});
test('buildRiskSignature ignores volatile same-day forecast time shifts', () => {
const base = snapshot({
forecastReleaseDate: '2026-07-15T06:30:00.000Z',
createdAt: '2026-06-30T08:00:00.000Z',
});
const changed = snapshot({
forecastReleaseDate: '2026-07-15T09:46:04.706Z',
createdAt: '2026-06-30T08:16:04.720Z',
});
assert.equal(buildRiskSignature(changed), buildRiskSignature(base));
assert.equal(shouldSaveRiskSnapshot(changed, base, new Date('2026-06-30T08:16:04.720Z')), false);
});
test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => {
const latest = findLatestDailySnapshot([
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),

View File

@@ -83,7 +83,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
snapshot.date,
clampScore(snapshot.riskScore),
snapshot.riskLevel,
snapshot.forecastReleaseDate ?? '',
normalizeForecastDate(snapshot.forecastReleaseDate),
snapshot.openBugCount,
snapshot.criticalBugCount ?? 0,
snapshot.failedTestCount,
@@ -136,6 +136,16 @@ function hasForecastShiftedByOneDay(current?: string, previous?: string): boolea
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
}
function normalizeForecastDate(value?: string): string {
if (!value) return '';
const raw = value.trim();
if (!raw) return '';
if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return raw.slice(0, 10);
const time = new Date(raw).getTime();
if (!Number.isFinite(time)) return raw;
return new Date(time).toISOString().slice(0, 10);
}
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
return Number.isFinite(date) ? date : 0;

View File

@@ -8,6 +8,10 @@ import {
formatRemainingWork,
getRiskScoreTone,
getXiaobaoWarningRiskCount,
getXiaobaoWarningUnreadUpdateCount,
getXiaobaoWarningUpdateSignature,
isXiaobaoWarningUpdated,
markXiaobaoWarningRead,
sanitizeRiskInsight,
} from './xiaobao-warning-view';
@@ -157,3 +161,105 @@ test('sanitizeRiskInsight filters invalid page refresh suggested actions', () =>
assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug并同步测试负责人复测']);
});
test('xiaobao warning update state is unread until the current user reads the same risk signature', () => {
const baseRisk = risk();
assert.equal(isXiaobaoWarningUpdated(baseRisk, [], 'user-1'), true);
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-1'), false);
assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-2'), true);
});
test('xiaobao warning update state becomes unread again when risk facts change', () => {
const baseRisk = risk();
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const changedRisk = risk({
riskScore: 69,
signals: {
...baseRisk.signals,
failedTestCount: 1,
},
});
assert.notEqual(getXiaobaoWarningUpdateSignature(changedRisk), getXiaobaoWarningUpdateSignature(baseRisk));
assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), true);
assert.equal(getXiaobaoWarningUnreadUpdateCount([baseRisk, changedRisk], readStates, 'user-1'), 1);
});
test('xiaobao warning update state waits until AI suggestion update is complete', () => {
const baseRisk = risk();
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const changedRisk = risk({
riskScore: 72,
aiInsightUpdating: true,
signals: {
...baseRisk.signals,
failedTestCount: 1,
},
});
const completedRisk = { ...changedRisk, aiInsightUpdating: false };
assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), false);
assert.equal(getXiaobaoWarningUnreadUpdateCount([changedRisk], readStates, 'user-1'), 0);
assert.deepEqual(markXiaobaoWarningRead(readStates, 'user-1', changedRisk, '2026-06-30T12:00:00.000Z'), readStates);
assert.equal(isXiaobaoWarningUpdated(completedRisk, readStates, 'user-1'), true);
});
test('xiaobao warning update state ignores refresh-volatile timing and hour drift', () => {
const baseRisk = risk({
forecastReleaseDate: '2026-07-05T10:00:00.000Z',
dailyEvidence: {
todayDeliveries: [],
todayProgress: [
{ id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T10:00:00.000Z' },
],
todayCreations: [],
todayRisks: [],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 2,
totalActivityCount: 1,
todayActualHours: 1,
lastActivityAt: '2026-06-30T10:00:00.000Z',
},
});
const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
const refreshDriftRisk = risk({
forecastReleaseDate: '2026-07-05T11:30:00.000Z',
signals: {
...baseRisk.signals,
daysToExpectedRelease: 4.2,
},
dailyEvidence: {
...baseRisk.dailyEvidence!,
recentActivityCount: 3,
todayActualHours: 2.5,
lastActivityAt: '2026-06-30T11:30:00.000Z',
todayProgress: [
{ id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T11:30:00.000Z' },
],
},
});
assert.equal(isXiaobaoWarningUpdated(refreshDriftRisk, readStates, 'user-1'), false);
});
test('markXiaobaoWarningRead replaces only the matching user and version read marker', () => {
const baseRisk = risk();
const otherRisk = risk({ versionId: 'ver-2', versionName: 'V2.0' });
const initial = [
...markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T10:00:00.000Z'),
...markXiaobaoWarningRead([], 'user-2', baseRisk, '2026-06-30T10:05:00.000Z'),
...markXiaobaoWarningRead([], 'user-1', otherRisk, '2026-06-30T10:10:00.000Z'),
];
const updated = markXiaobaoWarningRead(initial, 'user-1', baseRisk, '2026-06-30T11:00:00.000Z');
assert.equal(updated.filter((item) => item.userId === 'user-1' && item.versionId === 'ver-1').length, 1);
assert.equal(updated.find((item) => item.userId === 'user-1' && item.versionId === 'ver-1')?.readAt, '2026-06-30T11:00:00.000Z');
assert.equal(updated.some((item) => item.userId === 'user-2' && item.versionId === 'ver-1'), true);
assert.equal(updated.some((item) => item.userId === 'user-1' && item.versionId === 'ver-2'), true);
});

View File

@@ -1,6 +1,7 @@
import type { VersionWithContext } from './derive';
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
import type { XiaobaoVersionRisk } from './xiaobao-risk';
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
import { WORK_HOURS } from './work-hours';
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
@@ -28,6 +29,13 @@ export interface XiaobaoWarningRiskListFilter {
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
export interface XiaobaoWarningReadState {
userId: string;
versionId: string;
signature: string;
readAt: string;
}
export function filterXiaobaoWarningVersions(
versions: VersionWithContext[],
filter: XiaobaoWarningVersionFilter,
@@ -58,6 +66,52 @@ export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number
return filterXiaobaoRiskWarnings(risks).length;
}
export function getXiaobaoWarningUpdateSignature(risk: XiaobaoVersionRisk): string {
return buildRiskInsightDisplaySignature(risk);
}
export function isXiaobaoWarningUpdated(
risk: XiaobaoVersionRisk,
readStates: XiaobaoWarningReadState[],
userId?: string,
): boolean {
if (!userId) return false;
if (risk.aiInsightUpdating) return false;
const currentSignature = getXiaobaoWarningUpdateSignature(risk);
const readState = readStates.find((item) => item.userId === userId && item.versionId === risk.versionId);
const readSignature = readState?.signature
? normalizeRiskInsightDisplaySignature(readState.signature) ?? readState.signature
: undefined;
return readSignature !== currentSignature;
}
export function getXiaobaoWarningUnreadUpdateCount(
risks: XiaobaoVersionRisk[],
readStates: XiaobaoWarningReadState[],
userId?: string,
): number {
return risks.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, userId)).length;
}
export function markXiaobaoWarningRead(
readStates: XiaobaoWarningReadState[],
userId: string,
risk: XiaobaoVersionRisk,
readAt: string,
): XiaobaoWarningReadState[] {
if (risk.aiInsightUpdating) return readStates;
const item: XiaobaoWarningReadState = {
userId,
versionId: risk.versionId,
signature: getXiaobaoWarningUpdateSignature(risk),
readAt,
};
return [
item,
...readStates.filter((row) => row.userId !== userId || row.versionId !== risk.versionId),
];
}
export function getRiskScoreTone(score: number): RiskScoreTone {
if (score >= 75) return 'danger';
if (score >= 55) return 'warn';

View File

@@ -9,7 +9,7 @@ import {
ensureSystemAdminMember,
sanitizeSystemAdminPatch,
} from '@/lib/member-system';
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
import { loadServerData, saveServerData } from '@/lib/server-data';
@@ -23,13 +23,7 @@ const PRESET_DEPARTMENTS: Department[] = [
{ id: 'dept-4', name: '运营部', order: 4, createdAt: '2024-01-01' },
];
const PRESET_ROLES: RoleItem[] = [
{ id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS['role-admin'] },
{ id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-pm'] },
{ id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-dev'] },
{ id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-test'] },
{ id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-design'] },
];
const PRESET_ROLES: RoleItem[] = DEFAULT_ROLE_PRESETS;
const MOCK_MEMBERS: Member[] = [
SYSTEM_ADMIN_MEMBER,

View File

@@ -8,6 +8,7 @@ import {
makeVersionPlanCompletedActivity,
makeVersionPlanCreatedActivity,
makeVersionPlanRequirementProgressActivity,
makeVersionPlanResearchDirectionProgressActivity,
makeVersionPlanStartedActivity,
} from '@/lib/work-activity-factory';
import type { WorkActivityDraft } from '@/lib/work-activity';
@@ -73,8 +74,10 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
const previousLogIds = new Set((p.logs ?? []).map((log) => log.id));
for (const log of next.logs ?? []) {
if (previousLogIds.has(log.id)) continue;
const activity = makeVersionPlanRequirementProgressActivity(next, log);
if (activity) activities.push(activity);
const requirementActivity = makeVersionPlanRequirementProgressActivity(next, log);
if (requirementActivity) activities.push(requirementActivity);
const directionActivity = makeVersionPlanResearchDirectionProgressActivity(next, log);
if (directionActivity) activities.push(directionActivity);
}
return next;
});

View File

@@ -0,0 +1,94 @@
'use client';
import { create } from 'zustand';
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import {
markXiaobaoWarningRead,
type XiaobaoWarningReadState,
} from '@/lib/xiaobao-warning-view';
import { loadServerData, saveServerData } from '@/lib/server-data';
interface XiaobaoWarningReadStoreState {
readStates: XiaobaoWarningReadState[];
readStateLoaded: boolean;
error?: string;
fetchReadStates: () => Promise<void>;
markRiskRead: (userId: string, risk: XiaobaoVersionRisk, readAt?: string) => Promise<void>;
}
let readStateSaveQueue: Promise<void> = Promise.resolve();
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
try {
const rows = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views');
return normalizeReadStates(rows);
} catch {}
return null;
}
function normalizeReadStates(rows: unknown): XiaobaoWarningReadState[] {
if (!Array.isArray(rows)) return [];
return rows.filter(isReadState);
}
function isReadState(row: unknown): row is XiaobaoWarningReadState {
if (!row || typeof row !== 'object') return false;
const item = row as Partial<XiaobaoWarningReadState>;
return (
typeof item.userId === 'string' &&
typeof item.versionId === 'string' &&
typeof item.signature === 'string' &&
typeof item.readAt === 'string'
);
}
function mergeReadStates(
remoteRows: XiaobaoWarningReadState[] = [],
localRows: XiaobaoWarningReadState[] = [],
): XiaobaoWarningReadState[] {
const byKey = new Map<string, XiaobaoWarningReadState>();
for (const row of [...remoteRows, ...localRows]) {
const key = `${row.userId}::${row.versionId}`;
const existing = byKey.get(key);
if (!existing || row.readAt.localeCompare(existing.readAt) >= 0) {
byKey.set(key, row);
}
}
return Array.from(byKey.values()).sort((a, b) => b.readAt.localeCompare(a.readAt));
}
export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((set, get) => ({
readStates: [],
readStateLoaded: false,
error: undefined,
fetchReadStates: async () => {
const rows = await loadReadStates();
set({
readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates,
readStateLoaded: rows !== null,
error: rows === null ? '小宝预警查看状态加载失败' : undefined,
});
},
markRiskRead: async (userId, risk, readAt = new Date().toISOString()) => {
if (!userId) return;
const optimistic = markXiaobaoWarningRead(get().readStates, userId, risk, readAt);
set({ readStates: optimistic, error: undefined });
const task = readStateSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views');
const merged = mergeReadStates(normalizeReadStates(remote), get().readStates);
set({ readStates: merged, readStateLoaded: true, error: undefined });
await saveServerData('xiaobao-warning-views', merged);
});
readStateSaveQueue = task.catch(() => undefined);
try {
await task;
} catch (error) {
set({ error: '小宝预警查看状态保存失败' });
throw error;
}
},
}));

View File

@@ -192,3 +192,5 @@ The rule surface stays in pure frontend engines:
Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-managers with `xiaobao.warning:view` can only see unfinished versions where the current user is in `version.members`.
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.