优化
This commit is contained in:
@@ -23,6 +23,14 @@ pnpm build
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 发布后的页面资源恢复
|
||||||
|
|
||||||
|
`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。
|
||||||
|
|
||||||
|
刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。
|
||||||
|
|
||||||
|
该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。
|
||||||
|
|
||||||
## 源码结构
|
## 源码结构
|
||||||
|
|
||||||
- `src/app/`:应用壳层、根路由和启动装配。
|
- `src/app/`:应用壳层、根路由和启动装配。
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
clearNavigationRecoveryAttempt,
|
createNavigationRecoveryHandler,
|
||||||
|
installAssetRecoveryListeners,
|
||||||
isStaleRouteAssetError,
|
isStaleRouteAssetError,
|
||||||
recoverNavigationError,
|
recoverNavigationError,
|
||||||
|
reloadNavigationTarget,
|
||||||
type NavigationRecoveryDependencies,
|
type NavigationRecoveryDependencies,
|
||||||
} from './navigationRecovery'
|
} from './navigationRecovery'
|
||||||
|
|
||||||
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('navigationRecovery', () => {
|
describe('navigationRecovery', () => {
|
||||||
|
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
|
||||||
|
'forces a full reload for an async component failure on the current document: %s', (target) => {
|
||||||
|
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
|
||||||
|
const history = { state: { position: 2 }, replaceState: vi.fn() }
|
||||||
|
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
|
||||||
|
expect(location.reload).toHaveBeenCalledOnce()
|
||||||
|
expect(location.assign).not.toHaveBeenCalled()
|
||||||
|
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('loads the intended new route through a document navigation', () => {
|
||||||
|
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
|
||||||
|
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
|
||||||
|
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
|
||||||
|
expect(location.reload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'Failed to fetch dynamically imported module: /assets/page.js',
|
'Failed to fetch dynamically imported module: /assets/page.js',
|
||||||
'Importing a module script failed',
|
'Importing a module script failed',
|
||||||
'ChunkLoadError: Loading chunk 42 failed',
|
'ChunkLoadError: Loading chunk 42 failed',
|
||||||
'Loading CSS chunk page failed',
|
'Loading CSS chunk page failed',
|
||||||
|
'Unable to preload CSS for /assets/page-123.css',
|
||||||
])('recognizes stale deployed route assets: %s', (message) => {
|
])('recognizes stale deployed route assets: %s', (message) => {
|
||||||
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||||
})
|
})
|
||||||
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
|
|||||||
expect(deps.navigate).not.toHaveBeenCalled()
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
|
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
|
||||||
const storage = memoryStorage()
|
const storage = memoryStorage()
|
||||||
const deps = dependencies({ storage })
|
const deps = dependencies({ storage })
|
||||||
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||||
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
|
|||||||
await recoverNavigationError(error, '/system/users', deps)
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
clearNavigationRecoveryAttempt(storage)
|
const reloaded = dependencies({ storage })
|
||||||
await recoverNavigationError(error, '/system/users', deps)
|
await recoverNavigationError(error, '/system/redirect', reloaded)
|
||||||
expect(deps.navigate).toHaveBeenCalledTimes(2)
|
expect(reloaded.navigate).not.toHaveBeenCalled()
|
||||||
|
reloaded.now = () => 31_000
|
||||||
|
await recoverNavigationError(error, '/system/users', reloaded)
|
||||||
|
expect(reloaded.navigate).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports ordinary router failures without reloading the application', async () => {
|
it('reports ordinary router failures without reloading the application', async () => {
|
||||||
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
|
|||||||
expect(deps.navigate).not.toHaveBeenCalled()
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
storage[method] = () => { throw new Error('Storage unavailable') }
|
||||||
|
const deps = dependencies({ storage })
|
||||||
|
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the target query and hash after the user confirms discarding work', async () => {
|
||||||
|
const deps = dependencies({ hasUnsavedWork: () => true })
|
||||||
|
const target = '/orders?status=NEW&page=3#details'
|
||||||
|
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.navigate).toHaveBeenCalledWith(target)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
|
||||||
|
let finishConfirmation!: (value: boolean) => void
|
||||||
|
const deps = dependencies({
|
||||||
|
hasUnsavedWork: () => true,
|
||||||
|
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
|
||||||
|
})
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const cachedError = new Error('Importing a module script failed')
|
||||||
|
const first = recover(cachedError, '/orders')
|
||||||
|
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
finishConfirmation(false)
|
||||||
|
await Promise.all([first, duplicate])
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
deps.confirmDiscard = vi.fn(async () => true)
|
||||||
|
await recover(cachedError, '/orders')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates the same error reported by Vite and the router', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const error = new Error('Importing a module script failed')
|
||||||
|
await recover(error, '/orders')
|
||||||
|
await recover(error, '/orders')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.showError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
|
||||||
|
try {
|
||||||
|
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
|
||||||
|
payload: new Error('Cannot read properties of undefined'),
|
||||||
|
})
|
||||||
|
window.dispatchEvent(ordinary)
|
||||||
|
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
|
||||||
|
reason: new Error('Request failed with status code 500'),
|
||||||
|
})
|
||||||
|
window.dispatchEvent(apiError)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(apiError.defaultPrevented).toBe(false)
|
||||||
|
|
||||||
|
const error = new Error('Unable to preload CSS for /assets/page.css')
|
||||||
|
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
|
||||||
|
window.dispatchEvent(preload)
|
||||||
|
expect(preload.defaultPrevented).toBe(false)
|
||||||
|
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
|
||||||
|
window.dispatchEvent(rejection)
|
||||||
|
expect(rejection.defaultPrevented).toBe(true)
|
||||||
|
await recover(error, '/orders?page=2')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
|
||||||
|
} finally {
|
||||||
|
removeListeners()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
|
|||||||
/importing a module script failed/i,
|
/importing a module script failed/i,
|
||||||
/chunkloaderror/i,
|
/chunkloaderror/i,
|
||||||
/loading (css )?chunk [\w-]+ failed/i,
|
/loading (css )?chunk [\w-]+ failed/i,
|
||||||
|
/unable to preload css for/i,
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface NavigationRecoveryDependencies {
|
export interface NavigationRecoveryDependencies {
|
||||||
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
storage: Pick<Storage, 'getItem' | 'setItem'>
|
||||||
now: () => number
|
now: () => number
|
||||||
hasUnsavedWork: () => boolean
|
hasUnsavedWork: () => boolean
|
||||||
confirmDiscard: () => Promise<boolean>
|
confirmDiscard: () => Promise<boolean>
|
||||||
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
|
|||||||
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
|
|
||||||
return Boolean(parsed
|
|
||||||
&& parsed.target === target
|
|
||||||
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recoverNavigationError(
|
export async function recoverNavigationError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
target: string,
|
target: string,
|
||||||
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
|
|||||||
dependencies.showError('页面切换失败,请重试')
|
dependencies.showError('页面切换失败,请重试')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (recentAttempt(dependencies, target)) {
|
// Keep this marker across successful routing and full reloads. A redirect to
|
||||||
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
// another route must not bypass the cooldown either.
|
||||||
|
try {
|
||||||
|
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
|
||||||
|
let previous: RecoveryAttempt | null = null
|
||||||
|
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
|
||||||
|
if (previous && Number.isFinite(previous.attemptedAt)
|
||||||
|
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
|
||||||
|
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
||||||
|
|
||||||
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
try {
|
||||||
target,
|
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||||
attemptedAt: dependencies.now(),
|
target,
|
||||||
} satisfies RecoveryAttempt))
|
attemptedAt: dependencies.now(),
|
||||||
|
} satisfies RecoveryAttempt))
|
||||||
|
} catch {
|
||||||
|
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||||
|
return false
|
||||||
|
}
|
||||||
dependencies.discardUnsavedWork()
|
dependencies.discardUnsavedWork()
|
||||||
dependencies.navigate(target)
|
dependencies.navigate(target)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
|
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
|
||||||
storage.removeItem(RECOVERY_STORAGE_KEY)
|
let handledErrors = new WeakSet<object>()
|
||||||
|
let pending: Promise<boolean> | undefined
|
||||||
|
const guardedDependencies = {
|
||||||
|
...dependencies,
|
||||||
|
confirmDiscard: async () => {
|
||||||
|
const confirmed = await dependencies.confirmDiscard()
|
||||||
|
// Some importers cache the rejected promise/error. Cancelling must not
|
||||||
|
// prevent a later explicit retry from asking again.
|
||||||
|
if (!confirmed) handledErrors = new WeakSet<object>()
|
||||||
|
return confirmed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return (error: unknown, target: string): Promise<boolean> => {
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
|
||||||
|
handledErrors.add(error)
|
||||||
|
}
|
||||||
|
if (pending) return pending
|
||||||
|
// Defer execution so simultaneous router, Vite and Vue errors share one
|
||||||
|
// confirmation/reload, even when there is no unsaved form.
|
||||||
|
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
|
||||||
|
.finally(() => { pending = undefined })
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
|
||||||
|
const current = new URL(browser.location.href)
|
||||||
|
const destination = new URL(target, current)
|
||||||
|
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||||
|
// Assigning a URL that only changes the fragment does not reload assets.
|
||||||
|
browser.history.replaceState(browser.history.state, '', destination.href)
|
||||||
|
browser.location.reload()
|
||||||
|
} else {
|
||||||
|
browser.location.assign(destination.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installAssetRecoveryListeners(
|
||||||
|
browser: Window,
|
||||||
|
target: () => string,
|
||||||
|
recover: (error: unknown, target: string) => Promise<boolean>,
|
||||||
|
) {
|
||||||
|
const onPreloadError = (event: Event) => {
|
||||||
|
const error = (event as Event & { payload?: unknown }).payload
|
||||||
|
if (isStaleRouteAssetError(error)) void recover(error, target())
|
||||||
|
// Let Vite reject the import: swallowing it would make Vue Router treat
|
||||||
|
// an undefined module as a successful navigation, including on cancel.
|
||||||
|
}
|
||||||
|
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
if (!isStaleRouteAssetError(event.reason)) return
|
||||||
|
event.preventDefault()
|
||||||
|
void recover(event.reason, target())
|
||||||
|
}
|
||||||
|
browser.addEventListener('vite:preloadError', onPreloadError)
|
||||||
|
browser.addEventListener('unhandledrejection', onUnhandledRejection)
|
||||||
|
return () => {
|
||||||
|
browser.removeEventListener('vite:preloadError', onPreloadError)
|
||||||
|
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-25
@@ -8,7 +8,7 @@ import 'element-plus/es/components/message-box/style/css'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from '@/app/router'
|
import router from '@/app/router'
|
||||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||||
import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
|
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
@@ -22,8 +22,6 @@ const auth = useAuthStore(pinia)
|
|||||||
const unsaved = useUnsavedWorkStore(pinia)
|
const unsaved = useUnsavedWorkStore(pinia)
|
||||||
const workspace = useWorkspaceTabsStore(pinia)
|
const workspace = useWorkspaceTabsStore(pinia)
|
||||||
|
|
||||||
await auth.restoreSession()
|
|
||||||
|
|
||||||
registerAuthSessionExpiredHandler(async () => {
|
registerAuthSessionExpiredHandler(async () => {
|
||||||
const currentRoute = router.currentRoute.value
|
const currentRoute = router.currentRoute.value
|
||||||
if (currentRoute.name === 'login') return
|
if (currentRoute.name === 'login') return
|
||||||
@@ -39,31 +37,41 @@ registerAuthSessionExpiredHandler(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const recoverAssetError = createNavigationRecoveryHandler({
|
||||||
|
storage: {
|
||||||
|
getItem: (key) => window.sessionStorage.getItem(key),
|
||||||
|
setItem: (key, value) => window.sessionStorage.setItem(key, value),
|
||||||
|
},
|
||||||
|
now: Date.now,
|
||||||
|
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||||
|
confirmDiscard: async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||||
|
'重新加载页面?',
|
||||||
|
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discardUnsavedWork: () => unsaved.clear(),
|
||||||
|
navigate: (target) => reloadNavigationTarget(window, target),
|
||||||
|
showError: (message) => ElMessage.error(message),
|
||||||
|
})
|
||||||
|
let pendingRoute: string | undefined
|
||||||
|
const recoveryTarget = () => pendingRoute
|
||||||
|
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
|
router.beforeEach((to) => { pendingRoute = to.fullPath })
|
||||||
router.onError((error, to) => {
|
router.onError((error, to) => {
|
||||||
void recoverNavigationError(error, to.fullPath, {
|
void recoverAssetError(error, to.fullPath)
|
||||||
storage: sessionStorage,
|
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||||
now: Date.now,
|
|
||||||
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
|
||||||
confirmDiscard: async () => {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
|
||||||
'重新加载页面?',
|
|
||||||
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
discardUnsavedWork: () => unsaved.clear(),
|
|
||||||
navigate: (target) => window.location.assign(target),
|
|
||||||
showError: (message) => ElMessage.error(message),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
router.afterEach((_to, _from, failure) => {
|
router.afterEach((to) => {
|
||||||
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
|
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||||
})
|
})
|
||||||
|
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
|
||||||
|
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||||
@@ -72,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
app.config.errorHandler = (error) => {
|
||||||
|
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
|
||||||
|
else console.error(error)
|
||||||
|
}
|
||||||
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
||||||
|
await auth.restoreSession()
|
||||||
app.use(pinia).use(router).mount('#app')
|
app.use(pinia).use(router).mount('#app')
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
|
|||||||
function normalizeRechargeRequest(item: RawResource): FinanceRechargeRequest {
|
function normalizeRechargeRequest(item: RawResource): FinanceRechargeRequest {
|
||||||
return {
|
return {
|
||||||
requestId: stringValue(item.requestId), requestNo: stringValue(item.requestNo), amount: stringValue(item.amount),
|
requestId: stringValue(item.requestId), requestNo: stringValue(item.requestNo), amount: stringValue(item.amount),
|
||||||
paymentMethod: stringValue(item.paymentMethod), voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName),
|
voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName),
|
||||||
status: stringValue(item.status), version: numberValue(item.version), submittedByName: stringValue(item.submittedByName),
|
status: stringValue(item.status), version: numberValue(item.version), submittedByName: stringValue(item.submittedByName),
|
||||||
reviewedByName: stringValue(item.reviewedByName), reviewRemark: stringValue(item.reviewRemark), createdAt: stringValue(item.createdAt),
|
reviewedByName: stringValue(item.reviewedByName), reviewRemark: stringValue(item.reviewRemark), createdAt: stringValue(item.createdAt),
|
||||||
reviewedAt: stringValue(item.reviewedAt),
|
reviewedAt: stringValue(item.reviewedAt),
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const ledgerColumns: DataTableColumn[] = [
|
|||||||
const rechargeColumns: DataTableColumn[] = [
|
const rechargeColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
||||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'paymentMethod', label: '付款方式', width: 130, slotName: 'paymentMethod' },
|
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
|
||||||
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
|
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
|
||||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
||||||
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
|
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
|
||||||
@@ -465,7 +465,7 @@ onBeforeUnmount(clearVoucherPreview)
|
|||||||
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
|
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
|
||||||
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
|
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
|
||||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
|
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
|
||||||
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template><template #paymentMethod="{ row }">{{ paymentMethodLabel(row.paymentMethod) }}</template>
|
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
|
||||||
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
|
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
|
||||||
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
|
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
|
||||||
</DataTable></div>
|
</DataTable></div>
|
||||||
@@ -489,7 +489,7 @@ onBeforeUnmount(clearVoucherPreview)
|
|||||||
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||||
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||||
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
|
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
|
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
|
||||||
|
|||||||
@@ -263,7 +263,6 @@ export interface FinanceRechargeRequest {
|
|||||||
requestId: string
|
requestId: string
|
||||||
requestNo: string
|
requestNo: string
|
||||||
amount: string
|
amount: string
|
||||||
paymentMethod: string
|
|
||||||
voucherFileId: string
|
voucherFileId: string
|
||||||
voucherFileName: string
|
voucherFileName: string
|
||||||
status: string
|
status: string
|
||||||
|
|||||||
Reference in New Issue
Block a user