af49d22ca7
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
import { NavLink, useNavigate } from 'react-router-dom'
|
||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||
import { NAV_ITEMS } from '@/config/navigation'
|
||
import { useUiStore } from '@/app/store/ui'
|
||
import { useAuthStore } from '@/app/store/auth'
|
||
import { usePermission } from '@/hooks/usePermission'
|
||
import { useI18n } from '@/i18n'
|
||
import { cn } from '@/utils/cn'
|
||
import { getPlan } from '@/config/pricingPlans'
|
||
import { Progress } from '@/components/ui/feedback'
|
||
import { Avatar } from '@/components/ui/avatar'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { getBilling } from '@/services/api/org'
|
||
|
||
export function Sidebar() {
|
||
const { sidebarCollapsed, toggleSidebar, setMobileNavOpen } = useUiStore()
|
||
const { t } = useI18n()
|
||
const { can } = usePermission()
|
||
const tenant = useAuthStore((s) => s.tenant)
|
||
const user = useAuthStore((s) => s.user)
|
||
const navigate = useNavigate()
|
||
|
||
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 300_000 })
|
||
const plan = getPlan(billing?.info.planKey ?? tenant?.plan ?? 'pro')
|
||
const msgUsage = billing?.info.usage.messages
|
||
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
|
||
|
||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||
const groups = [
|
||
{ key: 'core', label: '核心业务' },
|
||
{ key: 'intelligence', label: '知识与洞察' },
|
||
{ key: 'admin', label: '企业管理' },
|
||
] as const
|
||
|
||
return (
|
||
<aside
|
||
className={cn(
|
||
'relative z-30 flex h-full flex-col border-e border-slate-200 bg-white shadow-[6px_0_24px_-22px_rgba(15,23,42,.45)] transition-[width] duration-200',
|
||
sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
|
||
)}
|
||
>
|
||
{/* Logo */}
|
||
<div
|
||
className="flex h-16 cursor-pointer items-center gap-2.5 border-b border-slate-100 px-4"
|
||
onClick={() => navigate('/dashboard')}
|
||
>
|
||
<img src="/brand-mark.svg" alt="" className="h-9 w-9 shrink-0 rounded-xl shadow-sm shadow-primary-900/10" />
|
||
{!sidebarCollapsed && (
|
||
<div className="min-w-0">
|
||
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 导航 */}
|
||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
|
||
{groups.map((group) => {
|
||
const groupItems = items.filter((item) => item.group === group.key)
|
||
if (!groupItems.length) return null
|
||
return (
|
||
<div key={group.key} className="mb-3">
|
||
{!sidebarCollapsed && (
|
||
<p className="mb-1.5 px-3 pt-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||
{group.label}
|
||
</p>
|
||
)}
|
||
<div className="space-y-1">
|
||
{groupItems.map((item) => (
|
||
<NavLink
|
||
key={item.key}
|
||
to={item.path}
|
||
onClick={() => setMobileNavOpen(false)}
|
||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||
className={({ isActive }) =>
|
||
cn(
|
||
'group relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] transition-all duration-150',
|
||
isActive
|
||
? 'bg-primary-50 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.1)] before:absolute before:inset-y-2 before:left-0 before:w-[3px] before:rounded-r-full before:bg-primary-600'
|
||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
|
||
sidebarCollapsed && 'justify-center px-0',
|
||
)
|
||
}
|
||
>
|
||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
|
||
</NavLink>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</nav>
|
||
|
||
{/* 底部:套餐与企业信息 */}
|
||
{!sidebarCollapsed && (
|
||
<div className="border-t border-slate-100 p-3">
|
||
<div className="rounded-[12px] border border-slate-200 bg-slate-50/80 p-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
|
||
<span className="text-xs font-medium text-primary-700">{plan.name}</span>
|
||
</div>
|
||
<div className="mt-2">
|
||
<div className="mb-1 flex justify-between text-[11px] text-muted-foreground">
|
||
<span>{t('sidebar.usage')}(消息)</span>
|
||
<span>{usagePct}%</span>
|
||
</div>
|
||
<Progress value={usagePct} />
|
||
</div>
|
||
<button
|
||
onClick={() => navigate('/billing')}
|
||
className="mt-2 text-xs font-medium text-primary hover:underline"
|
||
>
|
||
升级套餐 →
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 flex items-center gap-2 px-1">
|
||
<Avatar name={user?.name ?? '?'} size="sm" online />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-xs font-medium">{tenant?.name ?? '—'}</p>
|
||
<p className="truncate text-[11px] text-muted-foreground">
|
||
{user?.name} · {user?.title}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 收起按钮 */}
|
||
<button
|
||
onClick={toggleSidebar}
|
||
className="hidden h-10 items-center justify-center gap-2 border-t border-border text-xs text-muted-foreground hover:bg-muted hover:text-foreground lg:flex"
|
||
>
|
||
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||
{!sidebarCollapsed && <span>{t('sidebar.collapse')}</span>}
|
||
</button>
|
||
</aside>
|
||
)
|
||
}
|