feat(版本): 完善研发计划与预警已读
关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -6,8 +6,9 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||
import { calcDuration } from '@/lib/overtime';
|
||||
import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime';
|
||||
import type { OvertimeRecord } from '@/lib/overtime';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { DictDrawer } from '@/components/requirement/DictDrawer';
|
||||
@@ -29,7 +30,9 @@ function OvertimePageContent() {
|
||||
const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { departments, members, roles, fetchMembers } = useMemberStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const viewerRole = useMemo(() => roles.find((role) => role.id === user?.roleId), [roles, user?.roleId]);
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
@@ -43,20 +46,27 @@ function OvertimePageContent() {
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-';
|
||||
const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-';
|
||||
const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-';
|
||||
const visibleRecords = useMemo(() => filterOvertimeRecordsForViewer(records, {
|
||||
viewer: user,
|
||||
viewerRole,
|
||||
members,
|
||||
departments,
|
||||
}), [records, user, viewerRole, members, departments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...records];
|
||||
let list = [...visibleRecords];
|
||||
if (search) list = list.filter((r) => r.person.includes(search));
|
||||
if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter);
|
||||
if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter);
|
||||
if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter);
|
||||
list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
return list;
|
||||
}, [records, search, projectFilter, reasonFilter, monthFilter]);
|
||||
}, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
|
||||
@@ -90,7 +100,7 @@ function OvertimePageContent() {
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">加班记录</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{records.length}</span>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{visibleRecords.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleExport} className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
@@ -27,12 +27,14 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
import { calcActualElapsedHours, formatActualDuration } from '@/lib/work-hours';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { formatDateTime, formatLocalDate } from '@/lib/format';
|
||||
import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector';
|
||||
import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetrics, calcVersionOverviewEffortTotals } from '@/lib/version-overview';
|
||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||
|
||||
function formatOverviewDateTime(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -104,6 +106,7 @@ export default function VersionDetailPage() {
|
||||
}, [visibleTabs, activeTab]);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
@@ -123,6 +126,11 @@ export default function VersionDetailPage() {
|
||||
}, [fetchMembers, fetchCategories]);
|
||||
|
||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||
const releaseProgressMap = useMemo<Record<string, number>>(
|
||||
() => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {},
|
||||
[version, plans, requirements, devTasks, testCases],
|
||||
);
|
||||
const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0;
|
||||
|
||||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||
useEffect(() => {
|
||||
@@ -199,7 +207,10 @@ export default function VersionDetailPage() {
|
||||
});
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: { label: string; action: () => void; danger?: boolean }[] = [];
|
||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
|
||||
if (version.status !== 'released' && version.status !== 'closed') {
|
||||
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
|
||||
}
|
||||
if (version.status === 'planned') {
|
||||
buttons.push({ label: '删除', action: () => {
|
||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||
@@ -229,7 +240,7 @@ export default function VersionDetailPage() {
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.action}
|
||||
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
@@ -441,10 +452,11 @@ export default function VersionDetailPage() {
|
||||
|
||||
const isTerminalVersion = version.status === 'released' || version.status === 'closed';
|
||||
const deadline = version.expectedReleaseDate;
|
||||
const actualReleaseDate = version.releaseDate;
|
||||
const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
||||
let overdueDays = 0;
|
||||
if (deadline && isTerminalVersion && actualEndIso) {
|
||||
const endDate = new Date(actualEndIso);
|
||||
if (deadline && actualReleaseDate) {
|
||||
const endDate = new Date(actualReleaseDate);
|
||||
const deadlineDate = new Date(deadline);
|
||||
endDate.setHours(0, 0, 0, 0);
|
||||
deadlineDate.setHours(0, 0, 0, 0);
|
||||
@@ -453,7 +465,8 @@ export default function VersionDetailPage() {
|
||||
|
||||
const metrics = [
|
||||
{ label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: <Calendar className="h-3.5 w-3.5" /> },
|
||||
{ label: '预计截止', value: deadline ?? '未设置' },
|
||||
{ label: '期望发版', value: deadline ? formatOverviewDateTime(deadline) : '未设置' },
|
||||
{ label: '实际发版日期', value: actualReleaseDate ? formatOverviewDateTime(actualReleaseDate) : '未发布' },
|
||||
{ label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' },
|
||||
{ label: '实际耗时', value: formatActualDuration(versionActualHours), icon: <Clock className="h-3.5 w-3.5" /> },
|
||||
{ label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const },
|
||||
@@ -462,7 +475,7 @@ export default function VersionDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-7">
|
||||
{metrics.map((item) => (
|
||||
<OverviewMetric
|
||||
key={item.label}
|
||||
@@ -476,7 +489,7 @@ export default function VersionDetailPage() {
|
||||
</div>
|
||||
{overdueDays > 0 && (
|
||||
<div className="border-t border-red-100 bg-red-50 px-4 py-2 text-[12px] font-medium text-red-600">
|
||||
实际截止晚于预计截止 {overdueDays} 天
|
||||
实际发版晚于期望发版 {overdueDays} 天
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -930,6 +943,18 @@ export default function VersionDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReleaseModal && (
|
||||
<ReleaseVersionModal
|
||||
progress={releaseProgress}
|
||||
initialDate={formatLocalDate()}
|
||||
onSubmit={(releaseDate) => {
|
||||
updateVersion(version.productId, version.id, { status: 'released', releaseDate });
|
||||
setShowReleaseModal(false);
|
||||
}}
|
||||
onClose={() => setShowReleaseModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参与人员设置弹窗 */}
|
||||
{showMemberModal && (
|
||||
<MemberSettingModal
|
||||
@@ -946,6 +971,79 @@ export default function VersionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: {
|
||||
progress: number;
|
||||
initialDate: string;
|
||||
onSubmit: (releaseDate: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [releaseDate, setReleaseDate] = useState(initialDate);
|
||||
const warning = getReleaseProgressWarning(progress);
|
||||
const canSubmit = canSubmitReleaseForm(releaseDate);
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onSubmit(releaseDate.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-md"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-[var(--ink)]">发版</h3>
|
||||
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">填写实际发版日期后,版本状态会更新为已发布。</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{warning ? (
|
||||
<div className="flex gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2.5 text-[12px] leading-5 text-amber-800">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-emerald-100 bg-emerald-50 px-3 py-2.5 text-[12px] font-medium text-emerald-700">
|
||||
当前版本进度 100%,可记录实际发版日期。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-[var(--ink-soft)]">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
实际发版日期
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={releaseDate}
|
||||
onChange={(event) => setReleaseDate(event.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] outline-none focus:border-[var(--accent)]"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="h-8 rounded-md border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className="h-8 rounded-md bg-emerald-600 px-4 text-[12px] font-semibold text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
确认发版
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewMetric({ label, value, icon, sub, tone }: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
||||
@@ -20,6 +20,14 @@ import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||
import { getRiskScoreTone } from '@/lib/xiaobao-warning-view';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
canSubmitNewVersionForm,
|
||||
getRecommendedExpectedReleaseDate,
|
||||
getRecommendedWorkDays,
|
||||
getVersionDevelopmentTypeOption,
|
||||
type VersionDevelopmentType,
|
||||
} from '@/lib/version-form';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4';
|
||||
@@ -462,7 +470,7 @@ type IterationType = 'major' | 'minor' | 'patch';
|
||||
interface NewVersionModalProps {
|
||||
overview: any[];
|
||||
onClose: () => void;
|
||||
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string; members?: any[] }) => void;
|
||||
onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate: string; members?: any[] }) => void;
|
||||
currentUserName: string;
|
||||
}
|
||||
|
||||
@@ -510,6 +518,8 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [iterationType, setIterationType] = useState<IterationType | ''>('');
|
||||
const [versionNumber, setVersionNumber] = useState('');
|
||||
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
|
||||
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
|
||||
const [status] = useState<VersionStatus>('developing');
|
||||
const [priority, setPriority] = useState<Priority>('P2');
|
||||
const [expectedReleaseDate, setExpectedReleaseDate] = useState('');
|
||||
@@ -526,6 +536,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
setProjectName('');
|
||||
setIterationType('');
|
||||
setVersionNumber('');
|
||||
setDevelopmentType('');
|
||||
setProductDesignCompleted(false);
|
||||
setExpectedReleaseDate('');
|
||||
};
|
||||
|
||||
// When project changes
|
||||
@@ -535,6 +548,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
setProjectName(proj?.name ?? '');
|
||||
setIterationType('');
|
||||
setVersionNumber('');
|
||||
setDevelopmentType('');
|
||||
setProductDesignCompleted(false);
|
||||
setExpectedReleaseDate('');
|
||||
};
|
||||
|
||||
// When iteration type changes, auto-generate version number
|
||||
@@ -552,7 +568,33 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
setVersionNumber(cleaned);
|
||||
};
|
||||
|
||||
const canSubmit = productId && projectId && versionNumber && !submitting;
|
||||
const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => {
|
||||
setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned }));
|
||||
};
|
||||
|
||||
const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => {
|
||||
setDevelopmentType(type);
|
||||
applyRecommendedReleaseDate(type, productDesignCompleted);
|
||||
};
|
||||
|
||||
const handleProductDesignCompletedChange = (completed: boolean) => {
|
||||
setProductDesignCompleted(completed);
|
||||
if (developmentType) applyRecommendedReleaseDate(developmentType, completed);
|
||||
};
|
||||
|
||||
const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined;
|
||||
const recommendedWorkDays = selectedDevelopmentType
|
||||
? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted })
|
||||
: 0;
|
||||
|
||||
const canSubmit = canSubmitNewVersionForm({
|
||||
productId,
|
||||
projectId,
|
||||
versionNumber,
|
||||
developmentType,
|
||||
expectedReleaseDate,
|
||||
submitting,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
@@ -562,7 +604,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
name: `${projectName}V${versionNumber}`,
|
||||
status,
|
||||
priority,
|
||||
...(expectedReleaseDate ? { expectedReleaseDate } : {}),
|
||||
expectedReleaseDate,
|
||||
members: currentUserName ? [{ role: 'product', name: currentUserName }] : [],
|
||||
});
|
||||
onClose();
|
||||
@@ -573,7 +615,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--ink)]">新建版本</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-hover)] transition-colors">
|
||||
@@ -641,15 +683,71 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 截止日期 */}
|
||||
{/* 项目开发类型 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">截止日期</label>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">项目开发类型<span className="text-red-500">*</span></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => handleDevelopmentTypeChange(opt.key)}
|
||||
type="button"
|
||||
className={`rounded-lg border px-3 py-2 text-left transition-colors ${developmentType === opt.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
<span className="block text-[12px] font-medium">{opt.label}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-[var(--ink-muted)]">{opt.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品设计是否已完成 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">产品设计是否已完成</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProductDesignCompletedChange(false)}
|
||||
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${!productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
否,计入产品设计
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProductDesignCompletedChange(true)}
|
||||
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
是,只算开发测试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 期望发版日期 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">期望发版日期<span className="text-red-500">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
value={expectedReleaseDate}
|
||||
onChange={(e) => setExpectedReleaseDate(e.target.value)}
|
||||
required
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
||||
/>
|
||||
{selectedDevelopmentType && (
|
||||
<p className="mt-1.5 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
推荐周期 {recommendedWorkDays} 个工作日:
|
||||
{selectedDevelopmentType.stages.map((stage, index) => {
|
||||
const skipped = productDesignCompleted && stage.key === 'product_design';
|
||||
return (
|
||||
<span key={stage.key}>
|
||||
{index > 0 && ' + '}
|
||||
<span className={skipped ? 'text-[var(--ink-muted)] line-through' : ''}>
|
||||
{stage.label}{stage.workDays}天
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,11 +6,18 @@ import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lu
|
||||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||
import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
||||
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai';
|
||||
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
|
||||
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
||||
import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view';
|
||||
import {
|
||||
filterXiaobaoRiskWarnings,
|
||||
formatRemainingWork,
|
||||
isXiaobaoWarningUpdated,
|
||||
sanitizeRiskInsight,
|
||||
} from '@/lib/xiaobao-warning-view';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const RISK_LEVEL_LABEL: Record<XiaobaoRiskLevel, string> = {
|
||||
@@ -42,6 +49,7 @@ export default function XiaobaoWarningPage() {
|
||||
function XiaobaoWarningContent() {
|
||||
const router = useRouter();
|
||||
const canManage = useHasPermission('xiaobao.warning:manage');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const {
|
||||
risks,
|
||||
snapshots,
|
||||
@@ -54,12 +62,17 @@ function XiaobaoWarningContent() {
|
||||
finishInsightUpdate,
|
||||
today,
|
||||
} = useXiaobaoWarningRisks({ loadRiskCache: true });
|
||||
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
|
||||
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
|
||||
const [selectedProductId, setSelectedProductId] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||
const savedSnapshotKeysRef = useRef(new Set<string>());
|
||||
const requestedInsightKeysRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) fetchReadStates();
|
||||
}, [fetchReadStates, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
risks.forEach((risk) => {
|
||||
const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() };
|
||||
@@ -139,6 +152,15 @@ function XiaobaoWarningContent() {
|
||||
projectId: selectedProjectId || undefined,
|
||||
}), [risksWithInsight, selectedProductId, selectedProjectId]);
|
||||
|
||||
const updatedRiskIds = useMemo(() => {
|
||||
if (!user?.id || !readStateLoaded) return new Set<string>();
|
||||
return new Set(
|
||||
filteredRisks
|
||||
.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, user.id))
|
||||
.map((risk) => risk.versionId),
|
||||
);
|
||||
}, [filteredRisks, readStateLoaded, readStates, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRisks.length === 0) {
|
||||
setSelectedRiskId(null);
|
||||
@@ -156,6 +178,12 @@ function XiaobaoWarningContent() {
|
||||
? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length)
|
||||
: 0;
|
||||
|
||||
const selectRisk = (risk: XiaobaoVersionRisk) => {
|
||||
setSelectedRiskId(risk.versionId);
|
||||
if (!user?.id) return;
|
||||
markRiskRead(user.id, risk).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--bg)]">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
@@ -224,7 +252,8 @@ function XiaobaoWarningContent() {
|
||||
key={risk.versionId}
|
||||
active={risk.versionId === selectedRiskId}
|
||||
risk={risk}
|
||||
onClick={() => setSelectedRiskId(risk.versionId)}
|
||||
updated={updatedRiskIds.has(risk.versionId)}
|
||||
onClick={() => selectRisk(risk)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user