feat(版本): 完善计划覆盖与工作台待办

关键改动:

- 增加计划日志汇总、需求覆盖草稿校验与对应测试

- 抽取工作台工作项 Hook,补充待办计数能力

- 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 13:43:18 +08:00
parent a6a4d7d3d9
commit 52a88626d4
23 changed files with 1082 additions and 281 deletions

View File

@@ -1,11 +1,13 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react';
import { useHasPermission } from '@/components/auth/Guard';
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { getWorkspacePendingCount } from '@/lib/workspace-engine';
import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view';
type NavItemConfig = {
@@ -13,7 +15,7 @@ type NavItemConfig = {
path: string;
icon: any;
permission: string | null;
badge?: 'xiaobao-risk';
badge?: 'xiaobao-risk' | 'workspace-pending';
};
const NAV_GROUPS = [
@@ -21,7 +23,7 @@ const NAV_GROUPS = [
label: '工作区',
items: [
{ label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const },
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const },
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
{ label: '版本', path: '/versions', icon: Tag, permission: 'version:view' },
@@ -42,36 +44,40 @@ const NAV_GROUPS = [
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { workItems } = useWorkspaceWorkItems();
const workspacePendingCount = getWorkspacePendingCount(workItems);
const isActive = (path: string) =>
pathname === path || (path !== '/' && pathname.startsWith(path));
return (
<aside className="flex h-screen w-60 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="flex h-14 items-center gap-2 px-4">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--accent)]">
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
</div>
<span className="text-[13px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
</div>
<div className="px-3 pb-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input
placeholder="搜索"
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
/>
<aside className="flex h-screen w-64 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
<div className="border-b border-[var(--line)] px-4 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-zinc-950 shadow-sm">
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
</div>
<div className="min-w-0">
<p className="truncate text-[14px] font-semibold text-[var(--ink)]">FTB</p>
<p className="truncate text-[11px] text-[var(--ink-muted)]"></p>
</div>
</div>
</div>
<nav className="flex-1 px-2 pt-2">
<nav className="flex-1 overflow-y-auto px-3 py-3">
{NAV_GROUPS.map((group) => (
<NavGroup key={group.label} label={group.label} items={group.items} isActive={isActive} onNavigate={(p) => router.push(p)} />
<NavGroup
key={group.label}
label={group.label}
items={group.items}
isActive={isActive}
onNavigate={(p) => router.push(p)}
workspacePendingCount={workspacePendingCount}
/>
))}
</nav>
<div className="border-t border-[var(--line)] p-2">
<div className="border-t border-[var(--line)] p-3">
<UserBlock />
</div>
</aside>
@@ -85,16 +91,16 @@ function UserBlock() {
if (!user) {
return (
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--bg-subtle)] text-[11px] font-semibold text-[var(--ink-muted)]">?</div>
<div className="flex w-full items-center gap-2.5 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--bg-card)] text-[11px] font-semibold text-[var(--ink-muted)]">?</div>
<p className="truncate text-[13px] text-[var(--ink-muted)]"></p>
</div>
);
}
return (
<div className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
<div className="flex w-full items-center gap-2.5 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-2.5 py-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
{user.name?.[0] ?? '?'}
</div>
<div className="min-w-0 flex-1">
@@ -103,7 +109,7 @@ function UserBlock() {
</div>
<button
onClick={() => router.push('/profile')}
className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)] hover:text-[var(--accent)]"
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
title="个人中心"
>
<Settings className="h-4 w-4" strokeWidth={1.75} />
@@ -112,28 +118,38 @@ function UserBlock() {
);
}
function NavGroup({ label, items, isActive, onNavigate }: {
function NavGroup({ label, items, isActive, onNavigate, workspacePendingCount }: {
label: string;
items: NavItemConfig[];
isActive: (p: string) => boolean;
onNavigate: (p: string) => void;
workspacePendingCount: number;
}) {
const visibleItems = items.filter((item) => item.permission === null || /* eslint-disable-next-line react-hooks/rules-of-hooks */ true);
if (visibleItems.length === 0) return null;
return (
<div className="mb-4">
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">{label}</p>
{items.map((item) => (
<NavItem key={item.path} item={item} active={isActive(item.path)} onNavigate={onNavigate} />
))}
<div className="mb-5">
<p className="mb-2 px-2 text-[11px] font-semibold text-[var(--ink-muted)]">{label}</p>
<div className="space-y-1">
{items.map((item) => (
<NavItem
key={item.path}
item={item}
active={isActive(item.path)}
onNavigate={onNavigate}
workspacePendingCount={workspacePendingCount}
/>
))}
</div>
</div>
);
}
function NavItem({ item, active, onNavigate }: {
function NavItem({ item, active, onNavigate, workspacePendingCount }: {
item: NavItemConfig;
active: boolean;
onNavigate: (p: string) => void;
workspacePendingCount: number;
}) {
const hasPerm = useHasPermission(item.permission ?? '');
if (item.permission && !hasPerm) return null;
@@ -141,15 +157,23 @@ function NavItem({ item, active, onNavigate }: {
return (
<button
onClick={() => onNavigate(item.path)}
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors ${
className={`group relative flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-left text-[13px] transition-colors ${
active
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent-hover)]'
? 'bg-[var(--bg)] font-medium text-[var(--ink)] shadow-sm ring-1 ring-[var(--line)]'
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
}`}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
{active && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-[var(--accent)]" />}
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md ${
active
? 'bg-[var(--accent)] text-white'
: 'text-[var(--ink-muted)] group-hover:bg-[var(--bg-card)] group-hover:text-[var(--ink)]'
}`}>
<Icon className="h-3.5 w-3.5" strokeWidth={1.9} />
</span>
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.badge === 'xiaobao-risk' && <XiaobaoRiskNavBadge />}
{item.badge === 'workspace-pending' && <NavCountBadge count={workspacePendingCount} />}
</button>
);
}
@@ -159,6 +183,12 @@ function XiaobaoRiskNavBadge() {
const count = getXiaobaoWarningRiskCount(risks);
if (count <= 0) return null;
return <NavCountBadge count={count} />;
}
function NavCountBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span className="ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-red-600 px-1.5 text-[10px] font-semibold leading-none text-white">
{count > 99 ? '99+' : count}