Files
order_site/apps/frontend/src/layouts/AdminLayout.tsx
T

656 lines
23 KiB
TypeScript

import {
AuditOutlined,
AccountBookOutlined,
BellOutlined,
BugOutlined,
DashboardOutlined,
FileSearchOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
OrderedListOutlined,
SafetyCertificateOutlined,
SettingOutlined,
ShopOutlined,
SoundOutlined,
TeamOutlined,
TrophyOutlined,
UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons'
import {
App,
Badge,
Button,
Dropdown,
Layout,
List,
Menu,
Popover,
Space,
Tooltip,
Typography,
} from 'antd'
import type { MenuProps } from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router'
import {
fetchAdminSession,
fetchAdminNotifications,
fetchDevMockStatus,
logoutAdmin,
type AdminNotification,
} from '@/services/admin'
import {
clearAdminSession,
getAdminRole,
getAdminToken,
getAdminTokenExpiresAt,
getAdminUsername,
setAdminPermissions,
} from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed'
const NOTIFICATION_SOUND_KEY = 'admin-notification-sound-enabled'
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { message } = App.useApp()
const queryClient = useQueryClient()
const [collapsed, setCollapsed] = useState(
() => window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1',
)
const role = getAdminRole()
const username = getAdminUsername() || 'admin'
const expiresAt = getAdminTokenExpiresAt()
const [soundEnabled, setSoundEnabled] = useState(
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
)
const adminSessionQuery = useQuery({
queryKey: ['admin-session'],
queryFn: fetchAdminSession,
retry: false,
})
useEffect(() => {
const session = adminSessionQuery.data?.data
if (session?.authenticated && session.user) {
setAdminPermissions(session.user.permissions || [])
}
}, [adminSessionQuery.data])
const devMockStatusQuery = useQuery({
queryKey: ['admin-dev-mock-status'],
queryFn: () => fetchDevMockStatus(),
enabled: role !== 'finance',
retry: false,
})
const devMockEnabled = Boolean(devMockStatusQuery.data?.data?.enabled)
const notificationsQuery = useQuery({
queryKey: ['admin-notifications'],
queryFn: fetchAdminNotifications,
enabled: role !== 'finance',
retry: false,
})
const notifications = notificationsQuery.data?.data.items || []
const pendingNotificationCount = Number(notificationsQuery.data?.data.pendingCount || 0)
useRealtimeEvents({
url: '/api/v1/admin/realtime',
token: role === 'finance' ? '' : getAdminToken(),
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
onAuthExpired: () => {
clearAdminSession()
void navigate('/admin/login', { replace: true, state: { from: location } })
},
})
const menuItems = useMemo<MenuProps['items']>(() => {
const operationItems: MenuProps['items'] =
role === 'finance'
? [
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
]
: [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
...(role === 'support'
? []
: [{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' }]),
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
{
key: '/admin/kuaishou-industry',
icon: <SafetyCertificateOutlined />,
label: '电子凭证',
},
{
key: '/admin/cloudtentacles-records',
icon: <FileSearchOutlined />,
label: '发货记录',
},
]
const items: MenuProps['items'] = [
{
key: 'group-operation',
type: 'group',
label: '运营',
children: operationItems,
},
]
if (devMockEnabled) {
items.push({
key: 'group-dev',
type: 'group',
label: '开发',
children: [{ key: '/admin/dev-mock', icon: <BugOutlined />, label: '开发 Mock' }],
})
}
if (role === 'admin') {
items.push(
{
key: 'group-config',
type: 'group',
label: '配置',
children: [
{ key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' },
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' },
],
},
{
key: 'group-system',
type: 'group',
label: '系统',
children: [{ key: '/admin/audit-logs', icon: <AuditOutlined />, label: '审计日志' }],
},
)
}
return items
}, [devMockEnabled, role])
async function submitLogout() {
try {
await logoutAdmin()
} catch {
// 服务端撤销失败时仍清理本地登录态,避免当前设备继续显示为已登录。
} finally {
clearAdminSession()
message.success('已退出后台')
void navigate('/admin/login', { replace: true })
}
}
function toggleCollapsed() {
const next = !collapsed
setCollapsed(next)
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0')
}
function toggleNotificationSound() {
const next = !soundEnabled
setSoundEnabled(next)
window.localStorage.setItem(NOTIFICATION_SOUND_KEY, next ? '1' : '0')
if (next) {
playNotificationSound('default')
message.success('通知声音已开启')
} else {
message.info('通知声音已关闭')
}
}
function openNotification(notification: AdminNotification) {
const actionPath = buildNotificationActionPath(notification)
if (actionPath) {
void navigate(actionPath)
}
}
const notificationContent = (
<div className="admin-notification-popover">
{notifications.length === 0 ? (
<Typography.Text type="secondary">暂无待处理事项</Typography.Text>
) : (
<List
size="small"
dataSource={notifications.slice(0, 8)}
renderItem={(notification) => (
<List.Item
className="admin-notification-item"
onClick={() => openNotification(notification)}
>
<div>
<Space size={6} wrap>
<Typography.Text strong>{notification.title}</Typography.Text>
{notification.priority === 'high' || notification.priority === 'urgent' ? (
<span className="admin-notification-priority">紧急</span>
) : null}
{notification.reminderCount > 0 ? (
<Typography.Text type="secondary">
已催 {notification.reminderCount}
</Typography.Text>
) : null}
</Space>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }}>
{notification.body}
</Typography.Paragraph>
<Typography.Text type="secondary" className="admin-notification-time">
{formatAdminDateTime(notification.updatedAt)}
</Typography.Text>
</div>
</List.Item>
)}
/>
)}
</div>
)
const selectedKey = resolveSelectedKey(location.pathname)
return (
<Layout className="admin-shell">
<Layout.Sider
width={232}
collapsedWidth={72}
collapsed={collapsed}
className="admin-sider"
trigger={null}
>
<div className="admin-sider-inner">
<div className="admin-brand">
<div className="admin-brand-copy">
<strong>{collapsed ? 'OS' : '运营后台'}</strong>
{!collapsed ? <span>订单与交付管理</span> : null}
</div>
<Button
className="admin-collapse-button"
aria-label={collapsed ? '展开导航' : '折叠导航'}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={toggleCollapsed}
/>
</div>
{!collapsed ? (
<div className="admin-sidebar-profile">
<Typography.Text strong>{username}</Typography.Text>
<Typography.Text type="secondary">{roleLabel(role)}</Typography.Text>
{expiresAt ? (
<Typography.Text type="secondary">
{formatAdminDateTime(expiresAt)} 到期
</Typography.Text>
) : null}
</div>
) : null}
<Menu
mode="inline"
inlineIndent={16}
selectedKeys={[selectedKey]}
items={menuItems}
className="admin-menu"
onClick={({ key }) => navigate(String(key))}
/>
<div className="admin-sider-footer">
<Button
block={!collapsed}
icon={<LogoutOutlined />}
onClick={submitLogout}
aria-label="退出登录"
>
{collapsed ? null : '退出登录'}
</Button>
</div>
</div>
</Layout.Sider>
<Layout className="admin-main-shell">
<Layout.Header className="admin-topbar">
<Space className="admin-user" size={12}>
<Tooltip title={soundEnabled ? '关闭通知声音' : '开启通知声音'}>
<Button
type="text"
aria-label={soundEnabled ? '关闭通知声音' : '开启通知声音'}
icon={<SoundOutlined className={soundEnabled ? '' : 'admin-sound-muted'} />}
onClick={toggleNotificationSound}
/>
</Tooltip>
<Popover
content={notificationContent}
title="待处理事项"
trigger="click"
placement="bottomRight"
>
<Badge count={pendingNotificationCount} size="small" overflowCount={99}>
<Button type="text" aria-label="待处理事项" icon={<BellOutlined />} />
</Badge>
</Popover>
<Dropdown
menu={{
items: [{ key: 'logout', label: '退出登录' }],
onClick: () => {
void submitLogout()
},
}}
placement="bottomRight"
>
<Button icon={<UserOutlined />}>账号</Button>
</Dropdown>
</Space>
</Layout.Header>
<Layout.Content className="admin-content">
<Outlet />
</Layout.Content>
</Layout>
</Layout>
)
}
function applyAdminRealtimeEvent(
event: RealtimeEvent,
queryClient: ReturnType<typeof useQueryClient>,
) {
if (event.type === 'admin_notification.changed' && isRecord(event.data)) {
queryClient.setQueryData(['admin-notifications'], (current: unknown) => {
if (!isRecord(current) || !isRecord(current.data)) return current
const currentData = current.data as { items?: unknown[]; pendingCount?: number }
const items = Array.isArray(currentData.items) ? currentData.items : []
const notification = event.data as Record<string, unknown>
const notificationId = Number(notification.notificationId || event.entityId)
const nextItems =
event.operation === 'remove'
? items.filter(
(item) =>
Number((item as { notificationId?: unknown })?.notificationId) !== notificationId,
)
: upsertById(items, notification, 'notificationId', true)
return {
...current,
data: {
...currentData,
items: nextItems,
pendingCount:
event.operation === 'remove'
? Math.max(0, Number(currentData.pendingCount || 0) - 1)
: currentData.pendingCount,
},
}
})
}
if (event.type === 'work_order.changed' && event.scopes.includes('admin_work_orders')) {
// 拼单明细是独立查询,工单列表快照更新时也要同步刷新当前打开的明细。
void queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-order-sharing', event.entityId],
})
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-after-sales-cases'] })
if (isRecord(event.data)) {
const data = event.data
patchAdminWorkOrderCaches(queryClient, { ...event, data })
} else {
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
}
}
if (
event.type === 'worker_finance_request.changed' &&
event.scopes.includes('admin_worker_finance') &&
isRecord(event.data)
) {
const data = event.data
queryClient.setQueriesData(
{ queryKey: ['admin-worker-platform-finance-requests'] },
(current: unknown) => patchFinanceRequestListEnvelope(current, { ...event, data }),
)
}
if (
event.type === 'order.changed' &&
event.scopes.includes('admin_orders') &&
isRecord(event.data)
) {
const payload = event.data
if (isRecord(payload.order)) {
patchAdminOrderCaches(queryClient, { ...event, data: payload.order })
}
if (isRecord(payload.dashboard)) {
queryClient.setQueryData(['admin-dashboard-summary'], (current: unknown) => {
if (!isRecord(current)) return current
return { ...current, data: payload.dashboard }
})
}
if (Array.isArray(payload.tasks)) {
patchAdminTaskCaches(queryClient, payload.tasks)
}
}
}
function patchAdminWorkOrderCaches(
queryClient: ReturnType<typeof useQueryClient>,
event: RealtimeEvent,
) {
const item = event.data as {
workOrderId?: unknown
status?: unknown
productName?: unknown
platformOrderId?: unknown
worker?: { workerId?: unknown } | null
}
for (const [queryKey, current] of queryClient.getQueriesData({
queryKey: ['admin-worker-platform-orders'],
})) {
const statuses = String(queryKey[1] || '')
.split(',')
.filter(Boolean)
const keyword = String(queryKey[2] || '')
const workerId = Number(queryKey[3] || 0)
const workerLevelIds = String(queryKey[4] || '')
.split(',')
.map((value) => Number(value))
.filter(Boolean)
const targetWorkOrderId = Number(queryKey[5] || 0)
const page = Number(queryKey[6] || 1)
// 等级筛选的匹配逻辑只在服务端(接单打手或拼单参与者等级),快照不含等级,
// 无法在前端复现:已有行视为匹配(拉取时已通过服务端过滤),但不再插入新行。
const levelFilterActive = workerLevelIds.length > 0
const matches =
(!statuses.length || statuses.includes(String(item.status || ''))) &&
matchesWorkOrderKeyword(item, keyword) &&
(!workerId || workerId === Number(item.worker?.workerId || 0)) &&
(!targetWorkOrderId || targetWorkOrderId === Number(item.workOrderId || 0))
const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !levelFilterActive
const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'
queryClient.setQueryData(
queryKey,
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
)
}
}
function patchAdminTaskCaches(queryClient: ReturnType<typeof useQueryClient>, tasks: unknown[]) {
queryClient.setQueriesData({ queryKey: ['admin-tasks'] }, (current: unknown) => {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const items = current.data.items as unknown[]
const nextItems = items.map((item) => {
const taskId = Number((item as { taskId?: unknown }).taskId || 0)
return (
tasks.find((task) => Number((task as { taskId?: unknown }).taskId || 0) === taskId) || item
)
})
return { ...current, data: { ...current.data, items: nextItems } }
})
}
function patchAdminOrderCaches(
queryClient: ReturnType<typeof useQueryClient>,
event: RealtimeEvent,
) {
const item = event.data as { orderId?: unknown; platformOrderId?: unknown; payStatus?: unknown }
for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ['admin-orders'] })) {
const params = isRecord(queryKey[1]) ? queryKey[1] : {}
const page = Number(params.page || 1)
const matches =
(!String(params.platformOrderId || '').trim() ||
String(item.platformOrderId || '') === String(params.platformOrderId || '').trim()) &&
(!String(params.payStatus || '').trim() ||
String(item.payStatus || '') === String(params.payStatus || '').trim())
const shouldInsert = event.operation === 'upsert' && page === 1 && matches && !params.skuCode
const operation = event.operation === 'remove' || !matches ? 'remove' : 'upsert'
queryClient.setQueryData(
queryKey,
patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert),
)
}
}
function patchAdminOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const id = Number((event.data as { orderId?: unknown }).orderId || event.entityId)
const items = current.data.items as unknown[]
const nextItems =
event.operation === 'remove'
? items.filter((entry) => Number((entry as { orderId?: unknown })?.orderId) !== id)
: upsertById(items, event.data, 'orderId', insert)
return { ...current, data: { ...current.data, items: nextItems } }
}
function patchWorkOrderListEnvelope(current: unknown, event: RealtimeEvent, insert = false) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const item = event.data
const id = Number((item as { workOrderId?: unknown }).workOrderId || event.entityId)
const items = current.data.items as unknown[]
const nextItems =
event.operation === 'remove'
? items.filter((entry) => Number((entry as { workOrderId?: unknown })?.workOrderId) !== id)
: upsertById(items, item, 'workOrderId', insert)
return { ...current, data: { ...current.data, items: nextItems } }
}
function patchFinanceRequestListEnvelope(current: unknown, event: RealtimeEvent) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
const id = Number((event.data as { requestId?: unknown }).requestId || event.entityId)
const items = current.data.items as unknown[]
const nextItems =
event.operation === 'remove'
? items.filter((entry) => Number((entry as { requestId?: unknown })?.requestId) !== id)
: upsertById(items, event.data, 'requestId')
return { ...current, data: { ...current.data, items: nextItems } }
}
function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert = false) {
const id = Number((nextItem as Record<string, unknown>)[idKey] || 0)
const index = items.findIndex(
(item) => Number((item as Record<string, unknown>)?.[idKey] || 0) === id,
)
if (index < 0) return insert ? [nextItem, ...items] : items
return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item))
}
function matchesWorkOrderKeyword(
item: { productName?: unknown; platformOrderId?: unknown },
keyword: string,
) {
const normalizedKeyword = keyword.trim().toLowerCase()
if (!normalizedKeyword) return true
return [item.productName, item.platformOrderId]
.map((value) => String(value || '').toLowerCase())
.some((value) => value.includes(normalizedKeyword))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
type NotificationAlertKind =
| 'withdraw'
| 'recharge'
| 'acceptance'
| 'material'
| 'reminder'
| 'default'
function playNotificationSound(kind: NotificationAlertKind) {
try {
const audio = new AudioContext()
const oscillator = audio.createOscillator()
const gain = audio.createGain()
oscillator.type = 'sine'
const notes: Record<NotificationAlertKind, number[]> = {
withdraw: [740, 988],
recharge: [587, 740],
acceptance: [660, 784],
material: [523, 659],
reminder: [880, 1047, 1319],
default: [880],
}
const duration = 0.12
const sequence = notes[kind]
sequence.forEach((frequency, index) => {
oscillator.frequency.setValueAtTime(frequency, audio.currentTime + index * duration)
})
gain.gain.setValueAtTime(0.08, audio.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + sequence.length * duration)
oscillator.connect(gain)
gain.connect(audio.destination)
oscillator.start()
oscillator.stop(audio.currentTime + sequence.length * duration)
oscillator.addEventListener('ended', () => void audio.close())
} catch {
// 部分浏览器禁止音频或不支持 Web Audio,不影响待办展示。
}
}
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
if (pathname === '/admin/platform-shops') return '/admin/platform-shops'
return pathname
}
function buildNotificationActionPath(notification: AdminNotification) {
if (!notification.entityId) {
return notification.actionPath
}
const [pathname, query = ''] = notification.actionPath.split('?')
const searchParams = new URLSearchParams(query)
if (notification.entityType === 'work_order') {
searchParams.set('tab', 'orders')
searchParams.set('workOrderId', String(notification.entityId))
} else if (notification.entityType === 'worker_finance_request') {
searchParams.set('tab', 'finance')
searchParams.set('financeRequestId', String(notification.entityId))
} else {
return notification.actionPath
}
return `${pathname}?${searchParams.toString()}`
}
function roleLabel(role: string) {
if (role === 'admin') return '管理员'
if (role === 'operator') return '普通运营'
if (role === 'finance') return '财务'
return '客服'
}