feat(history): establish Telegram SaaS platform foundation

This commit is contained in:
wmq
2026-07-27 23:03:00 +08:00
parent cf0eed46c3
commit cd06d049fe
92 changed files with 5608 additions and 3749 deletions
+2
View File
@@ -3,12 +3,14 @@ import { Sidebar } from './Sidebar'
import { Topbar } from './Topbar'
import { Drawer } from '@/components/ui/drawer'
import { useUiStore } from '@/app/store/ui'
import { GlobalRealtime } from './GlobalRealtime'
export function AppLayout() {
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
return (
<div className="flex h-screen overflow-hidden bg-background">
<GlobalRealtime />
{/* 桌面端固定侧边栏 */}
<div className="hidden shrink-0 lg:block">
<Sidebar />
+61
View File
@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth'
import { toast } from '@/app/store/toast'
import type { Customer, Message } from '@/types'
import { getWebSocketTicket } from '@/services/api/auth'
export function GlobalRealtime() {
const token = useAuthStore((state) => state.token)
const location = useLocation()
const queryClient = useQueryClient()
useEffect(() => {
if (!token) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
let socket: WebSocket | undefined
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let stopped = false
let retryDelay = 500
const connect = async () => {
if (stopped) return
try {
const { ticket } = await getWebSocketTicket()
if (stopped) return
socket = new WebSocket(`${protocol}//${window.location.host}/api/ws?ticket=${encodeURIComponent(ticket)}`)
} catch {
reconnectTimer = setTimeout(() => { void connect() }, retryDelay)
retryDelay = Math.min(retryDelay * 2, 10_000)
return
}
socket.onopen = () => { retryDelay = 500 }
socket.onmessage = (raw) => {
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
try { event = JSON.parse(String(raw.data)) } catch { return }
if (event.type !== 'message.created' || event.message?.sender !== 'customer') return
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
if (!location.pathname.startsWith('/inbox')) {
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
toast.info(`${event.customer?.name || event.message.senderName || 'Telegram 客户'} 发来新消息`, preview.slice(0, 120))
}
}
socket.onerror = () => socket?.close()
socket.onclose = () => {
if (stopped) return
reconnectTimer = setTimeout(() => { void connect() }, retryDelay)
retryDelay = Math.min(retryDelay * 2, 10_000)
}
}
void connect()
return () => {
stopped = true
if (reconnectTimer) clearTimeout(reconnectTimer)
socket?.close()
}
}, [token, location.pathname, queryClient])
return null
}
+4 -20
View File
@@ -17,7 +17,7 @@ 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 { ROLE_META } from '@/config/roles'
import { toast } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { Avatar } from '@/components/ui/avatar'
@@ -39,7 +39,7 @@ export function Topbar() {
const { t, locale, setLocale } = useI18n()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user, tenant, switchRole, logout } = useAuthStore()
const { user, tenant, logout } = useAuthStore()
const setMobileNavOpen = useUiStore((s) => s.setMobileNavOpen)
const { data: notifications } = useQuery({
@@ -81,7 +81,7 @@ export function Topbar() {
</button>
}
items={[
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => toast.info('当前演示环境仅一个企业') },
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => {} },
'separator',
{ key: 'switch', label: '创建/加入新企业', icon: UsersRound, onSelect: () => navigate('/register') },
]}
@@ -110,7 +110,7 @@ export function Topbar() {
),
onSelect: () => {
setLocale(l.code as UiLocale)
toast.success(l.rtl ? '已切换为 RTL 布局(演示)' : `界面语言已切换:${l.nativeName}`)
toast.success(l.rtl ? '已切换为 RTL 布局' : `界面语言已切换:${l.nativeName}`)
},
}))}
/>
@@ -183,22 +183,6 @@ export function Topbar() {
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 },
]}
/>