This commit is contained in:
2026-09-19 17:02:17 +08:00
parent 7e3cd93425
commit 3475cdfe49
7 changed files with 243 additions and 55 deletions
+8
View File
@@ -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/`:应用壳层、根路由和启动装配。
+108 -5
View File
@@ -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()
}
})
}) })
+85 -20
View File
@@ -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
View File
@@ -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')
+1 -1
View File
@@ -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">
-1
View File
@@ -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