chore(history): import initial web product snapshot
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Topbar } from './Topbar'
|
||||
import { Drawer } from '@/components/ui/drawer'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
|
||||
export function AppLayout() {
|
||||
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<div className="hidden shrink-0 lg:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* 移动端抽屉导航 */}
|
||||
<Drawer open={mobileNavOpen} onOpenChange={setMobileNavOpen} side="left" width="w-64" title="灵犀跨境">
|
||||
<div className="h-full">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, User, MessagesSquare, BookOpen, ListTodo } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { globalSearch } from '@/services/api/search'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
/** 顶栏全局搜索(⌘/Ctrl + K 唤起) */
|
||||
export function GlobalSearch() {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const debounced = useDebounce(q, 250)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['global-search', debounced],
|
||||
queryFn: () => globalSearch(debounced),
|
||||
enabled: debounced.trim().length > 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
setOpen(true)
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onClick)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const go = (path: string) => {
|
||||
setOpen(false)
|
||||
setQ('')
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const hasResult =
|
||||
data && (data.customers.length + data.conversations.length + data.knowledge.length + data.tasks.length > 0)
|
||||
|
||||
const groups = [
|
||||
{ key: 'customers', label: '客户', icon: User, items: data?.customers ?? [], to: (id: string) => `/customers/${id}` },
|
||||
{ key: 'conversations', label: '会话', icon: MessagesSquare, items: data?.conversations ?? [], to: (id: string) => `/inbox?c=${id}` },
|
||||
{ key: 'knowledge', label: '知识库', icon: BookOpen, items: data?.knowledge ?? [], to: () => `/knowledge` },
|
||||
{ key: 'tasks', label: '任务', icon: ListTodo, items: data?.tasks ?? [], to: () => `/opportunities?tab=tasks` },
|
||||
]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative w-full max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-3" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={t('topbar.searchPlaceholder')}
|
||||
className="h-9 w-full rounded-md border border-input bg-muted/50 pl-9 pr-12 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none focus:ring-2 focus-visible:ring-ring rtl:pl-12 rtl:pr-9"
|
||||
/>
|
||||
<kbd className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 rounded border border-border bg-card px-1.5 text-[10px] text-muted-foreground md:block rtl:right-auto rtl:left-2.5">
|
||||
⌘K
|
||||
</kbd>
|
||||
|
||||
{open && q.trim() && (
|
||||
<div className="absolute z-50 mt-1.5 max-h-[420px] w-full overflow-y-auto rounded-lg border border-border bg-popover shadow-lg scrollbar-thin">
|
||||
{!hasResult && (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{debounced ? '未找到相关结果' : '输入关键词开始搜索'}
|
||||
</p>
|
||||
)}
|
||||
{groups.map(
|
||||
(g) =>
|
||||
g.items.length > 0 && (
|
||||
<div key={g.key} className="py-1.5">
|
||||
<p className="flex items-center gap-1.5 px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
<g.icon className="h-3.5 w-3.5" />
|
||||
{g.label}
|
||||
</p>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => go(g.to(item.id))}
|
||||
className={cn('flex w-full flex-col px-4 py-1.5 text-start hover:bg-muted')}
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{item.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { PanelLeftClose, PanelLeftOpen, Sparkles } 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))
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-16' : 'w-60',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold 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 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 font-medium text-primary-700'
|
||||
: 'text-secondary-foreground hover:bg-muted',
|
||||
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>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 底部:套餐与企业信息 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="rounded-md bg-muted/60 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bell,
|
||||
Building2,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
Globe,
|
||||
LogOut,
|
||||
Menu,
|
||||
UserRound,
|
||||
UsersRound,
|
||||
} from 'lucide-react'
|
||||
import { getNotifications, readAllNotifications } from '@/services/api/org'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
import { ROLE_META, type RoleKey } from '@/config/roles'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { confirmDialog } from '@/app/store/confirm'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { GlobalSearch } from './GlobalSearch'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const NOTIF_TYPE_COLOR: Record<string, string> = {
|
||||
inquiry: 'bg-emerald-100 text-emerald-700',
|
||||
risk: 'bg-red-100 text-red-700',
|
||||
task: 'bg-amber-100 text-amber-700',
|
||||
channel: 'bg-blue-100 text-blue-700',
|
||||
system: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
export function Topbar() {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user, tenant, switchRole, logout } = useAuthStore()
|
||||
const setMobileNavOpen = useUiStore((s) => s.setMobileNavOpen)
|
||||
|
||||
const { data: notifications } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: getNotifications,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const unread = notifications?.filter((n) => !n.read).length ?? 0
|
||||
|
||||
const handleLogout = () => {
|
||||
confirmDialog({
|
||||
title: '退出登录',
|
||||
description: '确定要退出当前账号吗?',
|
||||
onConfirm: () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
|
||||
{/* 移动端菜单 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
aria-label="打开导航"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 企业切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted md:flex">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => toast.info('当前演示环境仅一个企业') },
|
||||
'separator',
|
||||
{ key: 'switch', label: '创建/加入新企业', icon: UsersRound, onSelect: () => navigate('/register') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 全局搜索 */}
|
||||
<div className="flex flex-1 justify-center px-1">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
{/* 语言切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-1 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('lang.name')}>
|
||||
<Globe className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
}
|
||||
items={UI_LOCALES.map((l) => ({
|
||||
key: l.code,
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
{l.nativeName}
|
||||
{locale === l.code && <span className="text-primary">✓</span>}
|
||||
{l.rtl && <Badge variant="outline">RTL</Badge>}
|
||||
</span>
|
||||
),
|
||||
onSelect: () => {
|
||||
setLocale(l.code as UiLocale)
|
||||
toast.success(l.rtl ? '已切换为 RTL 布局(演示)' : `界面语言已切换:${l.nativeName}`)
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* 帮助 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={t('topbar.help')}
|
||||
onClick={() => navigate('/help')}
|
||||
>
|
||||
<CircleHelp className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
|
||||
{/* 通知 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('topbar.notifications')}>
|
||||
<Bell className="h-[18px] w-[18px]" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
className="[&>div]:w-80"
|
||||
items={[
|
||||
{
|
||||
key: 'read-all',
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-primary">
|
||||
<CheckCheck className="h-4 w-4" /> 全部标为已读
|
||||
</span>
|
||||
),
|
||||
onSelect: () => {
|
||||
readAllNotifications().then(() => queryClient.invalidateQueries({ queryKey: ['notifications'] }))
|
||||
},
|
||||
},
|
||||
'separator',
|
||||
...(notifications ?? []).slice(0, 6).map((n) => ({
|
||||
key: n.id,
|
||||
label: (
|
||||
<span className="flex flex-col gap-0.5 py-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
|
||||
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
|
||||
</span>
|
||||
<span className="text-sm font-medium leading-snug">{n.title}</span>
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.description}</span>
|
||||
</span>
|
||||
),
|
||||
onSelect: () => n.link && navigate(n.link),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<Avatar name={user?.name ?? '?'} size="sm" online />
|
||||
<span className="hidden text-start md:block">
|
||||
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span>
|
||||
<span className="block text-[11px] text-muted-foreground leading-tight">{ROLE_META[user?.role ?? 'viewer'].label}</span>
|
||||
</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 text-muted-foreground md:block" />
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
{ key: 'profile', label: t('topbar.profile'), icon: UserRound, onSelect: () => navigate('/settings') },
|
||||
'separator',
|
||||
...(Object.keys(ROLE_META) as RoleKey[]).map((r) => ({
|
||||
key: `role-${r}`,
|
||||
label: (
|
||||
<span className={cn('flex items-center gap-2', user?.role === r && 'font-medium text-primary')}>
|
||||
{ROLE_META[r].label}
|
||||
{user?.role === r && '✓'}
|
||||
</span>
|
||||
),
|
||||
icon: UsersRound,
|
||||
onSelect: () => {
|
||||
switchRole(r)
|
||||
queryClient.clear()
|
||||
toast.success(`已切换为「${ROLE_META[r].label}」(演示)`, '导航与按钮权限已同步更新')
|
||||
},
|
||||
})),
|
||||
'separator',
|
||||
{ key: 'logout', label: t('topbar.logout'), icon: LogOut, danger: true, onSelect: handleLogout },
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user