feat: 计划任务清单+进度联动+与我相关工作台
- 计划新增任务清单:调研支持任务CRUD,进度=完成数/总数 - 产品方案/UI:通过关联需求勾选进度,全部完成提示提交 - 版本胶囊条动态进度:调研/产品方案/UI 阶段独立计算 - 版本状态显示具体阶段名(调研中/产品设计中/UI设计中) - 计划开始日期≤今天自动进入"进行中",版本状态联动 - 与我相关重写为左右布局:左侧分组导航,右侧任务/需求勾选 - 计划支持超期原因校验(结束日期超版本截止) - 修复列表overflow裁剪问题、计划耗时当天至少1天 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user