Files
lingxi-web/src/components/layout/Sidebar.tsx
T
wmq af49d22ca7 feat: release contextual omnichannel customer platform
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.
2026-07-30 02:29:53 +08:00

140 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}