Merge branch 'main' into dev
This commit is contained in:
@@ -48,11 +48,14 @@ import {
|
||||
} from '@/services/admin'
|
||||
import {
|
||||
clearAdminSession,
|
||||
expireAdminSession,
|
||||
getAdminDisplayName,
|
||||
getAdminRole,
|
||||
getAdminSessionExpiryDelay,
|
||||
getAdminToken,
|
||||
getAdminTokenExpiresAt,
|
||||
getAdminUsername,
|
||||
isAdminSessionStorageKey,
|
||||
setAdminDisplayName,
|
||||
setAdminPermissions,
|
||||
} from '@/utils/admin-auth'
|
||||
@@ -79,7 +82,7 @@ export default function AdminLayout() {
|
||||
)
|
||||
const role = getAdminRole()
|
||||
const username = getAdminUsername() || 'admin'
|
||||
const expiresAt = getAdminTokenExpiresAt()
|
||||
const [expiresAt, setExpiresAt] = useState(() => getAdminTokenExpiresAt())
|
||||
const [soundEnabled, setSoundEnabled] = useState(
|
||||
() => window.localStorage.getItem(NOTIFICATION_SOUND_KEY) === '1',
|
||||
)
|
||||
@@ -98,6 +101,43 @@ export default function AdminLayout() {
|
||||
}
|
||||
}, [adminSessionQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
const navigateToLogin = () => {
|
||||
if (!window.location.hash.startsWith('#/admin/login')) {
|
||||
void navigate('/admin/login', { replace: true })
|
||||
}
|
||||
}
|
||||
const handleSessionExpired = () => navigateToLogin()
|
||||
const handleSessionRenewed = () => setExpiresAt(getAdminTokenExpiresAt())
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (!isAdminSessionStorageKey(event.key)) return
|
||||
if (event.newValue === null) {
|
||||
expireAdminSession()
|
||||
navigateToLogin()
|
||||
return
|
||||
}
|
||||
handleSessionRenewed()
|
||||
}
|
||||
|
||||
window.addEventListener('admin-session-expired', handleSessionExpired)
|
||||
window.addEventListener('admin-session-renewed', handleSessionRenewed)
|
||||
window.addEventListener('storage', handleStorage)
|
||||
return () => {
|
||||
window.removeEventListener('admin-session-expired', handleSessionExpired)
|
||||
window.removeEventListener('admin-session-renewed', handleSessionRenewed)
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const delay = getAdminSessionExpiryDelay(expiresAt)
|
||||
if (delay === null) return
|
||||
const timer = window.setTimeout(() => {
|
||||
expireAdminSession()
|
||||
}, delay)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [expiresAt])
|
||||
|
||||
const displayName =
|
||||
adminSessionQuery.data?.data.user?.displayName || getAdminDisplayName() || username
|
||||
|
||||
@@ -123,7 +163,7 @@ export default function AdminLayout() {
|
||||
onEvent: (event) => applyAdminRealtimeEvent(event, queryClient),
|
||||
// 会话过期时清登录态回登录页,避免 SSE 带着失效 token 无限重连刷 401。
|
||||
onAuthExpired: () => {
|
||||
clearAdminSession()
|
||||
expireAdminSession()
|
||||
void navigate('/admin/login', { replace: true, state: { from: location } })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { App, Button, Layout, Menu, Typography } from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { fetchWorkerAfterSalesCases, fetchWorkerProfile, logoutWorker } from '@/services/worker'
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getWorkerToken,
|
||||
getWorkerTokenExpiresAt,
|
||||
getWorkerUsername,
|
||||
isWorkerSessionExpiryKey,
|
||||
isWorkerSessionStorageKey,
|
||||
} from '@/utils/worker-auth'
|
||||
import { useRealtimeEvents, type RealtimeEvent } from '@/hooks/useRealtimeEvents'
|
||||
@@ -100,6 +101,8 @@ export default function WorkerLayout() {
|
||||
[afterSalesPendingCount],
|
||||
)
|
||||
|
||||
const [sessionExpiresAt, setSessionExpiresAt] = useState(() => getWorkerTokenExpiresAt())
|
||||
|
||||
useEffect(() => {
|
||||
const navigateToLogin = () => {
|
||||
if (window.location.pathname !== '/') {
|
||||
@@ -107,30 +110,38 @@ export default function WorkerLayout() {
|
||||
}
|
||||
}
|
||||
const handleSessionExpired = () => navigateToLogin()
|
||||
const handleSessionRenewed = () => setSessionExpiresAt(getWorkerTokenExpiresAt())
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (isWorkerSessionStorageKey(event.key) && event.newValue === null) {
|
||||
if (!isWorkerSessionStorageKey(event.key)) return
|
||||
if (event.newValue === null) {
|
||||
expireWorkerSession()
|
||||
navigateToLogin()
|
||||
return
|
||||
}
|
||||
if (isWorkerSessionExpiryKey(event.key)) {
|
||||
handleSessionRenewed()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('worker-session-expired', handleSessionExpired)
|
||||
window.addEventListener('worker-session-renewed', handleSessionRenewed)
|
||||
window.addEventListener('storage', handleStorage)
|
||||
return () => {
|
||||
window.removeEventListener('worker-session-expired', handleSessionExpired)
|
||||
window.removeEventListener('worker-session-renewed', handleSessionRenewed)
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const delay = getWorkerSessionExpiryDelay(getWorkerTokenExpiresAt())
|
||||
const delay = getWorkerSessionExpiryDelay(sessionExpiresAt)
|
||||
if (delay === null) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
expireWorkerSession()
|
||||
}, delay)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [])
|
||||
}, [sessionExpiresAt])
|
||||
|
||||
useRealtimeEvents({
|
||||
url: '/api/v1/worker/realtime',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
|
||||
import { expireAdminSession, getAdminToken, setAdminSessionExpiresAt } from '@/utils/admin-auth'
|
||||
import { isWorkerSite } from '@/utils/site'
|
||||
import { expireWorkerSession, getWorkerToken } from '@/utils/worker-auth'
|
||||
import { expireWorkerSession, getWorkerToken, setWorkerSessionExpiresAt } from '@/utils/worker-auth'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
@@ -20,6 +20,7 @@ const http = axios.create({
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
applySessionExpiryHeaders(response)
|
||||
const data = response.data as ApiEnvelope<unknown>
|
||||
|
||||
// Business-level error: HTTP 200 but code !== 0
|
||||
@@ -36,6 +37,7 @@ http.interceptors.response.use(
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
applySessionExpiryHeaders(error?.response)
|
||||
const responseMessage =
|
||||
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
||||
const fallbackMessage = resolveFallbackHttpMessage(error)
|
||||
@@ -57,7 +59,7 @@ http.interceptors.response.use(
|
||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||
|
||||
if (status === 401 && shouldClearAdminSession(errorCode)) {
|
||||
clearAdminSession()
|
||||
expireAdminSession()
|
||||
|
||||
if (
|
||||
window.location.hash.startsWith('#/admin') &&
|
||||
@@ -105,6 +107,37 @@ http.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
/**
|
||||
* 服务端每次校验会话都会回传滑动续期后的空闲到期时间;
|
||||
* 这里同步到 localStorage,WorkerLayout / AdminLayout 的定时器会自动重排。
|
||||
*/
|
||||
function applySessionExpiryHeaders(
|
||||
response:
|
||||
| {
|
||||
config?: { url?: string }
|
||||
headers?: Record<string, unknown> & { get?: (name: string) => unknown }
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
if (!response) return
|
||||
const url = String(response.config?.url || '')
|
||||
if (!url.startsWith('/api/v1/admin') && !url.startsWith('/api/v1/worker')) return
|
||||
|
||||
const headers = response.headers
|
||||
const raw =
|
||||
typeof headers?.get === 'function'
|
||||
? headers.get('x-session-expires-at')
|
||||
: headers?.['x-session-expires-at']
|
||||
const expiresAt = String(raw || '').trim()
|
||||
if (!expiresAt) return
|
||||
|
||||
if (url.startsWith('/api/v1/admin')) {
|
||||
setAdminSessionExpiresAt(expiresAt)
|
||||
} else {
|
||||
setWorkerSessionExpiresAt(expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFallbackHttpMessage(error: unknown) {
|
||||
const message = String((error as { message?: string })?.message ?? '')
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export function fetchWorkerSession() {
|
||||
return apiGet<{
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
absoluteExpiresAt?: string
|
||||
worker: WorkerUser
|
||||
}>('/api/v1/worker/auth/session')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export type AdminUserStatus = 'active' | 'disabled'
|
||||
export interface AdminLoginResponse {
|
||||
token: string
|
||||
expiresAt: string
|
||||
absoluteExpiresAt?: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
@@ -41,6 +42,7 @@ export interface AdminDeviceStatusResponse {
|
||||
export interface AdminSessionSummary {
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
absoluteExpiresAt?: string
|
||||
user: {
|
||||
userId: number
|
||||
username: string
|
||||
|
||||
@@ -721,6 +721,7 @@ export type Pagination = {
|
||||
export type WorkerLoginResponse = {
|
||||
token: string
|
||||
expiresAt: string
|
||||
absoluteExpiresAt?: string
|
||||
worker: WorkerUser
|
||||
passwordWeak?: boolean
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ const ADMIN_DISPLAY_NAME_KEY = 'order-site-admin-display-name'
|
||||
const ADMIN_ROLE_KEY = 'order-site-admin-role'
|
||||
const ADMIN_PERMISSIONS_KEY = 'order-site-admin-permissions'
|
||||
|
||||
const ADMIN_SESSION_STORAGE_KEYS = new Set([
|
||||
ADMIN_TOKEN_KEY,
|
||||
ADMIN_EXPIRES_AT_KEY,
|
||||
ADMIN_USER_ID_KEY,
|
||||
ADMIN_USERNAME_KEY,
|
||||
ADMIN_DISPLAY_NAME_KEY,
|
||||
ADMIN_ROLE_KEY,
|
||||
ADMIN_PERMISSIONS_KEY,
|
||||
])
|
||||
|
||||
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin' | 'finance', number> = {
|
||||
support: 1,
|
||||
operator: 2,
|
||||
@@ -143,6 +153,37 @@ export function clearAdminSession() {
|
||||
localStorage.removeItem(ADMIN_PERMISSIONS_KEY)
|
||||
}
|
||||
|
||||
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
|
||||
export function setAdminSessionExpiresAt(expiresAt: string) {
|
||||
const normalized = String(expiresAt || '').trim()
|
||||
if (!normalized || normalized === getAdminTokenExpiresAt()) return
|
||||
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, normalized)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('admin-session-renewed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
||||
export function expireAdminSession() {
|
||||
const hadSession = Boolean(getAdminToken() || getAdminTokenExpiresAt())
|
||||
clearAdminSession()
|
||||
if (hadSession && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('admin-session-expired'))
|
||||
}
|
||||
return hadSession
|
||||
}
|
||||
|
||||
export function isAdminSessionStorageKey(key: string | null) {
|
||||
return Boolean(key && ADMIN_SESSION_STORAGE_KEYS.has(key))
|
||||
}
|
||||
|
||||
export function getAdminSessionExpiryDelay(expiresAt: string, now = Date.now()) {
|
||||
if (!expiresAt) return null
|
||||
const expiresAtMs = Date.parse(expiresAt)
|
||||
if (!Number.isFinite(expiresAtMs)) return null
|
||||
return Math.max(0, expiresAtMs - now)
|
||||
}
|
||||
|
||||
export function hasAdminSession() {
|
||||
const token = getAdminToken()
|
||||
const expiresAt = getAdminTokenExpiresAt()
|
||||
|
||||
@@ -73,6 +73,16 @@ export function clearWorkerSession() {
|
||||
localStorage.removeItem(WORKER_STATUS_KEY)
|
||||
}
|
||||
|
||||
/** 滑动续期:只更新本地空闲到期时间,Token 本身不重签(exp 是绝对上限)。 */
|
||||
export function setWorkerSessionExpiresAt(expiresAt: string) {
|
||||
const normalized = String(expiresAt || '').trim()
|
||||
if (!normalized || normalized === getWorkerTokenExpiresAt()) return
|
||||
localStorage.setItem(WORKER_EXPIRES_AT_KEY, normalized)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('worker-session-renewed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理失效会话并通知当前标签页,避免并发请求各自重复跳转。 */
|
||||
export function expireWorkerSession() {
|
||||
const hadSession = Boolean(getWorkerToken() || getWorkerTokenExpiresAt())
|
||||
@@ -87,6 +97,10 @@ export function isWorkerSessionStorageKey(key: string | null) {
|
||||
return Boolean(key && WORKER_SESSION_STORAGE_KEYS.has(key))
|
||||
}
|
||||
|
||||
export function isWorkerSessionExpiryKey(key: string | null) {
|
||||
return key === WORKER_EXPIRES_AT_KEY
|
||||
}
|
||||
|
||||
export function getWorkerSessionExpiryDelay(expiresAt: string, now = Date.now()) {
|
||||
if (!expiresAt) return null
|
||||
const expiresAtMs = Date.parse(expiresAt)
|
||||
|
||||
@@ -163,17 +163,63 @@ export function patchWorkerWorkOrderListEnvelope(
|
||||
}
|
||||
}
|
||||
|
||||
/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新。 */
|
||||
function readWorkOrderId(item: unknown): number {
|
||||
return isRecord(item) ? toNumber(item.workOrderId) : 0
|
||||
}
|
||||
|
||||
function readPinnedAtMs(item: unknown): number | null {
|
||||
if (!isRecord(item)) return null
|
||||
const pinnedAt = String(item.pinnedAt || '').trim()
|
||||
if (!pinnedAt) return null
|
||||
const parsed = Date.parse(pinnedAt)
|
||||
// 有置顶标记但时间不可解析时仍算置顶,宁可排前面也不能被普通单挤下去。
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 大厅排序必须与服务端一致:置顶单永远在最前(pinned_at DESC),其余按 id DESC。
|
||||
* 实时事件把新单插入第 1 页后必须重排,否则新单会插到置顶单上面。
|
||||
*/
|
||||
export function sortWorkerHallItems(items: unknown[]): unknown[] {
|
||||
return items.slice().sort((left, right) => {
|
||||
const leftPinnedAt = readPinnedAtMs(left)
|
||||
const rightPinnedAt = readPinnedAtMs(right)
|
||||
if (leftPinnedAt !== null && rightPinnedAt === null) return -1
|
||||
if (leftPinnedAt === null && rightPinnedAt !== null) return 1
|
||||
if (leftPinnedAt !== null && rightPinnedAt !== null && leftPinnedAt !== rightPinnedAt) {
|
||||
return rightPinnedAt - leftPinnedAt
|
||||
}
|
||||
return readWorkOrderId(right) - readWorkOrderId(left)
|
||||
})
|
||||
}
|
||||
|
||||
/** 无限下拉(大厅):第 1 页允许插入,其余页只做就地更新;最终按置顶优先重排。 */
|
||||
export function patchWorkerWorkOrderInfiniteEnvelope(
|
||||
current: unknown,
|
||||
event: WorkerListEvent,
|
||||
insert = false,
|
||||
): unknown {
|
||||
if (!isRecord(current) || !Array.isArray(current.pages)) return current
|
||||
return {
|
||||
...current,
|
||||
pages: current.pages.map((page: unknown, index: number) =>
|
||||
patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0),
|
||||
),
|
||||
const pages = current.pages.map((page: unknown, index: number) =>
|
||||
patchWorkerWorkOrderListEnvelope(page, event, insert && index === 0),
|
||||
)
|
||||
|
||||
const lists: unknown[][] = []
|
||||
for (const page of pages) {
|
||||
if (!isRecord(page) || !isRecord(page.data) || !Array.isArray(page.data.items)) {
|
||||
return { ...current, pages }
|
||||
}
|
||||
lists.push(page.data.items)
|
||||
}
|
||||
|
||||
// 第 2 页之后被置顶的单也要提到第 1 页最前,所以跨页统一排序再按原页长度切回。
|
||||
const sorted = sortWorkerHallItems(lists.flat())
|
||||
let cursor = 0
|
||||
const sortedPages = pages.map((page, index) => {
|
||||
const length = lists[index]?.length || 0
|
||||
const items = sorted.slice(cursor, cursor + length)
|
||||
cursor += length
|
||||
return isRecord(page) && isRecord(page.data) ? { ...page, data: { ...page.data, items } } : page
|
||||
})
|
||||
return { ...current, pages: sortedPages }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user