feat(history): establish Telegram SaaS platform foundation
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user