feat(版本): 完善研发计划与预警已读
关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -11,6 +11,7 @@ export const APP_DATA_KEYS = [
|
|||||||
'work-activities',
|
'work-activities',
|
||||||
'xiaobao-risk-insights',
|
'xiaobao-risk-insights',
|
||||||
'xiaobao-risk-snapshots',
|
'xiaobao-risk-snapshots',
|
||||||
|
'xiaobao-warning-views',
|
||||||
'overtime',
|
'overtime',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
|||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||||
import { calcDuration } from '@/lib/overtime';
|
import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime';
|
||||||
import type { OvertimeRecord } from '@/lib/overtime';
|
import type { OvertimeRecord } from '@/lib/overtime';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { DictDrawer } from '@/components/requirement/DictDrawer';
|
import { DictDrawer } from '@/components/requirement/DictDrawer';
|
||||||
@@ -29,7 +30,9 @@ function OvertimePageContent() {
|
|||||||
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
const { requirements, fetchRequirements } = useRequirementStore();
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
|
const { departments, members, roles, fetchMembers } = useMemberStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
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 allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
@@ -43,20 +46,27 @@ function OvertimePageContent() {
|
|||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||||
|
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||||
|
|
||||||
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
|
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
|
||||||
const versionName = (id?: string) => id ? (allVersions.find((v) => v.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 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(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = [...records];
|
let list = [...visibleRecords];
|
||||||
if (search) list = list.filter((r) => r.person.includes(search));
|
if (search) list = list.filter((r) => r.person.includes(search));
|
||||||
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
|
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
|
||||||
if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter);
|
if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter);
|
||||||
if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter);
|
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());
|
list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||||
return list;
|
return list;
|
||||||
}, [records, search, projectFilter, reasonFilter, monthFilter]);
|
}, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]);
|
||||||
|
|
||||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
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">
|
<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">
|
<div className="flex items-center gap-2.5">
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">加班记录</h1>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
@@ -27,12 +27,14 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
|||||||
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||||
import { hasPermission } from '@/lib/permissions';
|
import { hasPermission } from '@/lib/permissions';
|
||||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
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 { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||||
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
||||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
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 {
|
function formatOverviewDateTime(value?: string | null): string {
|
||||||
if (!value) return '-';
|
if (!value) return '-';
|
||||||
@@ -104,6 +106,7 @@ export default function VersionDetailPage() {
|
|||||||
}, [visibleTabs, activeTab]);
|
}, [visibleTabs, activeTab]);
|
||||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||||
|
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
@@ -123,6 +126,11 @@ export default function VersionDetailPage() {
|
|||||||
}, [fetchMembers, fetchCategories]);
|
}, [fetchMembers, fetchCategories]);
|
||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -199,7 +207,10 @@ export default function VersionDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderActions = () => {
|
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') {
|
if (version.status === 'planned') {
|
||||||
buttons.push({ label: '删除', action: () => {
|
buttons.push({ label: '删除', action: () => {
|
||||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||||
@@ -229,7 +240,7 @@ export default function VersionDetailPage() {
|
|||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={btn.label}
|
||||||
onClick={btn.action}
|
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}
|
{btn.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -441,10 +452,11 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
|
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
|
||||||
const deadline = version.expectedReleaseDate;
|
const deadline = version.expectedReleaseDate;
|
||||||
|
const actualReleaseDate = version.releaseDate;
|
||||||
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
||||||
let overdueDays = 0;
|
let overdueDays = 0;
|
||||||
if (deadline && isTerminalVersion && actualEndIso) {
|
if (deadline && actualReleaseDate) {
|
||||||
const endDate = new Date(actualEndIso);
|
const endDate = new Date(actualReleaseDate);
|
||||||
const deadlineDate = new Date(deadline);
|
const deadlineDate = new Date(deadline);
|
||||||
endDate.setHours(0, 0, 0, 0);
|
endDate.setHours(0, 0, 0, 0);
|
||||||
deadlineDate.setHours(0, 0, 0, 0);
|
deadlineDate.setHours(0, 0, 0, 0);
|
||||||
@@ -453,7 +465,8 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
|
{ 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: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
|
||||||
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
|
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
|
||||||
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
|
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
|
||||||
@@ -462,7 +475,7 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
|
<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) => (
|
{metrics.map((item) => (
|
||||||
<OverviewMetric
|
<OverviewMetric
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -476,7 +489,7 @@ export default function VersionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{overdueDays > 0 && (
|
{overdueDays > 0 && (
|
||||||
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{showMemberModal && (
|
||||||
<MemberSettingModal
|
<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 }: {
|
function OverviewMetric({ label, value, icon, sub, tone }: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ import { buildVersionProgressMap } from '@/lib/version-progress';
|
|||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
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';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
|
|
||||||
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||||
@@ -462,7 +470,7 @@ type IterationType = 'major' | 'minor' | 'patch';
|
|||||||
interface NewVersionModalProps {
|
interface NewVersionModalProps {
|
||||||
overview: any[];
|
overview: any[];
|
||||||
onClose: () => void;
|
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;
|
currentUserName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +518,8 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
const [iterationType, setIterationType] = useState<IterationType | ''>('');
|
const [iterationType, setIterationType] = useState<IterationType | ''>('');
|
||||||
const [versionNumber, setVersionNumber] = useState('');
|
const [versionNumber, setVersionNumber] = useState('');
|
||||||
|
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
|
||||||
|
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
|
||||||
const [status] = useState<VersionStatus>('developing');
|
const [status] = useState<VersionStatus>('developing');
|
||||||
const [priority, setPriority] = useState<Priority>('P2');
|
const [priority, setPriority] = useState<Priority>('P2');
|
||||||
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
|
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
|
||||||
@@ -526,6 +536,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
setProjectName('');
|
setProjectName('');
|
||||||
setIterationType('');
|
setIterationType('');
|
||||||
setVersionNumber('');
|
setVersionNumber('');
|
||||||
|
setDevelopmentType('');
|
||||||
|
setProductDesignCompleted(false);
|
||||||
|
setExpectedReleaseDate('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// When project changes
|
// When project changes
|
||||||
@@ -535,6 +548,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
setProjectName(proj?.name ?? '');
|
setProjectName(proj?.name ?? '');
|
||||||
setIterationType('');
|
setIterationType('');
|
||||||
setVersionNumber('');
|
setVersionNumber('');
|
||||||
|
setDevelopmentType('');
|
||||||
|
setProductDesignCompleted(false);
|
||||||
|
setExpectedReleaseDate('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// When iteration type changes, auto-generate version number
|
// When iteration type changes, auto-generate version number
|
||||||
@@ -552,7 +568,33 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
setVersionNumber(cleaned);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
@@ -562,7 +604,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
name: `${projectName}V${versionNumber}`,
|
name: `${projectName}V${versionNumber}`,
|
||||||
status,
|
status,
|
||||||
priority,
|
priority,
|
||||||
...(expectedReleaseDate ? { expectedReleaseDate } : {}),
|
expectedReleaseDate,
|
||||||
members: currentUserName ? [{ role: 'product', name: currentUserName }] : [],
|
members: currentUserName ? [{ role: 'product', name: currentUserName }] : [],
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -573,7 +615,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
<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">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-[15px] font-semibold text-[var(--ink)]">新建版本</h2>
|
<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">
|
<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>
|
</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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={expectedReleaseDate}
|
value={expectedReleaseDate}
|
||||||
onChange={(e) => setExpectedReleaseDate(e.target.value)}
|
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)]"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lu
|
|||||||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||||
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||||||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
||||||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
||||||
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
|
import {
|
||||||
|
filterXiaobaoRiskWarnings,
|
||||||
|
formatRemainingWork,
|
||||||
|
isXiaobaoWarningUpdated,
|
||||||
|
sanitizeRiskInsight,
|
||||||
|
} from '@/lib/xiaobao-warning-view';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||||
@@ -42,6 +49,7 @@ export default function XiaobaoWarningPage() {
|
|||||||
function XiaobaoWarningContent() {
|
function XiaobaoWarningContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canManage = useHasPermission('xiaobao.warning:manage');
|
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const {
|
const {
|
||||||
risks,
|
risks,
|
||||||
snapshots,
|
snapshots,
|
||||||
@@ -54,12 +62,17 @@ function XiaobaoWarningContent() {
|
|||||||
finishInsightUpdate,
|
finishInsightUpdate,
|
||||||
today,
|
today,
|
||||||
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||||
|
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
|
||||||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||||
const [selectedProductId, setSelectedProductId] = useState('');
|
const [selectedProductId, setSelectedProductId] = useState('');
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||||
const savedSnapshotKeysRef = useRef(new Set<string>());
|
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||||
const requestedInsightKeysRef = useRef(new Set<string>());
|
const requestedInsightKeysRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id) fetchReadStates();
|
||||||
|
}, [fetchReadStates, user?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
risks.forEach((risk) => {
|
risks.forEach((risk) => {
|
||||||
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
||||||
@@ -139,6 +152,15 @@ function XiaobaoWarningContent() {
|
|||||||
projectId: selectedProjectId || undefined,
|
projectId: selectedProjectId || undefined,
|
||||||
}), [risksWithInsight, selectedProductId, selectedProjectId]);
|
}), [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(() => {
|
useEffect(() => {
|
||||||
if (filteredRisks.length === 0) {
|
if (filteredRisks.length === 0) {
|
||||||
setSelectedRiskId(null);
|
setSelectedRiskId(null);
|
||||||
@@ -156,6 +178,12 @@ function XiaobaoWarningContent() {
|
|||||||
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
|
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const selectRisk = (risk: XiaobaoVersionRisk) => {
|
||||||
|
setSelectedRiskId(risk.versionId);
|
||||||
|
if (!user?.id) return;
|
||||||
|
markRiskRead(user.id, risk).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
<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">
|
<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}
|
key={risk.versionId}
|
||||||
active={risk.versionId === selectedRiskId}
|
active={risk.versionId === selectedRiskId}
|
||||||
risk={risk}
|
risk={risk}
|
||||||
onClick={() => setSelectedRiskId(risk.versionId)}
|
updated={updatedRiskIds.has(risk.versionId)}
|
||||||
|
onClick={() => selectRisk(risk)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
@@ -7,8 +8,15 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
|||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
|
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||||||
import { getWorkspacePendingCount } from '@/lib/workspace-engine';
|
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 = {
|
type NavItemConfig = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -180,17 +188,31 @@ function NavItem({ item, active, onNavigate, workspacePendingCount }: {
|
|||||||
|
|
||||||
function XiaobaoRiskNavBadge() {
|
function XiaobaoRiskNavBadge() {
|
||||||
const { risks } = useXiaobaoWarningRisks();
|
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);
|
const count = getXiaobaoWarningRiskCount(risks);
|
||||||
if (count <= 0) return null;
|
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;
|
if (count <= 0) return null;
|
||||||
|
const toneClass = tone === 'update' ? 'bg-blue-600' : 'bg-red-600';
|
||||||
|
|
||||||
return (
|
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}
|
{count > 99 ? '99+' : count}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import {
|
import {
|
||||||
calcPlanProgress,
|
getResearchDirectionProgressSummary,
|
||||||
getRequirementCoverageSummary,
|
getRequirementCoverageSummary,
|
||||||
PRODUCT_PLAN_KIND_LABEL,
|
PRODUCT_PLAN_KIND_LABEL,
|
||||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||||
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||||
} from '@/lib/version-plan';
|
} from '@/lib/version-plan';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
|
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
|
||||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
import { canEditPlanRequirementCoverage, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||||
import type { PlanResultPayload } 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 {
|
interface Props {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -70,23 +70,16 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
|
|
||||||
const completionState = getPlanCompletionState(plan);
|
const completionState = getPlanCompletionState(plan);
|
||||||
const isResearch = plan.type === 'research';
|
const progress = plan.type === 'research'
|
||||||
const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent;
|
? 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 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 canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||||
const currentUserName = user?.name ?? plan.owner;
|
const currentUserName = user?.name ?? plan.owner;
|
||||||
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||||
const isProductDesignPlan = productPlanKind === 'design';
|
const isProductDesignPlan = productPlanKind === 'design';
|
||||||
const isProductReviewPlan = productPlanKind === 'review';
|
const isProductReviewPlan = productPlanKind === 'review';
|
||||||
|
|
||||||
const 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 handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
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 className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Research Tasks */}
|
{plan.type === 'research' && (
|
||||||
{isResearch && plan.tasks && plan.tasks.length > 0 && (
|
<PlanResearchDirectionPanel
|
||||||
<div className="space-y-1.5">
|
plan={plan}
|
||||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">任务清单</div>
|
canEdit={canEditCoverage}
|
||||||
{plan.tasks.map((task) => (
|
currentUserName={currentUserName}
|
||||||
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
onUpdate={updatePlan}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{linkedReqs.length > 0 && (
|
{linkedReqs.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<PlanRequirementCoveragePanel
|
{plan.type === 'research' ? (
|
||||||
plan={plan}
|
<PlanLinkedRequirementReferenceList requirements={linkedReqs} />
|
||||||
requirements={linkedReqs}
|
) : (
|
||||||
canEdit={canEditCoverage}
|
<PlanRequirementCoveragePanel
|
||||||
currentUserName={currentUserName}
|
plan={plan}
|
||||||
onUpdate={updatePlan}
|
requirements={linkedReqs}
|
||||||
/>
|
canEdit={canEditCoverage}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onUpdate={updatePlan}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||||
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Result */}
|
||||||
{plan.status === 'completed' && plan.resultUrl && (
|
{plan.status === 'completed' && plan.resultUrl && (
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { useState } from 'react';
|
|||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import type { Requirement } from '@/lib/requirement';
|
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 {
|
import {
|
||||||
canSaveRequirementCoverageDraft,
|
canSaveRequirementCoverageDraft,
|
||||||
canOpenRequirementCoverageRecord,
|
canOpenRequirementCoverageRecord,
|
||||||
|
getResearchDirectionProgressSummary,
|
||||||
|
getResearchDirectionStatus,
|
||||||
getRequirementCoverage,
|
getRequirementCoverage,
|
||||||
getRequirementCoverageStatus,
|
getRequirementCoverageStatus,
|
||||||
getRequirementCoverageSummary,
|
getRequirementCoverageSummary,
|
||||||
REQUIREMENT_COVERAGE_LABEL,
|
REQUIREMENT_COVERAGE_LABEL,
|
||||||
|
updateResearchDirectionProgress,
|
||||||
updateRequirementCoverage,
|
updateRequirementCoverage,
|
||||||
} from '@/lib/version-plan';
|
} from '@/lib/version-plan';
|
||||||
|
|
||||||
@@ -25,6 +28,17 @@ interface CoverageProps {
|
|||||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
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 {
|
interface LogTimelineProps {
|
||||||
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -65,6 +79,7 @@ function getLogTone(log: VersionPlanLog): { badge: string } {
|
|||||||
|
|
||||||
function getLogTypeLabel(log: VersionPlanLog): string {
|
function getLogTypeLabel(log: VersionPlanLog): string {
|
||||||
if (log.type === 'ai_decompose') return 'AI 拆解';
|
if (log.type === 'ai_decompose') return 'AI 拆解';
|
||||||
|
if (log.type === 'research_direction_progress') return '调研方向';
|
||||||
if (log.type === 'requirement_progress') return '需求进度';
|
if (log.type === 'requirement_progress') return '需求进度';
|
||||||
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) {
|
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
|
||||||
const [selectedMonth, setSelectedMonth] = useState('all');
|
const [selectedMonth, setSelectedMonth] = useState('all');
|
||||||
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
calcPlanProgress,
|
calcPlanProgress,
|
||||||
sortPlansNewestFirst,
|
sortPlansNewestFirst,
|
||||||
getPlanLogsForPlans,
|
getPlanLogsForPlans,
|
||||||
|
getResearchDirectionPresetOptions,
|
||||||
|
getResearchDirectionProgressSummary,
|
||||||
getRequirementCoverageSummary,
|
getRequirementCoverageSummary,
|
||||||
PRODUCT_PLAN_KIND_LABEL,
|
PRODUCT_PLAN_KIND_LABEL,
|
||||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||||
@@ -20,7 +22,7 @@ import { FieldError } from '@/components/FieldError';
|
|||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||||
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
|
||||||
import type { VersionWithContext } from '@/lib/derive';
|
import type { VersionWithContext } from '@/lib/derive';
|
||||||
import type { Requirement } from '@/lib/requirement';
|
import type { Requirement } from '@/lib/requirement';
|
||||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||||
@@ -80,8 +82,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
|||||||
const totalDuration = calcTotalDuration(typePlans);
|
const totalDuration = calcTotalDuration(typePlans);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={planType === 'research' ? 'space-y-4' : '-m-5 h-[calc(100vh-98px)] min-h-[520px]'}>
|
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
|
||||||
{planType === 'research' && (
|
{false && planType === 'research' && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} 个{TYPE_LABEL[planType]}计划</span>
|
<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>
|
</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)]">
|
<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]}计划
|
暂无{TYPE_LABEL[planType]}计划
|
||||||
</div>
|
</div>
|
||||||
) : planType === 'research' ? (
|
) : false && planType === 'research' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{typePlans.map((plan) => {
|
{typePlans.map((plan) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -402,7 +404,7 @@ function ProductUiPlanWorkspace({
|
|||||||
onCreatePlan,
|
onCreatePlan,
|
||||||
}: {
|
}: {
|
||||||
typePlans: VersionPlan[];
|
typePlans: VersionPlan[];
|
||||||
planType: 'product' | 'ui';
|
planType: VersionPlan['type'];
|
||||||
totalDuration: string;
|
totalDuration: string;
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
version?: VersionWithContext;
|
version?: VersionWithContext;
|
||||||
@@ -461,7 +463,9 @@ function ProductUiPlanWorkspace({
|
|||||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
{typePlans.map((plan) => {
|
{typePlans.map((plan) => {
|
||||||
const { effectiveStatus } = getPlanRuntime(plan);
|
const { effectiveStatus } = getPlanRuntime(plan);
|
||||||
const summary = getRequirementCoverageSummary(plan);
|
const summary = plan.type === 'research'
|
||||||
|
? getResearchDirectionProgressSummary(plan)
|
||||||
|
: getRequirementCoverageSummary(plan);
|
||||||
const isSelected = plan.id === selectedPlanIdOrFirst;
|
const isSelected = plan.id === selectedPlanIdOrFirst;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -561,7 +565,7 @@ function ProductUiPlanDetail({
|
|||||||
onOpenComplete,
|
onOpenComplete,
|
||||||
}: {
|
}: {
|
||||||
plan: VersionPlan;
|
plan: VersionPlan;
|
||||||
planType: 'product' | 'ui';
|
planType: VersionPlan['type'];
|
||||||
version?: VersionWithContext;
|
version?: VersionWithContext;
|
||||||
currentUserName: string;
|
currentUserName: string;
|
||||||
versionMembers: { role: string; name: string }[];
|
versionMembers: { role: string; name: string }[];
|
||||||
@@ -691,15 +695,27 @@ function ProductUiPlanDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedRequirements.length > 0 && (
|
{plan.type === 'research' && (
|
||||||
<PlanRequirementCoveragePanel
|
<PlanResearchDirectionPanel
|
||||||
plan={plan}
|
plan={plan}
|
||||||
requirements={selectedRequirements}
|
|
||||||
canEdit={canEditCoverage}
|
canEdit={canEditCoverage}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
onUpdate={onUpdate}
|
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 && (
|
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||||
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -756,6 +772,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
|
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
|
||||||
[linkedRequirements, allRequirements, selectedReqs],
|
[linkedRequirements, allRequirements, selectedReqs],
|
||||||
);
|
);
|
||||||
|
const researchDirectionPresetOptions = useMemo(() => getResearchDirectionPresetOptions(tasks), [tasks]);
|
||||||
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
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>
|
<span className="text-[10px] text-[var(--ink-muted)] ml-1">至少添加一项</span>
|
||||||
</label>
|
</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">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ const RISK_LEVEL_STYLE: Record<XiaobaoRiskLevel, string> = {
|
|||||||
export function XiaobaoWarningCard({
|
export function XiaobaoWarningCard({
|
||||||
risk,
|
risk,
|
||||||
active = false,
|
active = false,
|
||||||
|
updated = false,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
risk: XiaobaoVersionRisk;
|
risk: XiaobaoVersionRisk;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -50,9 +52,16 @@ export function XiaobaoWarningCard({
|
|||||||
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
|
{risk.productName ?? '-'} / {risk.projectName ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${RISK_LEVEL_STYLE[risk.riskLevel]}`}>
|
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||||
{RISK_LEVEL_LABEL[risk.riskLevel]}
|
{updated && (
|
||||||
</span>
|
<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>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const WORK_ACTIVITY_ACTION_LABEL: Record<WorkActivityAction, string> = {
|
|||||||
version_plan_started: '开始计划',
|
version_plan_started: '开始计划',
|
||||||
version_plan_completed: '完成计划',
|
version_plan_completed: '完成计划',
|
||||||
version_plan_requirement_progress: '需求进度',
|
version_plan_requirement_progress: '需求进度',
|
||||||
|
version_plan_research_direction_progress: '调研方向',
|
||||||
dev_task_created: '新建开发任务',
|
dev_task_created: '新建开发任务',
|
||||||
dev_task_started: '开始开发',
|
dev_task_started: '开始开发',
|
||||||
dev_task_self_testing: '进入自测',
|
dev_task_self_testing: '进入自测',
|
||||||
|
|||||||
97
apps/web/lib/overtime.test.ts
Normal file
97
apps/web/lib/overtime.test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
export interface OvertimeRecord {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -12,6 +17,13 @@ export interface OvertimeRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OvertimeVisibilityContext {
|
||||||
|
viewer: AuthUser | null | undefined;
|
||||||
|
viewerRole: RoleItem | undefined;
|
||||||
|
members: Member[];
|
||||||
|
departments: Department[];
|
||||||
|
}
|
||||||
|
|
||||||
export type OvertimeReason =
|
export type OvertimeReason =
|
||||||
| 'requirement_change'
|
| 'requirement_change'
|
||||||
| 'requirement_add'
|
| 'requirement_add'
|
||||||
@@ -105,3 +117,46 @@ export function calcDuration(start: string, end: string): number {
|
|||||||
|
|
||||||
return Math.round(total * 10) / 10;
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,8 +105,26 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
|||||||
'xiaobao.warning:view',
|
'xiaobao.warning:view',
|
||||||
'overtime:view', 'overtime:create',
|
'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 {
|
export function hasPermission(role: RoleItem | undefined, permission: string): boolean {
|
||||||
if (!role) return false;
|
if (!role) return false;
|
||||||
if (role.permissions.includes('*')) return true;
|
if (role.permissions.includes('*')) return true;
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import type { RoleItem } from './members';
|
import type { RoleItem } from './members';
|
||||||
|
import { DEFAULT_ROLE_PERMISSIONS } from './permissions';
|
||||||
import { mergePresetRolePermissions } from './role-permission-migration';
|
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', () => {
|
test('mergePresetRolePermissions adds new preset permissions to existing product manager role', () => {
|
||||||
const roles: RoleItem[] = [
|
const roles: RoleItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import type { RoleItem } from './members';
|
import type { RoleItem } from './members';
|
||||||
|
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from './permissions';
|
||||||
|
|
||||||
const XIAOBAO_WARNING_ROLE_PERMISSIONS: Record<string, string[]> = {
|
const XIAOBAO_WARNING_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
'role-pm': ['xiaobao.warning:view', 'xiaobao.warning:manage'],
|
'role-pm': ['xiaobao.warning:view', 'xiaobao.warning:manage'],
|
||||||
'role-dev': ['xiaobao.warning:view'],
|
'role-dev': ['xiaobao.warning:view'],
|
||||||
'role-test': ['xiaobao.warning:view'],
|
'role-test': ['xiaobao.warning:view'],
|
||||||
'role-design': ['xiaobao.warning:view'],
|
'role-design': ['xiaobao.warning:view'],
|
||||||
|
'role-lead': ['xiaobao.warning:view'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
|
export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem[]; changed: boolean } {
|
||||||
let changed = false;
|
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 next = roles.map((role) => {
|
||||||
const xiaobaoPermissions = XIAOBAO_WARNING_ROLE_PERMISSIONS[role.id];
|
const xiaobaoPermissions = XIAOBAO_WARNING_ROLE_PERMISSIONS[role.id];
|
||||||
if (!xiaobaoPermissions || role.permissions.includes('*')) return role;
|
if (!xiaobaoPermissions || role.permissions.includes('*')) return role;
|
||||||
@@ -17,5 +21,14 @@ export function mergePresetRolePermissions(roles: RoleItem[]): { roles: RoleItem
|
|||||||
changed = true;
|
changed = true;
|
||||||
return { ...role, permissions: merged };
|
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 };
|
return { roles: next, changed };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ServerDataKey =
|
|||||||
| 'work-activities'
|
| 'work-activities'
|
||||||
| 'xiaobao-risk-insights'
|
| 'xiaobao-risk-insights'
|
||||||
| 'xiaobao-risk-snapshots'
|
| 'xiaobao-risk-snapshots'
|
||||||
|
| 'xiaobao-warning-views'
|
||||||
| 'overtime';
|
| 'overtime';
|
||||||
|
|
||||||
interface ServerDataResponse<T> {
|
interface ServerDataResponse<T> {
|
||||||
|
|||||||
70
apps/web/lib/version-form.test.ts
Normal file
70
apps/web/lib/version-form.test.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
115
apps/web/lib/version-form.ts
Normal file
115
apps/web/lib/version-form.ts
Normal 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);
|
||||||
|
}
|
||||||
82
apps/web/lib/version-plan-research-direction.test.ts
Normal file
82
apps/web/lib/version-plan-research-direction.test.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -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({
|
const state = getPlanCompletionState(plan({
|
||||||
type: 'research',
|
type: 'research',
|
||||||
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
|
tasks: [{ id: 'task-1', title: '调研方向', status: 'pending' }],
|
||||||
linkedRequirementIds: [],
|
linkedRequirementIds: ['r1'],
|
||||||
}));
|
}));
|
||||||
assert.equal(state.canSubmitResult, false);
|
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', () => {
|
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);
|
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({
|
const state = getPlanCompletionState(plan({
|
||||||
type: 'research',
|
type: 'research',
|
||||||
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],
|
tasks: [{ id: 'task-1', title: '调研', status: 'completed' }],
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
|||||||
const requirementCompleted = requirementSummary.completed;
|
const requirementCompleted = requirementSummary.completed;
|
||||||
|
|
||||||
const missingReasons: string[] = [];
|
const missingReasons: string[] = [];
|
||||||
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少子任务');
|
if (requiresChecklist(plan) && checklistTotal === 0) missingReasons.push('缺少调研方向');
|
||||||
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成');
|
if (requiresChecklist(plan) && checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('调研方向未全部完成');
|
||||||
if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
|
if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) {
|
||||||
missingReasons.push('关联需求未全部覆盖');
|
missingReasons.push('关联需求未全部覆盖');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,3 +193,16 @@ test('hides coverage record action after a requirement is completed', () => {
|
|||||||
assert.equal(canOpenRequirementCoverageRecord!('completed', true), false);
|
assert.equal(canOpenRequirementCoverageRecord!('completed', true), false);
|
||||||
assert.equal(canOpenRequirementCoverageRecord!('partial', false), 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
|||||||
export type ProductPlanKind = 'design' | 'review';
|
export type ProductPlanKind = 'design' | 'review';
|
||||||
export type ProductPlanReviewResult = 'passed' | 'failed';
|
export type ProductPlanReviewResult = 'passed' | 'failed';
|
||||||
export type RequirementCoverageStatus = 'not_started' | 'partial' | 'completed';
|
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 AiDecomposeLogStatus = 'started' | 'completed' | 'error';
|
||||||
export type ProductPlanReviewFailureType =
|
export type ProductPlanReviewFailureType =
|
||||||
| 'requirement_mismatch'
|
| 'requirement_mismatch'
|
||||||
@@ -45,6 +45,30 @@ export interface PlanTask {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: PlanTaskStatus;
|
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 {
|
export interface VersionPlanRequirementCoverage {
|
||||||
@@ -69,6 +93,8 @@ export interface VersionPlanLog {
|
|||||||
coverageStatus?: RequirementCoverageStatus;
|
coverageStatus?: RequirementCoverageStatus;
|
||||||
completedContent?: string;
|
completedContent?: string;
|
||||||
remainingContent?: string;
|
remainingContent?: string;
|
||||||
|
directionTaskId?: string;
|
||||||
|
directionTitle?: string;
|
||||||
aiTarget?: AgentDecomposeTarget;
|
aiTarget?: AgentDecomposeTarget;
|
||||||
aiStatus?: AiDecomposeLogStatus;
|
aiStatus?: AiDecomposeLogStatus;
|
||||||
}
|
}
|
||||||
@@ -84,6 +110,15 @@ export interface RequirementCoverageUpdateInput {
|
|||||||
requirementTitle?: string;
|
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'> & {
|
export type PlanLogDraft = Omit<VersionPlanLog, 'id' | 'createdAt'> & {
|
||||||
id?: string;
|
id?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -100,6 +135,33 @@ export const REQUIREMENT_COVERAGE_LABEL: Record<RequirementCoverageStatus, strin
|
|||||||
completed: '完全完成',
|
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 {
|
export interface VersionPlan {
|
||||||
id: string;
|
id: string;
|
||||||
versionId: 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 {
|
function getPlanCreatedAtTime(plan: VersionPlan): number {
|
||||||
const time = new Date(plan.createdAt).getTime();
|
const time = new Date(plan.createdAt).getTime();
|
||||||
return Number.isFinite(time) ? time : 0;
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
|||||||
19
apps/web/lib/version-release.test.ts
Normal file
19
apps/web/lib/version-release.test.ts
Normal 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);
|
||||||
|
});
|
||||||
14
apps/web/lib/version-release.ts
Normal file
14
apps/web/lib/version-release.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
export function makeDevTaskCreatedActivity(task: DevTask, actorId: string): WorkActivityDraft {
|
||||||
return {
|
return {
|
||||||
actorId,
|
actorId,
|
||||||
|
|||||||
49
apps/web/lib/work-activity-research-direction.test.ts
Normal file
49
apps/web/lib/work-activity-research-direction.test.ts
Normal 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, '补充支付流程差异');
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ export type WorkActivityAction =
|
|||||||
| 'version_plan_started'
|
| 'version_plan_started'
|
||||||
| 'version_plan_completed'
|
| 'version_plan_completed'
|
||||||
| 'version_plan_requirement_progress'
|
| 'version_plan_requirement_progress'
|
||||||
|
| 'version_plan_research_direction_progress'
|
||||||
| 'dev_task_created'
|
| 'dev_task_created'
|
||||||
| 'dev_task_started'
|
| 'dev_task_started'
|
||||||
| 'dev_task_self_testing'
|
| 'dev_task_self_testing'
|
||||||
|
|||||||
@@ -279,6 +279,89 @@ test('getWorkspaceDailyReport counts actual hours from today activity sources',
|
|||||||
assert.equal(report.totalCount, 2);
|
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', () => {
|
test('getWorkspaceDailyReport rebuilds activity evidence from persisted work item timestamps', () => {
|
||||||
const report = getWorkspaceDailyReport({
|
const report = getWorkspaceDailyReport({
|
||||||
activities: [],
|
activities: [],
|
||||||
|
|||||||
@@ -120,12 +120,13 @@ export function getWorkspaceDailyReport({
|
|||||||
...items.map((item) => item.taskId),
|
...items.map((item) => item.taskId),
|
||||||
]);
|
]);
|
||||||
const worklogTaskIds = new Set(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)))
|
const activityHours = calcActivityHoursForDate(
|
||||||
.filter((sourceId) => !worklogTaskIds.has(sourceId))
|
Array.from(new Set(reportActivities.map((activity) => activity.sourceId))),
|
||||||
.reduce((sum, sourceId) => {
|
workItemMap,
|
||||||
const item = workItemMap.get(sourceId);
|
worklogTaskIds,
|
||||||
return sum + calcWorkItemHoursForDate(item, date, now);
|
date,
|
||||||
}, 0);
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
const needsProgressItems = workItems
|
const needsProgressItems = workItems
|
||||||
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
.filter((item) => shouldRequireProgress(item, date, touchedSourceIds))
|
||||||
@@ -297,25 +298,64 @@ function getTestCaseTerminalFallback(
|
|||||||
return undefined;
|
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);
|
const interval = getActualInterval(item, now);
|
||||||
if (!interval) return 0;
|
if (!interval) return undefined;
|
||||||
|
|
||||||
const day = getLocalDateBounds(date);
|
const day = getLocalDateBounds(date);
|
||||||
if (!day) return 0;
|
if (!day) return undefined;
|
||||||
|
|
||||||
const start = new Date(interval.start).getTime();
|
const start = new Date(interval.start).getTime();
|
||||||
const end = new Date(interval.end).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 overlapStart = Math.max(start, day.start.getTime());
|
||||||
const overlapEnd = Math.min(end, day.end.getTime());
|
const overlapEnd = Math.min(end, day.end.getTime());
|
||||||
if (overlapEnd <= overlapStart) return 0;
|
if (overlapEnd <= overlapStart) return undefined;
|
||||||
|
|
||||||
return calcActualElapsedHours(
|
return { start: overlapStart, end: overlapEnd };
|
||||||
new Date(overlapStart).toISOString(),
|
}
|
||||||
new Date(overlapEnd).toISOString(),
|
|
||||||
);
|
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 {
|
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ test('getReusableInsight keeps showing cached insight when only refresh-volatile
|
|||||||
dailyEvidence: {
|
dailyEvidence: {
|
||||||
...risk().dailyEvidence!,
|
...risk().dailyEvidence!,
|
||||||
recentActivityCount: 12,
|
recentActivityCount: 12,
|
||||||
|
todayActualHours: 1.5,
|
||||||
lastActivityAt: '2026-06-29T03:37:15.903Z',
|
lastActivityAt: '2026-06-29T03:37:15.903Z',
|
||||||
todayProgress: [
|
todayProgress: [
|
||||||
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
|
{ 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: {
|
dailyEvidence: {
|
||||||
...risk().dailyEvidence!,
|
...risk().dailyEvidence!,
|
||||||
recentActivityCount: 13,
|
recentActivityCount: 13,
|
||||||
|
todayActualHours: 3,
|
||||||
lastActivityAt: '2026-06-29T06:21:38.597Z',
|
lastActivityAt: '2026-06-29T06:21:38.597Z',
|
||||||
todayProgress: [
|
todayProgress: [
|
||||||
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },
|
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },
|
||||||
|
|||||||
@@ -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(
|
export function getReusableInsight(
|
||||||
cache: XiaobaoRiskInsightCacheItem[],
|
cache: XiaobaoRiskInsightCacheItem[],
|
||||||
risk: XiaobaoVersionRisk,
|
risk: XiaobaoVersionRisk,
|
||||||
@@ -365,7 +373,6 @@ function buildDisplayCompatibilityKey(signature: string): string | undefined {
|
|||||||
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
|
progressNotes: normalizeCompatibilityEvidence(dailyEvidence.progressNotes),
|
||||||
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
|
needsProgressItems: normalizeCompatibilityEvidence(dailyEvidence.needsProgressItems),
|
||||||
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
|
totalActivityCount: normalizeInteger(dailyEvidence.totalActivityCount),
|
||||||
todayActualHours: normalizeNumber(dailyEvidence.todayActualHours),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -436,10 +443,6 @@ function normalizeInteger(value: unknown): number | null {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 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 {
|
function getTime(value: string): number {
|
||||||
const time = new Date(value).getTime();
|
const time = new Date(value).getTime();
|
||||||
return Number.isFinite(time) ? time : Number.NaN;
|
return Number.isFinite(time) ? time : Number.NaN;
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ test('buildRiskSignature changes when critical bug count changes without open bu
|
|||||||
assert.notEqual(base, changed);
|
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', () => {
|
test('findLatestDailySnapshot returns latest same-day snapshot for a version', () => {
|
||||||
const latest = findLatestDailySnapshot([
|
const latest = findLatestDailySnapshot([
|
||||||
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),
|
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 40, createdAt: '2026-06-29T09:00:00.000Z' }),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
|||||||
snapshot.date,
|
snapshot.date,
|
||||||
clampScore(snapshot.riskScore),
|
clampScore(snapshot.riskScore),
|
||||||
snapshot.riskLevel,
|
snapshot.riskLevel,
|
||||||
snapshot.forecastReleaseDate ?? '',
|
normalizeForecastDate(snapshot.forecastReleaseDate),
|
||||||
snapshot.openBugCount,
|
snapshot.openBugCount,
|
||||||
snapshot.criticalBugCount ?? 0,
|
snapshot.criticalBugCount ?? 0,
|
||||||
snapshot.failedTestCount,
|
snapshot.failedTestCount,
|
||||||
@@ -136,6 +136,16 @@ function hasForecastShiftedByOneDay(current?: string, previous?: string): boolea
|
|||||||
return Number.isFinite(delta) && delta >= ONE_DAY_MS;
|
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 {
|
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
||||||
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
||||||
return Number.isFinite(date) ? date : 0;
|
return Number.isFinite(date) ? date : 0;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
formatRemainingWork,
|
formatRemainingWork,
|
||||||
getRiskScoreTone,
|
getRiskScoreTone,
|
||||||
getXiaobaoWarningRiskCount,
|
getXiaobaoWarningRiskCount,
|
||||||
|
getXiaobaoWarningUnreadUpdateCount,
|
||||||
|
getXiaobaoWarningUpdateSignature,
|
||||||
|
isXiaobaoWarningUpdated,
|
||||||
|
markXiaobaoWarningRead,
|
||||||
sanitizeRiskInsight,
|
sanitizeRiskInsight,
|
||||||
} from './xiaobao-warning-view';
|
} from './xiaobao-warning-view';
|
||||||
|
|
||||||
@@ -157,3 +161,105 @@ test('sanitizeRiskInsight filters invalid page refresh suggested actions', () =>
|
|||||||
|
|
||||||
assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { VersionWithContext } from './derive';
|
import type { VersionWithContext } from './derive';
|
||||||
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
||||||
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
||||||
|
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
|
||||||
import { WORK_HOURS } from './work-hours';
|
import { WORK_HOURS } from './work-hours';
|
||||||
|
|
||||||
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
||||||
@@ -28,6 +29,13 @@ export interface XiaobaoWarningRiskListFilter {
|
|||||||
|
|
||||||
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
|
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
|
||||||
|
|
||||||
|
export interface XiaobaoWarningReadState {
|
||||||
|
userId: string;
|
||||||
|
versionId: string;
|
||||||
|
signature: string;
|
||||||
|
readAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function filterXiaobaoWarningVersions(
|
export function filterXiaobaoWarningVersions(
|
||||||
versions: VersionWithContext[],
|
versions: VersionWithContext[],
|
||||||
filter: XiaobaoWarningVersionFilter,
|
filter: XiaobaoWarningVersionFilter,
|
||||||
@@ -58,6 +66,52 @@ export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number
|
|||||||
return filterXiaobaoRiskWarnings(risks).length;
|
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 {
|
export function getRiskScoreTone(score: number): RiskScoreTone {
|
||||||
if (score >= 75) return 'danger';
|
if (score >= 75) return 'danger';
|
||||||
if (score >= 55) return 'warn';
|
if (score >= 55) return 'warn';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
ensureSystemAdminMember,
|
ensureSystemAdminMember,
|
||||||
sanitizeSystemAdminPatch,
|
sanitizeSystemAdminPatch,
|
||||||
} from '@/lib/member-system';
|
} 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 { mergePresetRolePermissions } from '@/lib/role-permission-migration';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
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' },
|
{ id: 'dept-4', name: '运营部', order: 4, createdAt: '2024-01-01' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRESET_ROLES: RoleItem[] = [
|
const PRESET_ROLES: RoleItem[] = DEFAULT_ROLE_PRESETS;
|
||||||
{ 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 MOCK_MEMBERS: Member[] = [
|
const MOCK_MEMBERS: Member[] = [
|
||||||
SYSTEM_ADMIN_MEMBER,
|
SYSTEM_ADMIN_MEMBER,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
makeVersionPlanCompletedActivity,
|
makeVersionPlanCompletedActivity,
|
||||||
makeVersionPlanCreatedActivity,
|
makeVersionPlanCreatedActivity,
|
||||||
makeVersionPlanRequirementProgressActivity,
|
makeVersionPlanRequirementProgressActivity,
|
||||||
|
makeVersionPlanResearchDirectionProgressActivity,
|
||||||
makeVersionPlanStartedActivity,
|
makeVersionPlanStartedActivity,
|
||||||
} from '@/lib/work-activity-factory';
|
} from '@/lib/work-activity-factory';
|
||||||
import type { WorkActivityDraft } from '@/lib/work-activity';
|
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));
|
const previousLogIds = new Set((p.logs ?? []).map((log) => log.id));
|
||||||
for (const log of next.logs ?? []) {
|
for (const log of next.logs ?? []) {
|
||||||
if (previousLogIds.has(log.id)) continue;
|
if (previousLogIds.has(log.id)) continue;
|
||||||
const activity = makeVersionPlanRequirementProgressActivity(next, log);
|
const requirementActivity = makeVersionPlanRequirementProgressActivity(next, log);
|
||||||
if (activity) activities.push(activity);
|
if (requirementActivity) activities.push(requirementActivity);
|
||||||
|
const directionActivity = makeVersionPlanResearchDirectionProgressActivity(next, log);
|
||||||
|
if (directionActivity) activities.push(directionActivity);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
94
apps/web/stores/useXiaobaoWarningReadStore.ts
Normal file
94
apps/web/stores/useXiaobaoWarningReadStore.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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`.
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user