feat: 与我相关增加产品/项目/版本树筛选 + 待办红色徽标
布局改为三栏: 1. 左一:产品/项目/版本树(只显示跟自己相关的) - 树状可展开/折叠 - 版本节点上有待办数红色徽标 - 点击版本筛选任务列表 - 点击"全部"取消筛选 2. 左二:环节分类tab - 只显示待办数(红色圆形徽标),无待办不显示数字 - 去掉之前的总数显示 3. 右侧:任务列表(不变) - header 显示当前选中的版本名标签 - 待办/已完成计数受版本筛选影响 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2 } from 'lucide-react';
|
import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
@@ -47,6 +47,7 @@ export default function WorkspacePage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||||
const [showCompleted, setShowCompleted] = useState(true);
|
const [showCompleted, setShowCompleted] = useState(true);
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||||
@@ -75,70 +76,121 @@ export default function WorkspacePage() {
|
|||||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
// 构建树:只显示跟自己有关的产品/项目/版本
|
||||||
let items = activeTab === 'all' ? workItems : workItems.filter((i) => i.type === activeTab);
|
const tree = useMemo(() => {
|
||||||
if (!showCompleted) items = items.filter((i) => !i.completed);
|
const myVersionIds = new Set(workItems.map((i) => i.versionId).filter(Boolean));
|
||||||
return items.sort((a, b) => {
|
const productMap = new Map<string, { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> }>();
|
||||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
|
||||||
return 0;
|
allVersions.forEach((v) => {
|
||||||
|
if (!myVersionIds.has(v.id)) return;
|
||||||
|
if (!productMap.has(v.productName)) productMap.set(v.productName, { name: v.productName, projects: new Map() });
|
||||||
|
const prod = productMap.get(v.productName)!;
|
||||||
|
if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] });
|
||||||
|
const proj = prod.projects.get(v.projectName)!;
|
||||||
|
const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length;
|
||||||
|
if (!proj.versions.find((ver) => ver.id === v.id)) {
|
||||||
|
proj.versions.push({ id: v.id, name: v.name, pendingCount: pending });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [workItems, activeTab, showCompleted]);
|
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
return productMap;
|
||||||
all: workItems.length,
|
}, [allVersions, workItems]);
|
||||||
plan_research: workItems.filter((i) => i.type === 'plan_research').length,
|
|
||||||
plan_product: workItems.filter((i) => i.type === 'plan_product').length,
|
|
||||||
plan_ui: workItems.filter((i) => i.type === 'plan_ui').length,
|
|
||||||
devTask: workItems.filter((i) => i.type === 'devTask').length,
|
|
||||||
testCase: workItems.filter((i) => i.type === 'testCase').length,
|
|
||||||
bug: workItems.filter((i) => i.type === 'bug').length,
|
|
||||||
}), [workItems]);
|
|
||||||
|
|
||||||
const pendingCount = workItems.filter((i) => !i.completed).length;
|
const filteredItems = useMemo(() => {
|
||||||
const completedCount = workItems.filter((i) => i.completed).length;
|
let items = workItems;
|
||||||
|
if (selectedVersionId) items = items.filter((i) => i.versionId === selectedVersionId);
|
||||||
|
if (activeTab !== 'all') items = items.filter((i) => i.type === activeTab);
|
||||||
|
if (!showCompleted) items = items.filter((i) => !i.completed);
|
||||||
|
return items.sort((a, b) => (a.completed !== b.completed ? (a.completed ? 1 : -1) : 0));
|
||||||
|
}, [workItems, activeTab, showCompleted, selectedVersionId]);
|
||||||
|
|
||||||
|
// 待办数量按 tab 分(受 version filter 影响)
|
||||||
|
const pendingByTab = useMemo(() => {
|
||||||
|
const base = selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems;
|
||||||
|
return {
|
||||||
|
all: base.filter((i) => !i.completed).length,
|
||||||
|
plan_research: base.filter((i) => i.type === 'plan_research' && !i.completed).length,
|
||||||
|
plan_product: base.filter((i) => i.type === 'plan_product' && !i.completed).length,
|
||||||
|
plan_ui: base.filter((i) => i.type === 'plan_ui' && !i.completed).length,
|
||||||
|
devTask: base.filter((i) => i.type === 'devTask' && !i.completed).length,
|
||||||
|
testCase: base.filter((i) => i.type === 'testCase' && !i.completed).length,
|
||||||
|
bug: base.filter((i) => i.type === 'bug' && !i.completed).length,
|
||||||
|
};
|
||||||
|
}, [workItems, selectedVersionId]);
|
||||||
|
|
||||||
|
const pendingCount = pendingByTab.all;
|
||||||
|
const completedCount = (selectedVersionId ? workItems.filter((i) => i.versionId === selectedVersionId) : workItems).filter((i) => i.completed).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<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)]">
|
<div className="w-52 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
|
||||||
<h1 className="text-[15px] font-semibold text-[var(--ink)]">与我相关</h1>
|
<div className="flex h-14 items-center px-4 border-b border-[var(--line)]">
|
||||||
|
<h1 className="text-[14px] font-semibold text-[var(--ink)]">与我相关</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 border-b border-[var(--line)]">
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVersionId(null)}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-[12px] mb-1 transition-colors ${!selectedVersionId ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||||
|
>
|
||||||
|
<Layers className="h-3 w-3" />
|
||||||
|
<span className="flex-1 text-left">全部</span>
|
||||||
|
{pendingByTab.all > 0 && !selectedVersionId && (
|
||||||
|
<span className="text-[10px] bg-red-500 text-white rounded-full px-1.5 min-w-[18px] text-center">{pendingByTab.all}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{Array.from(tree.entries()).map(([prodName, prod]) => (
|
||||||
|
<ProductNode key={prodName} name={prodName} prod={prod} selectedVersionId={selectedVersionId} onSelect={setSelectedVersionId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 左侧第二列:环节分类 */}
|
||||||
|
<div className="w-48 shrink-0 border-r border-[var(--line)] bg-[var(--bg-card)] flex flex-col">
|
||||||
|
<div className="flex h-14 items-center px-4 border-b border-[var(--line)]">
|
||||||
<div className="flex items-center gap-3 text-[11px]">
|
<div className="flex items-center gap-3 text-[11px]">
|
||||||
<span className="text-[var(--ink-muted)]">待办 <span className="font-medium text-[var(--ink)]">{pendingCount}</span></span>
|
<span className="text-[var(--ink-muted)]">待办 <span className="font-medium text-[var(--ink)]">{pendingCount}</span></span>
|
||||||
<span className="text-[var(--ink-muted)]">已完成 <span className="font-medium text-emerald-600">{completedCount}</span></span>
|
<span className="text-[var(--ink-muted)]">完成 <span className="font-medium text-emerald-600">{completedCount}</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-3 space-y-1">
|
<nav className="flex-1 p-2 space-y-0.5">
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const count = counts[tab.key];
|
const pending = pendingByTab[tab.key];
|
||||||
const active = activeTab === tab.key;
|
const active = activeTab === tab.key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(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)]'}`}
|
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-[12px] 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" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1 text-left">{tab.label}</span>
|
<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>
|
{pending > 0 && (
|
||||||
|
<span className="text-[10px] bg-red-500 text-white rounded-full px-1.5 min-w-[18px] text-center">{pending}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-3 border-t border-[var(--line)]">
|
<div className="p-3 border-t border-[var(--line)]">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-[var(--ink-soft)] cursor-pointer">
|
<label className="flex items-center gap-2 text-[11px] text-[var(--ink-soft)] cursor-pointer">
|
||||||
<input type="checkbox" checked={showCompleted} onChange={(e) => setShowCompleted(e.target.checked)} className="h-3.5 w-3.5 rounded" />
|
<input type="checkbox" checked={showCompleted} onChange={(e) => setShowCompleted(e.target.checked)} className="h-3.5 w-3.5 rounded" />
|
||||||
显示已完成
|
显示已完成
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:任务列表 */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<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">
|
<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>
|
<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)]">{filteredItems.length} 项</span>
|
<span className="ml-2 text-[12px] text-[var(--ink-muted)]">{filteredItems.length} 项</span>
|
||||||
|
{selectedVersionId && (
|
||||||
|
<span className="ml-3 text-[11px] text-[var(--accent)] bg-[var(--accent-soft)] px-2 py-0.5 rounded-full">
|
||||||
|
{versionMap.get(selectedVersionId)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
|
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
|
||||||
@@ -159,49 +211,82 @@ export default function WorkspacePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProductNode({ name, prod, selectedVersionId, onSelect }: {
|
||||||
|
name: string;
|
||||||
|
prod: { name: string; projects: Map<string, { name: string; versions: { id: string; name: string; pendingCount: number }[] }> };
|
||||||
|
selectedVersionId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="mb-0.5">
|
||||||
|
<button onClick={() => setExpanded(!expanded)} className="w-full flex items-center gap-1.5 px-2 py-1 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||||
|
{expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && Array.from(prod.projects.entries()).map(([projName, proj]) => (
|
||||||
|
<ProjectNode key={projName} name={projName} versions={proj.versions} selectedVersionId={selectedVersionId} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
|
||||||
|
name: string;
|
||||||
|
versions: { id: string; name: string; pendingCount: number }[];
|
||||||
|
selectedVersionId: string | null;
|
||||||
|
onSelect: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="ml-3">
|
||||||
|
<button onClick={() => setExpanded(!expanded)} className="w-full flex items-center gap-1.5 px-2 py-1 rounded text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]">
|
||||||
|
{expanded ? <ChevronDown className="h-2.5 w-2.5 shrink-0" /> : <ChevronRight className="h-2.5 w-2.5 shrink-0" />}
|
||||||
|
<FolderOpen className="h-3 w-3 shrink-0 text-[var(--ink-muted)]" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && versions.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => onSelect(selectedVersionId === v.id ? null : v.id)}
|
||||||
|
className={`w-full flex items-center gap-1.5 ml-5 px-2 py-1 rounded text-[11px] transition-colors ${selectedVersionId === v.id ? 'bg-[var(--accent-soft)] text-[var(--accent)] font-medium' : 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-left truncate">{v.name}</span>
|
||||||
|
{v.pendingCount > 0 && (
|
||||||
|
<span className="text-[9px] bg-red-500 text-white rounded-full px-1.5 min-w-[16px] text-center">{v.pendingCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WorkItemCard({ item, onNavigate }: { item: WorkItem; onNavigate: () => void }) {
|
function WorkItemCard({ item, onNavigate }: { item: WorkItem; onNavigate: () => void }) {
|
||||||
const statusBadge = getStatusBadge(item);
|
const statusBadge = getStatusBadge(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] ${item.completed ? 'opacity-60' : ''}`}>
|
<div className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] ${item.completed ? 'opacity-60' : ''}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* 完成标记 */}
|
|
||||||
{item.completed && <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />}
|
{item.completed && <CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0" />}
|
||||||
|
|
||||||
{/* 类型标签 */}
|
|
||||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)] shrink-0">
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)] shrink-0">
|
||||||
{WORK_ITEM_TYPE_LABEL[item.type]}
|
{WORK_ITEM_TYPE_LABEL[item.type]}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 状态 */}
|
|
||||||
{statusBadge}
|
{statusBadge}
|
||||||
|
|
||||||
{/* 编号 */}
|
|
||||||
{item.extra?.taskNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.taskNo}</span>}
|
{item.extra?.taskNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.taskNo}</span>}
|
||||||
{item.extra?.caseNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.caseNo}</span>}
|
{item.extra?.caseNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.caseNo}</span>}
|
||||||
{item.extra?.bugNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.bugNo}</span>}
|
{item.extra?.bugNo && <span className="text-[11px] font-mono text-[var(--ink-muted)] shrink-0">{item.extra.bugNo}</span>}
|
||||||
|
|
||||||
{/* 标题 */}
|
|
||||||
<span className="text-[13px] font-medium text-[var(--ink)] flex-1 truncate">{item.title}</span>
|
<span className="text-[13px] font-medium text-[var(--ink)] flex-1 truncate">{item.title}</span>
|
||||||
|
|
||||||
{/* 优先级 */}
|
|
||||||
{item.priority && <span className="text-[10px] text-[var(--ink-muted)] shrink-0">{item.priority}</span>}
|
{item.priority && <span className="text-[10px] text-[var(--ink-muted)] shrink-0">{item.priority}</span>}
|
||||||
|
|
||||||
{/* Bug 严重程度 */}
|
|
||||||
{item.extra?.severity && (
|
{item.extra?.severity && (
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${BUG_SEVERITY_COLOR[item.extra.severity as keyof typeof BUG_SEVERITY_COLOR] || ''}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${BUG_SEVERITY_COLOR[item.extra.severity as keyof typeof BUG_SEVERITY_COLOR] || ''}`}>
|
||||||
{BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''}
|
{BUG_SEVERITY_LABEL[item.extra.severity as keyof typeof BUG_SEVERITY_LABEL] || ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 跳转 */}
|
|
||||||
<button onClick={onNavigate} className="h-6 w-6 flex items-center justify-center rounded text-[var(--ink-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-subtle)] shrink-0" title="跳转到版本详情">
|
<button onClick={onNavigate} className="h-6 w-6 flex items-center justify-center rounded text-[var(--ink-muted)] hover:text-[var(--accent)] hover:bg-[var(--bg-subtle)] shrink-0" title="跳转到版本详情">
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 text-[11px] text-[var(--ink-muted)]">
|
||||||
{/* 上下文:产品 / 项目 / 版本 */}
|
|
||||||
<div className="flex items-center gap-2 mt-1.5 ml-0 text-[11px] text-[var(--ink-muted)]">
|
|
||||||
<span>{item.productName}</span>
|
<span>{item.productName}</span>
|
||||||
<span className="text-[var(--line)]">/</span>
|
<span className="text-[var(--line)]">/</span>
|
||||||
<span>{item.projectName}</span>
|
<span>{item.projectName}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user