feat: 计划任务清单+进度联动+与我相关工作台
- 计划新增任务清单:调研支持任务CRUD,进度=完成数/总数 - 产品方案/UI:通过关联需求勾选进度,全部完成提示提交 - 版本胶囊条动态进度:调研/产品方案/UI 阶段独立计算 - 版本状态显示具体阶段名(调研中/产品设计中/UI设计中) - 计划开始日期≤今天自动进入"进行中",版本状态联动 - 与我相关重写为左右布局:左侧分组导航,右侧任务/需求勾选 - 计划支持超期原因校验(结束日期超版本截止) - 修复列表overflow裁剪问题、计划耗时当天至少1天 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
|||||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||||
import { getVersionDetail } from '@/lib/derive';
|
import { getVersionDetail } from '@/lib/derive';
|
||||||
import { STAGES } from '@/lib/stage';
|
import { STAGES } from '@/lib/stage';
|
||||||
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
|
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
|
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
|
||||||
@@ -57,6 +57,27 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||||
|
|
||||||
|
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||||
|
useEffect(() => {
|
||||||
|
if (!version || !plans.length) return;
|
||||||
|
if (version.status !== 'planned' && version.status !== 'developing') return;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||||||
|
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
|
||||||
|
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
|
||||||
|
if (versionPlans.length === 0) return;
|
||||||
|
let targetStage = '';
|
||||||
|
for (const p of versionPlans) {
|
||||||
|
const s = stageMap[p.type];
|
||||||
|
if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) {
|
||||||
|
targetStage = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
|
||||||
|
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
|
||||||
|
}
|
||||||
|
}, [plans, version, versionId, updateVersion]);
|
||||||
|
|
||||||
const elapsedDays = useMemo(() => {
|
const elapsedDays = useMemo(() => {
|
||||||
if (!version?.startDate) return 0;
|
if (!version?.startDate) return 0;
|
||||||
const start = new Date(version.startDate);
|
const start = new Date(version.startDate);
|
||||||
@@ -171,7 +192,7 @@ export default function VersionDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
|
||||||
{VERSION_STATUS_LABEL[version.status]}
|
{getVersionDisplayStatus(version.status, version.currentStage)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
|
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
|
||||||
@@ -221,7 +242,50 @@ export default function VersionDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Capsule stages */}
|
{/* Capsule stages */}
|
||||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
{(() => {
|
||||||
|
const vPlans = plans.filter((p) => p.versionId === version.id);
|
||||||
|
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
||||||
|
const productPlans = vPlans.filter((p) => p.type === 'product');
|
||||||
|
const uiPlans = vPlans.filter((p) => p.type === 'ui');
|
||||||
|
|
||||||
|
const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => {
|
||||||
|
if (group.length === 0) return 0;
|
||||||
|
if (type === 'research') {
|
||||||
|
const totals = group.reduce((acc, p) => {
|
||||||
|
const tasks = p.tasks || [];
|
||||||
|
acc.total += tasks.length;
|
||||||
|
acc.done += tasks.filter((t) => t.status === 'completed').length;
|
||||||
|
return acc;
|
||||||
|
}, { total: 0, done: 0 });
|
||||||
|
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||||||
|
}
|
||||||
|
const totals = group.reduce((acc, p) => {
|
||||||
|
const linked = p.linkedRequirementIds || [];
|
||||||
|
const completed = p.completedRequirementIds || [];
|
||||||
|
acc.total += linked.length;
|
||||||
|
acc.done += completed.filter((id) => linked.includes(id)).length;
|
||||||
|
return acc;
|
||||||
|
}, { total: 0, done: 0 });
|
||||||
|
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const calcDays = (group: typeof vPlans) => {
|
||||||
|
if (group.length === 0) return 0;
|
||||||
|
const starts = group.map((p) => p.startTime).sort();
|
||||||
|
const startDate = starts[0];
|
||||||
|
if (startDate > today) return 0;
|
||||||
|
const diff = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
return Math.max(1, diff); // 当天开始至少算1天
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageProgress: Record<string, { percent: number; daysSpent: number }> = {};
|
||||||
|
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) };
|
||||||
|
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) };
|
||||||
|
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) };
|
||||||
|
|
||||||
|
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 双栏:加班排名 + 原因占比 */}
|
{/* 双栏:加班排名 + 原因占比 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -289,11 +353,34 @@ export default function VersionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium">参与人员</div>
|
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium">参与人员</div>
|
||||||
{version.members && version.members.length > 0 ? (
|
{(() => {
|
||||||
<MemberChips members={version.members} />
|
const planMembers = plans
|
||||||
) : (
|
.filter((p) => p.versionId === version.id)
|
||||||
<span className="text-[12px] text-[var(--ink-muted)]">暂无成员</span>
|
.map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner }));
|
||||||
)}
|
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI' };
|
||||||
|
// 按 role+name 去重(同阶段同一人只显示一次)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const dedupPlanMembers = planMembers.filter((m) => {
|
||||||
|
const key = `${m.role}:${m.name}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const existingKeys = new Set((version.members ?? []).map((m) => `${m.role}:${m.name}`));
|
||||||
|
const extraMembers = dedupPlanMembers.filter((m) => !existingKeys.has(`${m.role}:${m.name}`));
|
||||||
|
const allMembers = [...(version.members ?? []), ...extraMembers];
|
||||||
|
if (allMembers.length === 0) return <span className="text-[12px] text-[var(--ink-muted)]">暂无成员</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{allMembers.map((m, i) => (
|
||||||
|
<span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]">
|
||||||
|
<span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
|
||||||
|
<span className="font-medium text-[var(--ink)]">{m.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
@@ -345,6 +432,12 @@ export default function VersionDetailPage() {
|
|||||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||||
}
|
}
|
||||||
|
// 同步版本状态:计划开始时间<=今天,版本进入对应阶段
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) {
|
||||||
|
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||||||
|
updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onUpdate={(id, data) => {
|
onUpdate={(id, data) => {
|
||||||
updatePlan(id, data);
|
updatePlan(id, data);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useProductStore } from '@/stores/useProductStore';
|
|||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
import { flattenVersions, flattenProjects } from '@/lib/derive';
|
||||||
import type { VersionWithContext } from '@/lib/derive';
|
import type { VersionWithContext } from '@/lib/derive';
|
||||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT } from '@/lib/version-status';
|
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, getVersionDisplayStatus } from '@/lib/version-status';
|
||||||
import { STAGES } from '@/lib/stage';
|
import { STAGES } from '@/lib/stage';
|
||||||
import { ROLE_LABEL } from '@/lib/stage';
|
import { ROLE_LABEL } from '@/lib/stage';
|
||||||
import { calcOverallProgress } from '@/lib/risk';
|
import { calcOverallProgress } from '@/lib/risk';
|
||||||
@@ -76,15 +76,7 @@ function sortVersions(versions: VersionWithContext[]): VersionWithContext[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStageLabel(version: VersionWithContext): string {
|
function getStageLabel(version: VersionWithContext): string {
|
||||||
if (version.status === 'planned') return '规划中';
|
return getVersionDisplayStatus(version.status as VersionStatus, version.currentStage);
|
||||||
if (version.status === 'released') return '已发布';
|
|
||||||
if (version.status === 'paused') return '已暂停';
|
|
||||||
if (version.status === 'closed') return '已关闭';
|
|
||||||
if (version.currentStage) {
|
|
||||||
const stage = STAGES.find((s) => s.key === version.currentStage);
|
|
||||||
return stage?.label ?? '-';
|
|
||||||
}
|
|
||||||
return '-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_ROLE_MAP: Record<string, string[]> = {
|
const STAGE_ROLE_MAP: Record<string, string[]> = {
|
||||||
|
|||||||
328
apps/web/app/workspace/page.tsx
Normal file
328
apps/web/app/workspace/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Search, FileText, Palette, Layout, ClipboardList, Check, ExternalLink, Link2, FileUp } from 'lucide-react';
|
||||||
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { flattenVersions } from '@/lib/derive';
|
||||||
|
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||||
|
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||||
|
|
||||||
|
type TabKey = 'all' | 'research' | 'product' | 'ui';
|
||||||
|
|
||||||
|
const TABS: { key: TabKey; label: string; icon: any }[] = [
|
||||||
|
{ key: 'all', label: '全部待办', icon: ClipboardList },
|
||||||
|
{ key: 'research', label: '调研', icon: Search },
|
||||||
|
{ key: 'product', label: '产品方案', icon: FileText },
|
||||||
|
{ key: 'ui', label: 'UI设计', icon: Palette },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WorkspacePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { overview, fetchOverview } = useProductStore();
|
||||||
|
const { plans, fetchPlans, updatePlan, completePlan } = useVersionPlanStore();
|
||||||
|
const { requirements, fetchRequirements } = useRequirementStore();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||||
|
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
|
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||||
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
|
|
||||||
|
const userName = user?.name ?? '';
|
||||||
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
|
// 我负责的所有未完成计划
|
||||||
|
const myPlans = useMemo(() =>
|
||||||
|
plans.filter((p) => p.owner === userName && p.status !== 'completed'),
|
||||||
|
[plans, userName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
all: myPlans.length,
|
||||||
|
research: myPlans.filter((p) => p.type === 'research').length,
|
||||||
|
product: myPlans.filter((p) => p.type === 'product').length,
|
||||||
|
ui: myPlans.filter((p) => p.type === 'ui').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = activeTab === 'all' ? myPlans : myPlans.filter((p) => p.type === activeTab);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const toggleTask = (plan: VersionPlan, task: PlanTask) => {
|
||||||
|
const next: PlanTask['status'] = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending';
|
||||||
|
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: next } : t);
|
||||||
|
updatePlan(plan.id, { tasks: updatedTasks });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* 左侧:分组 */}
|
||||||
|
<div className="w-60 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
|
||||||
|
<div className="flex h-14 items-center px-5 border-b border-[var(--line)]">
|
||||||
|
<h1 className="text-[15px] font-semibold text-[var(--ink)]">与我相关</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const count = counts[tab.key];
|
||||||
|
const active = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium'
|
||||||
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
<span className="flex-1 text-left">{tab.label}</span>
|
||||||
|
<span className={`text-[11px] tabular-nums px-1.5 py-0.5 rounded ${active ? 'bg-[var(--accent)] text-white' : 'bg-[var(--bg-subtle)] text-[var(--ink-muted)]'}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:待办列表 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<header className="flex h-14 shrink-0 items-center border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||||
|
<h2 className="text-[14px] font-semibold text-[var(--ink)]">
|
||||||
|
{TABS.find((t) => t.key === activeTab)?.label}
|
||||||
|
</h2>
|
||||||
|
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filtered.length} 项</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)] space-y-3">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] p-12 text-center">
|
||||||
|
<p className="text-[13px] text-[var(--ink-muted)]">暂无待办事项</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((plan) => {
|
||||||
|
const version = allVersions.find((v) => v.id === plan.versionId);
|
||||||
|
const isActive = plan.startTime <= today;
|
||||||
|
const typeLabel = plan.type === 'research' ? '调研' : plan.type === 'product' ? '产品方案' : 'UI设计';
|
||||||
|
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
versionName={version?.name}
|
||||||
|
versionInfo={version ? `${version.productName} / ${version.projectName}` : '-'}
|
||||||
|
versionId={version?.id}
|
||||||
|
typeLabel={typeLabel}
|
||||||
|
isActive={isActive}
|
||||||
|
linkedReqs={linkedReqs}
|
||||||
|
onToggleTask={(task) => toggleTask(plan, task)}
|
||||||
|
onToggleReq={(reqId) => {
|
||||||
|
const current = plan.completedRequirementIds || [];
|
||||||
|
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
|
||||||
|
updatePlan(plan.id, { completedRequirementIds: next });
|
||||||
|
}}
|
||||||
|
onAddTask={(title) => {
|
||||||
|
const newTask: PlanTask = { id: `task-${Date.now()}`, title, status: 'pending' };
|
||||||
|
updatePlan(plan.id, { tasks: [...(plan.tasks || []), newTask] });
|
||||||
|
}}
|
||||||
|
onComplete={() => setCompletingPlan(plan)}
|
||||||
|
onJumpVersion={() => version && router.push(`/versions/${version.id}`)}
|
||||||
|
onUpdate={(data) => updatePlan(plan.id, data)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completingPlan && (
|
||||||
|
<CompleteModal
|
||||||
|
onClose={() => setCompletingPlan(null)}
|
||||||
|
onSubmit={(result) => {
|
||||||
|
completePlan(completingPlan.id, result);
|
||||||
|
setCompletingPlan(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan, versionName, versionInfo, versionId, typeLabel, isActive, linkedReqs, onToggleTask, onToggleReq, onAddTask, onComplete, onJumpVersion, onUpdate }: {
|
||||||
|
plan: VersionPlan;
|
||||||
|
versionName?: string;
|
||||||
|
versionInfo: string;
|
||||||
|
versionId?: string;
|
||||||
|
typeLabel: string;
|
||||||
|
isActive: boolean;
|
||||||
|
linkedReqs: { id: string; code: string; title: string }[];
|
||||||
|
onToggleTask: (task: PlanTask) => void;
|
||||||
|
onToggleReq: (reqId: string) => void;
|
||||||
|
onAddTask: (title: string) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onJumpVersion: () => void;
|
||||||
|
onUpdate: (data: Partial<VersionPlan>) => void;
|
||||||
|
}) {
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
|
const isResearch = plan.type === 'research';
|
||||||
|
const progress = isResearch
|
||||||
|
? calcPlanProgress(plan.tasks)
|
||||||
|
: calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
|
||||||
|
const allReqsDone = !isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && progress === 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${isActive ? 'bg-blue-50 text-blue-600' : 'bg-zinc-100 text-zinc-500'}`}>
|
||||||
|
{isActive ? '进行中' : '未开始'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--ink-muted)] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)]">{typeLabel}</span>
|
||||||
|
<span className="text-[14px] font-medium text-[var(--ink)]">{plan.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(allReqsDone || (isResearch && plan.tasks && plan.tasks.length > 0)) && (
|
||||||
|
<button onClick={onComplete} className="h-7 px-3 rounded-md text-[11px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">
|
||||||
|
提交完成
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-[var(--ink-muted)] mb-3">
|
||||||
|
<span>{versionInfo} / </span>
|
||||||
|
<button onClick={onJumpVersion} className="text-[var(--accent)] hover:underline">{versionName || '版本'}</button>
|
||||||
|
<span>{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)}</span>
|
||||||
|
{progress > 0 && <span className="font-medium text-[var(--ink-soft)]">{progress}%</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{((isResearch && plan.tasks && plan.tasks.length > 0) || (!isResearch && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0)) && (
|
||||||
|
<div className="mb-3 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 调研:任务清单 */}
|
||||||
|
{isResearch && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(plan.tasks || []).map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-[var(--bg-subtle)]">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleTask(task)}
|
||||||
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||||
|
task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' :
|
||||||
|
task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' :
|
||||||
|
'border-[var(--line)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||||
|
{task.status === 'in_progress' && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
|
||||||
|
</button>
|
||||||
|
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : task.status === 'in_progress' ? 'text-blue-600' : 'text-[var(--ink-muted)]'}`}>
|
||||||
|
{task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
value={newTaskTitle}
|
||||||
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && newTaskTitle.trim()) { onAddTask(newTaskTitle.trim()); setNewTaskTitle(''); } }}
|
||||||
|
placeholder="添加任务,回车确认"
|
||||||
|
className="flex-1 h-7 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 产品方案/UI:关联需求清单 */}
|
||||||
|
{!isResearch && linkedReqs.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{linkedReqs.map((req) => {
|
||||||
|
const isDone = (plan.completedRequirementIds || []).includes(req.id);
|
||||||
|
return (
|
||||||
|
<div key={req.id} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-[var(--bg-subtle)]">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleReq(req.id)}
|
||||||
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||||
|
>
|
||||||
|
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
|
||||||
|
<span className={`flex-1 text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allReqsDone && (
|
||||||
|
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 text-[12px] text-green-700">
|
||||||
|
所有需求已完成,请提交原型成果
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isResearch && linkedReqs.length === 0 && (
|
||||||
|
<div className="text-[12px] text-[var(--ink-muted)] py-2">
|
||||||
|
未关联需求,<button onClick={onJumpVersion} className="text-[var(--accent)] hover:underline">前往版本详情</button>关联
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompleteModal({ onClose, onSubmit }: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (result: { resultType: 'link' | 'file'; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
|
||||||
|
}) {
|
||||||
|
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [fileName, setFileName] = useState('');
|
||||||
|
const [fileData, setFileData] = useState('');
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setFileName(file.name);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setFileData(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = resultType === 'link' ? url.trim().length > 0 : fileData.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="text-[13px] font-semibold text-[var(--ink)] mb-4">提交成果</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" />链接</button>
|
||||||
|
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" />文件</button>
|
||||||
|
</div>
|
||||||
|
{resultType === 'link' ? (
|
||||||
|
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
|
||||||
|
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">已选:{fileName}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||||
|
<button onClick={() => onSubmit({ resultType, resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
|
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
|
||||||
import type { RoleProgress } from '@/lib/derive';
|
import type { RoleProgress } from '@/lib/derive';
|
||||||
|
|
||||||
export function CapsuleStages({ currentStage, progress }: {
|
export interface StageProgressItem {
|
||||||
|
percent: number;
|
||||||
|
daysSpent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CapsuleStages({ currentStage, progress, stageProgress }: {
|
||||||
currentStage?: Stage;
|
currentStage?: Stage;
|
||||||
progress?: RoleProgress[];
|
progress?: RoleProgress[];
|
||||||
|
stageProgress?: Partial<Record<Stage, StageProgressItem>>;
|
||||||
}) {
|
}) {
|
||||||
const currentIdx = currentStage !== undefined
|
const currentIdx = currentStage !== undefined
|
||||||
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
|
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
|
||||||
@@ -24,6 +30,11 @@ export function CapsuleStages({ currentStage, progress }: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getStageInfo(stage: Stage) {
|
function getStageInfo(stage: Stage) {
|
||||||
|
// 优先使用 stageProgress
|
||||||
|
if (stageProgress && stageProgress[stage]) {
|
||||||
|
const sp = stageProgress[stage]!;
|
||||||
|
return { percent: sp.percent, days: sp.daysSpent, hasData: true };
|
||||||
|
}
|
||||||
const roles = stageRoleMap[stage];
|
const roles = stageRoleMap[stage];
|
||||||
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
|
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
|
||||||
const items = roles.map((r) => progressMap[r]).filter(Boolean);
|
const items = roles.map((r) => progressMap[r]).filter(Boolean);
|
||||||
@@ -50,7 +61,7 @@ export function CapsuleStages({ currentStage, progress }: {
|
|||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
|
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
|
||||||
{isCompleted ? (info.hasData ? `${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
|
{isCompleted ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[3px] w-full bg-zinc-50">
|
<div className="h-[3px] w-full bg-zinc-50">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
|
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
|
||||||
import type { VersionPlan } from '@/lib/version-plan';
|
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||||
import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan';
|
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plans: VersionPlan[];
|
plans: VersionPlan[];
|
||||||
@@ -39,7 +39,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
|||||||
<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>
|
||||||
<span className="text-[12px] text-[var(--ink-soft)]">总耗时:<span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
|
<span className="text-[12px] text-[var(--ink-soft)]">已耗时:<span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
|
||||||
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]">版本截止:<span className="font-medium text-red-500">{versionDeadline}</span></span>}
|
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]">版本截止:<span className="font-medium text-red-500">{versionDeadline}</span></span>}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||||
@@ -55,41 +55,35 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{typePlans.map((plan) => {
|
{typePlans.map((plan) => {
|
||||||
const dur = plan.completedAt
|
// 已耗时:已完成用实际完成时间,进行中用当前时间,未开始为0
|
||||||
|
const now = new Date().toISOString().slice(0, 10);
|
||||||
|
const effectiveStatus = plan.status === 'pending' && plan.startTime <= now ? 'in_progress' : plan.status;
|
||||||
|
const dur = effectiveStatus === 'completed' && plan.completedAt
|
||||||
? calcPlanDuration(plan.startTime, plan.completedAt)
|
? calcPlanDuration(plan.startTime, plan.completedAt)
|
||||||
: calcPlanDuration(plan.startTime, plan.endTime);
|
: effectiveStatus === 'in_progress'
|
||||||
const durText = formatDuration(dur.days, dur.hours);
|
? calcPlanDuration(plan.startTime, now)
|
||||||
|
: { days: 0, hours: 0 };
|
||||||
|
const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
|
||||||
return (
|
return (
|
||||||
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
|
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
|
||||||
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[plan.status]}`}>
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
|
||||||
{STATUS_LABEL[plan.status]}
|
{STATUS_LABEL[effectiveStatus]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
|
||||||
<span>负责人:{plan.owner}</span>
|
|
||||||
<span>计划:{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)}</span>
|
<span>计划:{plan.startTime.slice(0, 10)} → {plan.endTime.slice(0, 10)}</span>
|
||||||
{plan.completedAt && <span className="text-green-600">完成:{plan.completedAt.slice(0, 10)}</span>}
|
{plan.completedAt && <span className="text-green-600">完成:{plan.completedAt.slice(0, 10)}</span>}
|
||||||
<span className="font-medium text-[var(--ink)]">耗时 {durText}</span>
|
<span className="font-medium text-[var(--ink)]">已耗时 {durText}</span>
|
||||||
</div>
|
</div>
|
||||||
{plan.overdueReason && (
|
{plan.overdueReason && (
|
||||||
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">超期原因:{plan.overdueReason}</div>
|
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">超期原因:{plan.overdueReason}</div>
|
||||||
)}
|
)}
|
||||||
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
|
{(plan.type === 'product' || plan.type === 'ui') && (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="mt-1.5 text-[12px] text-[var(--ink-soft)]">负责人:<span className="font-medium text-[var(--ink)]">{plan.owner}</span></div>
|
||||||
{plan.linkedRequirementIds.map((rid) => {
|
|
||||||
const req = linkedRequirements.find((r) => r.id === rid);
|
|
||||||
return req ? (
|
|
||||||
<span key={rid} className="inline-flex items-center gap-1 rounded bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] text-[var(--ink-soft)]">
|
|
||||||
{req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''}
|
|
||||||
{req.productOwner && <span className="text-[var(--ink-muted)]">({req.productOwner})</span>}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{plan.status === 'completed' && plan.resultUrl && (
|
{plan.status === 'completed' && plan.resultUrl && (
|
||||||
<div className="flex items-center gap-1.5 mt-2">
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
@@ -99,9 +93,80 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 调研:任务进度 */}
|
||||||
|
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcPlanProgress(plan.tasks)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcPlanProgress(plan.tasks)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{plan.tasks.map((task) => (
|
||||||
|
<div key={task.id} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const nextStatus = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending';
|
||||||
|
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
||||||
|
onUpdate(plan.id, { tasks: updatedTasks });
|
||||||
|
}}
|
||||||
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' : 'border-[var(--line)]'}`}
|
||||||
|
>
|
||||||
|
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||||
|
{task.status === 'in_progress' && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
|
||||||
|
</button>
|
||||||
|
<span className={`text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
|
||||||
|
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : task.status === 'in_progress' ? 'text-blue-600' : 'text-[var(--ink-muted)]'}`}>
|
||||||
|
{task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 产品方案/UI:关联需求进度 */}
|
||||||
|
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{plan.linkedRequirementIds.map((rid) => {
|
||||||
|
const req = linkedRequirements.find((r) => r.id === rid);
|
||||||
|
const isDone = (plan.completedRequirementIds || []).includes(rid);
|
||||||
|
return req ? (
|
||||||
|
<div key={rid} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const current = plan.completedRequirementIds || [];
|
||||||
|
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
|
||||||
|
onUpdate(plan.id, { completedRequirementIds: next });
|
||||||
|
}}
|
||||||
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||||
|
>
|
||||||
|
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
<span className={`text-[11px] font-mono text-[var(--ink-muted)]`}>{req.code}</span>
|
||||||
|
<span className={`text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && effectiveStatus !== 'completed' && (
|
||||||
|
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-green-700">所有需求已完成,请提交原型成果</span>
|
||||||
|
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 ml-3">
|
<div className="flex items-center gap-1 ml-3">
|
||||||
{plan.status !== 'completed' && (
|
{effectiveStatus !== 'completed' && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
|
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -166,6 +231,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
|
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
|
||||||
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
|
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
|
||||||
const [remark, setRemark] = useState(initial?.remark ?? '');
|
const [remark, setRemark] = useState(initial?.remark ?? '');
|
||||||
|
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
|
||||||
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||||||
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
||||||
const showReqSelect = planType === 'product' || planType === 'ui';
|
const showReqSelect = planType === 'product' || planType === 'ui';
|
||||||
@@ -184,6 +251,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
endTime,
|
endTime,
|
||||||
status: initial?.status ?? 'pending',
|
status: initial?.status ?? 'pending',
|
||||||
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
|
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
|
||||||
|
tasks: tasks.length > 0 ? tasks : undefined,
|
||||||
remark: remark.trim() || undefined,
|
remark: remark.trim() || undefined,
|
||||||
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
overdueReason: isOverdue ? overdueReason.trim() : undefined,
|
||||||
addedBy: currentUserName,
|
addedBy: currentUserName,
|
||||||
@@ -246,6 +314,29 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{planType === 'research' && (
|
||||||
|
<div>
|
||||||
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">任务清单</label>
|
||||||
|
<div className="space-y-1.5 mb-2">
|
||||||
|
{tasks.map((task, i) => (
|
||||||
|
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
|
||||||
|
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
|
||||||
|
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newTaskTitle}
|
||||||
|
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
|
||||||
|
placeholder="输入任务名称,回车添加"
|
||||||
|
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">备注</label>
|
||||||
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
|
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
export interface PlanTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: PlanTaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VersionPlan {
|
export interface VersionPlan {
|
||||||
id: string;
|
id: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
@@ -7,6 +15,8 @@ export interface VersionPlan {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
status: 'pending' | 'in_progress' | 'completed';
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
tasks?: PlanTask[];
|
||||||
|
completedRequirementIds?: string[];
|
||||||
linkedRequirementIds?: string[];
|
linkedRequirementIds?: string[];
|
||||||
resultType?: 'link' | 'file';
|
resultType?: 'link' | 'file';
|
||||||
resultUrl?: string;
|
resultUrl?: string;
|
||||||
@@ -21,11 +31,24 @@ export interface VersionPlan {
|
|||||||
|
|
||||||
export type PlanType = VersionPlan['type'];
|
export type PlanType = VersionPlan['type'];
|
||||||
|
|
||||||
|
export function calcPlanProgress(tasks?: PlanTask[]): number {
|
||||||
|
if (!tasks || tasks.length === 0) return 0;
|
||||||
|
const completed = tasks.filter((t) => t.status === 'completed').length;
|
||||||
|
return Math.round((completed / tasks.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcLinkedReqProgress(linkedIds?: string[], completedIds?: string[]): number {
|
||||||
|
if (!linkedIds || linkedIds.length === 0) return 0;
|
||||||
|
const done = (completedIds || []).filter((id) => linkedIds.includes(id)).length;
|
||||||
|
return Math.round((done / linkedIds.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
export function calcPlanDuration(start: string, end: string): { days: number; hours: number } {
|
export function calcPlanDuration(start: string, end: string): { days: number; hours: number } {
|
||||||
const s = new Date(start).getTime();
|
const s = new Date(start).getTime();
|
||||||
const e = new Date(end).getTime();
|
const e = new Date(end).getTime();
|
||||||
if (isNaN(s) || isNaN(e) || e <= s) return { days: 0, hours: 0 };
|
if (isNaN(s) || isNaN(e) || e < s) return { days: 0, hours: 0 };
|
||||||
const totalDays = Math.ceil((e - s) / (1000 * 60 * 60 * 24));
|
const diff = Math.ceil((e - s) / (1000 * 60 * 60 * 24));
|
||||||
|
const totalDays = Math.max(1, diff); // 当天开始当天结束至少1天
|
||||||
return { days: totalDays, hours: totalDays * 8 };
|
return { days: totalDays, hours: totalDays * 8 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,13 +58,19 @@ export function formatDuration(days: number, _hours: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calcTotalDuration(plans: VersionPlan[]): string {
|
export function calcTotalDuration(plans: VersionPlan[]): string {
|
||||||
const completedOrActive = plans.filter((p) => p.status !== 'pending');
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
if (completedOrActive.length === 0) return '0天';
|
const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today);
|
||||||
|
if (activePlans.length === 0) return '0天';
|
||||||
|
|
||||||
const intervals = completedOrActive.map((p) => ({
|
const intervals = activePlans.map((p) => {
|
||||||
start: new Date(p.startTime).getTime(),
|
const start = new Date(p.startTime).getTime();
|
||||||
end: p.completedAt ? new Date(p.completedAt).getTime() : new Date(p.endTime).getTime(),
|
const end = p.completedAt
|
||||||
})).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
|
? new Date(p.completedAt).getTime()
|
||||||
|
: p.startTime <= today
|
||||||
|
? new Date(today).getTime()
|
||||||
|
: new Date(p.startTime).getTime();
|
||||||
|
return { start, end };
|
||||||
|
}).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
if (intervals.length === 0) return '0天';
|
if (intervals.length === 0) return '0天';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
|
export type VersionStatus = 'developing' | 'planned' | 'released' | 'paused' | 'closed';
|
||||||
|
|
||||||
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
||||||
developing: '开发中',
|
developing: '进行中',
|
||||||
planned: '规划中',
|
planned: '规划中',
|
||||||
released: '已发布',
|
released: '已发布',
|
||||||
paused: '已暂停',
|
paused: '已暂停',
|
||||||
@@ -23,3 +23,20 @@ export const VERSION_STATUS_BG: Record<VersionStatus, string> = {
|
|||||||
paused: 'bg-purple-100 text-purple-600',
|
paused: 'bg-purple-100 text-purple-600',
|
||||||
closed: 'bg-zinc-100 text-zinc-500',
|
closed: 'bg-zinc-100 text-zinc-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STAGE_LABEL: Record<string, string> = {
|
||||||
|
requirement: '调研中',
|
||||||
|
product_design: '产品设计中',
|
||||||
|
ui_design: 'UI设计中',
|
||||||
|
dev: '开发中',
|
||||||
|
integration: '联调中',
|
||||||
|
testing: '测试中',
|
||||||
|
released: '已发布',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getVersionDisplayStatus(status: VersionStatus, currentStage?: string): string {
|
||||||
|
if (status === 'developing' && currentStage && STAGE_LABEL[currentStage]) {
|
||||||
|
return STAGE_LABEL[currentStage];
|
||||||
|
}
|
||||||
|
return VERSION_STATUS_LABEL[status];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user