chore(history): import initial web product snapshot

This commit is contained in:
wmq
2026-07-25 00:30:00 +08:00
commit cf0eed46c3
131 changed files with 15693 additions and 0 deletions
+32
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
}
+121
View File
@@ -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>
)
}
+207
View File
@@ -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>
)
}