关键改动: - 更新 README 项目说明和小宝相关入口命名 - 优化问翻小宝页面与侧边栏测试 - 小宝预警已读状态抑制同签名重复 AI 请求 Co-Authored-By: Codex GPT-5 <codex@openai.com>
221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark } 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 { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
|
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
|
|
import { getWorkspacePendingCount } from '@/lib/workspace-engine';
|
|
import { buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
|
|
import {
|
|
filterXiaobaoRiskWarnings,
|
|
getXiaobaoWarningRiskCount,
|
|
getXiaobaoWarningUnreadUpdateCount,
|
|
} from '@/lib/xiaobao-warning-view';
|
|
|
|
type NavItemConfig = {
|
|
label: string;
|
|
path: string;
|
|
icon: any;
|
|
permission: string | null;
|
|
badge?: 'xiaobao-risk' | 'workspace-pending';
|
|
};
|
|
|
|
const NAV_GROUPS = [
|
|
{
|
|
label: '工作区',
|
|
items: [
|
|
{ label: 'AI 助手', path: '/wenfan-xiaobao', icon: MessageCircleQuestionMark, permission: null as string | null },
|
|
{ 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, 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' },
|
|
{ label: '需求池', path: '/requirements', icon: Lightbulb, permission: 'requirement:view' },
|
|
{ label: '加班记录', path: '/overtime', icon: Clock, permission: 'overtime:view' },
|
|
],
|
|
},
|
|
{
|
|
label: '管理',
|
|
items: [
|
|
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
|
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
|
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
|
],
|
|
},
|
|
];
|
|
|
|
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-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 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)}
|
|
workspacePendingCount={workspacePendingCount}
|
|
/>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-[var(--line)] p-3">
|
|
<UserBlock />
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function UserBlock() {
|
|
const router = useRouter();
|
|
const user = useAuthStore((s) => s.user);
|
|
const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
|
|
|
|
if (!user) {
|
|
return (
|
|
<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 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">
|
|
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
|
|
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => router.push('/profile')}
|
|
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} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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-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, workspacePendingCount }: {
|
|
item: NavItemConfig;
|
|
active: boolean;
|
|
onNavigate: (p: string) => void;
|
|
workspacePendingCount: number;
|
|
}) {
|
|
const hasPerm = useHasPermission(item.permission ?? '');
|
|
if (item.permission && !hasPerm) return null;
|
|
const Icon = item.icon;
|
|
return (
|
|
<button
|
|
onClick={() => onNavigate(item.path)}
|
|
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(--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)]'
|
|
}`}
|
|
>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
function XiaobaoRiskNavBadge() {
|
|
const { risks } = useXiaobaoWarningRisks();
|
|
const user = useAuthStore((s) => s.user);
|
|
const pendingInsightKeys = useXiaobaoRiskStore((s) => s.pendingInsightKeys);
|
|
const { readStates, readStateLoaded, fetchReadStates } = useXiaobaoWarningReadStore();
|
|
useEffect(() => {
|
|
if (user?.id) fetchReadStates();
|
|
}, [fetchReadStates, user?.id]);
|
|
|
|
const count = getXiaobaoWarningRiskCount(risks);
|
|
if (count <= 0) return null;
|
|
|
|
const warningRisks = filterXiaobaoRiskWarnings(risks).map((risk) => (
|
|
pendingInsightKeys.includes(buildXiaobaoRiskInsightPendingKey(risk))
|
|
? { ...risk, aiInsightUpdating: true }
|
|
: risk
|
|
));
|
|
const unreadUpdateCount = readStateLoaded ? getXiaobaoWarningUnreadUpdateCount(warningRisks, readStates, user?.id) : 0;
|
|
return <NavCountBadge count={count} tone={unreadUpdateCount > 0 ? 'update' : 'risk'} />;
|
|
}
|
|
|
|
function NavCountBadge({ count, tone = 'risk' }: { count: number; tone?: 'risk' | 'update' }) {
|
|
if (count <= 0) return null;
|
|
const toneClass = tone === 'update' ? 'bg-blue-600' : 'bg-red-600';
|
|
|
|
return (
|
|
<span className={`ml-auto inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[10px] font-semibold leading-none text-white ${toneClass}`}>
|
|
{count > 99 ? '99+' : count}
|
|
</span>
|
|
);
|
|
}
|