804 lines
27 KiB
TypeScript
804 lines
27 KiB
TypeScript
import {
|
|
CheckCircleOutlined,
|
|
AuditOutlined,
|
|
AccountBookOutlined,
|
|
ApartmentOutlined,
|
|
BellOutlined,
|
|
BugOutlined,
|
|
DashboardOutlined,
|
|
FileSearchOutlined,
|
|
LogoutOutlined,
|
|
LineChartOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
OrderedListOutlined,
|
|
SafetyCertificateOutlined,
|
|
SettingOutlined,
|
|
ShopOutlined,
|
|
SoundOutlined,
|
|
TeamOutlined,
|
|
TrophyOutlined,
|
|
UnorderedListOutlined,
|
|
UserOutlined,
|
|
WalletOutlined,
|
|
} 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,
|
|
getAdminDisplayName,
|
|
getAdminRole,
|
|
getAdminToken,
|
|
getAdminTokenExpiresAt,
|
|
getAdminUsername,
|
|
setAdminDisplayName,
|
|
setAdminPermissions,
|
|
} from '@/utils/admin-auth'
|
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
import {
|
|
evaluateAdminWorkOrderRealtimeMatch,
|
|
parseAdminWorkOrderListFilters,
|
|
patchAdminWorkOrderListItems,
|
|
shouldPrependAdminWorkOrder,
|
|
} from '@/utils/admin-realtime-work-orders'
|
|
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
|
import dayjs from '@/lib/dayjs'
|
|
|
|
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 || [])
|
|
setAdminDisplayName(session.user.displayName || session.user.username)
|
|
}
|
|
}, [adminSessionQuery.data])
|
|
|
|
const displayName =
|
|
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
|
|
|
|
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/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
|
|
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
|
|
{ key: '/admin/fund', icon: <WalletOutlined />, label: '钱包管理' },
|
|
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '资金审核' },
|
|
]
|
|
: [
|
|
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
|
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
|
{
|
|
key: '/admin/fund',
|
|
icon: <WalletOutlined />,
|
|
label: role === 'support' ? '我的钱包' : '钱包管理',
|
|
},
|
|
...(role === 'support'
|
|
? []
|
|
: [
|
|
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '财务' },
|
|
{
|
|
key: '/admin/sales-profit',
|
|
icon: <LineChartOutlined />,
|
|
label: '销售毛利',
|
|
},
|
|
{
|
|
key: '/admin/cost-center',
|
|
icon: <ApartmentOutlined />,
|
|
label: '商品与成本',
|
|
},
|
|
]),
|
|
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
|
{
|
|
key: '/admin/manual-fulfillment',
|
|
icon: <CheckCircleOutlined />,
|
|
label: '人工履约',
|
|
},
|
|
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
|
|
{
|
|
key: '/admin/kuaishou-industry',
|
|
icon: <SafetyCertificateOutlined />,
|
|
label: '电子凭证',
|
|
},
|
|
{
|
|
key: '/admin/douyin-mashida-certificates',
|
|
icon: <SafetyCertificateOutlined />,
|
|
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>{displayName}</Typography.Text>
|
|
{displayName !== username ? (
|
|
<Typography.Text type="secondary">@{username}</Typography.Text>
|
|
) : null}
|
|
<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
|
|
for (const [queryKey, current] of queryClient.getQueriesData({
|
|
queryKey: ['admin-worker-platform-orders'],
|
|
})) {
|
|
const filters = parseAdminWorkOrderListFilters(queryKey)
|
|
if (!filters) {
|
|
// 读不懂这个 key(旧结构 / 别的调用方):不猜、不插、不移除,交给服务端下一次拉取。
|
|
continue
|
|
}
|
|
|
|
// 三态判定:match 才允许插入顶部;mismatch 才允许移除;unknown 只原地更新已存在的行。
|
|
// 服务端的 operation 交给判定函数处理:显式单号检索下连 remove 都不能删行
|
|
// (旧写法在这里直接把 remove 判成 mismatch,绕过了单号护栏)。
|
|
const verdict = evaluateAdminWorkOrderRealtimeMatch(item, filters, {
|
|
operation: event.operation,
|
|
})
|
|
if (verdict === 'unknown') {
|
|
queryClient.setQueryData(
|
|
queryKey,
|
|
patchAdminWorkOrderListItems(current, item, { remove: false, insert: false }),
|
|
)
|
|
continue
|
|
}
|
|
|
|
const items =
|
|
isRecord(current) && isRecord(current.data) && Array.isArray(current.data.items)
|
|
? (current.data.items as unknown[])
|
|
: []
|
|
const insert =
|
|
verdict === 'match' &&
|
|
event.operation === 'upsert' &&
|
|
filters.page === 1 &&
|
|
shouldPrependAdminWorkOrder(items, item)
|
|
|
|
queryClient.setQueryData(
|
|
queryKey,
|
|
patchAdminWorkOrderListItems(current, item, {
|
|
remove: verdict === 'mismatch',
|
|
insert,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
paidAt?: unknown
|
|
createdAt?: unknown
|
|
provider?: unknown
|
|
shopName?: unknown
|
|
itemTitle?: unknown
|
|
itemSummary?: 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 pageSize = Math.max(1, Number(params.pageSize || 20) || 20)
|
|
const keyword = String(params.keyword || '').trim()
|
|
const matchesFilters =
|
|
matchesAdminOrderKeyword(item, keyword) &&
|
|
matchesAdminOrderProvider(item, String(params.provider || '').trim()) &&
|
|
(!String(params.payStatus || '').trim() ||
|
|
String(item.payStatus || '') === String(params.payStatus || '').trim()) &&
|
|
// 有关键词时不限日期;否则实时插入也要落在当前日期筛选内。
|
|
(keyword ? true : matchesAdminOrderDateRange(item, params))
|
|
const shouldInsert =
|
|
event.operation === 'upsert' && page === 1 && matchesFilters && !params.skuCode
|
|
const operation = event.operation === 'remove' || !matchesFilters ? 'remove' : 'upsert'
|
|
queryClient.setQueryData(
|
|
queryKey,
|
|
patchAdminOrderListEnvelope(current, { ...event, operation }, shouldInsert, pageSize),
|
|
)
|
|
}
|
|
}
|
|
|
|
function getOrderSortTimestamp(item: unknown): number {
|
|
if (!item || typeof item !== 'object') return 0
|
|
const record = item as Record<string, unknown>
|
|
const timeStr = String(record.paidAt || record.createdAt || '').trim()
|
|
if (!timeStr) return 0
|
|
const t = dayjs(timeStr).valueOf()
|
|
return Number.isFinite(t) ? t : 0
|
|
}
|
|
|
|
function sortAdminOrdersDesc(items: unknown[]): unknown[] {
|
|
return [...items].sort((a, b) => {
|
|
const timeA = getOrderSortTimestamp(a)
|
|
const timeB = getOrderSortTimestamp(b)
|
|
if (timeB !== timeA) {
|
|
return timeB - timeA
|
|
}
|
|
const idA = Number((a as Record<string, unknown>)?.orderId || 0)
|
|
const idB = Number((b as Record<string, unknown>)?.orderId || 0)
|
|
return idB - idA
|
|
})
|
|
}
|
|
|
|
function patchAdminOrderListEnvelope(
|
|
current: unknown,
|
|
event: RealtimeEvent,
|
|
insert = false,
|
|
pageSize = 20,
|
|
) {
|
|
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 existed = items.some((entry) => Number((entry as { orderId?: unknown })?.orderId) === id)
|
|
let nextItems =
|
|
event.operation === 'remove'
|
|
? items.filter((entry) => Number((entry as { orderId?: unknown })?.orderId) !== id)
|
|
: upsertById(items, event.data, 'orderId', insert)
|
|
|
|
// 严格保持按支付/创建时间倒序排列,避免后台同步历史单(如 15/16 号)时盲目插在第 1 行
|
|
nextItems = sortAdminOrdersDesc(nextItems)
|
|
|
|
if (insert && nextItems.length > pageSize) {
|
|
nextItems = nextItems.slice(0, pageSize)
|
|
}
|
|
|
|
const pagination = isRecord(current.data.pagination) ? current.data.pagination : null
|
|
let nextPagination = pagination
|
|
if (pagination && insert && !existed) {
|
|
nextPagination = {
|
|
...pagination,
|
|
total: Number(pagination.total || 0) + 1,
|
|
}
|
|
} else if (pagination && event.operation === 'remove' && existed) {
|
|
nextPagination = {
|
|
...pagination,
|
|
total: Math.max(0, Number(pagination.total || 0) - 1),
|
|
}
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
data: {
|
|
...current.data,
|
|
items: nextItems,
|
|
...(nextPagination ? { pagination: nextPagination } : {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
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 matchesAdminOrderKeyword(
|
|
item: {
|
|
platformOrderId?: unknown
|
|
shopName?: unknown
|
|
itemTitle?: unknown
|
|
itemSummary?: unknown
|
|
},
|
|
keyword: string,
|
|
) {
|
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
|
if (!normalizedKeyword) return true
|
|
return [item.platformOrderId, item.shopName, item.itemTitle, item.itemSummary]
|
|
.map((value) => String(value || '').toLowerCase())
|
|
.some((value) => value.includes(normalizedKeyword))
|
|
}
|
|
|
|
function matchesAdminOrderProvider(item: { provider?: unknown }, provider: string) {
|
|
const normalized = provider.trim().toLowerCase()
|
|
if (!normalized) return true
|
|
const value = String(item.provider || '').toLowerCase()
|
|
if (normalized === 'pdd') return value === 'pdd'
|
|
if (normalized === '91' || normalized === '91kaquan' || normalized === 'open_91') {
|
|
return ['91kaquan', 'open_91', '91'].includes(value)
|
|
}
|
|
if (normalized === 'kuaishou' || normalized === 'kuaishou-industry') {
|
|
return value.startsWith('kuaishou')
|
|
}
|
|
return value === normalized
|
|
}
|
|
|
|
function matchesAdminOrderDateRange(
|
|
item: { paidAt?: unknown; createdAt?: unknown },
|
|
params: Record<string, unknown>,
|
|
) {
|
|
const dateFrom = String(params.dateFrom || '')
|
|
.trim()
|
|
.slice(0, 10)
|
|
const dateTo = String(params.dateTo || '')
|
|
.trim()
|
|
.slice(0, 10)
|
|
if (!dateFrom && !dateTo) return true
|
|
const raw = String(item.paidAt || item.createdAt || '').trim()
|
|
if (!raw) return false
|
|
const day = dayjs(raw).format('YYYY-MM-DD')
|
|
if (dateFrom && day < dateFrom) return false
|
|
if (dateTo && day > dateTo) return false
|
|
return true
|
|
}
|
|
|
|
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/cost-center')) return '/admin/cost-center'
|
|
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 '客服'
|
|
}
|