diff --git a/Jenkinsfile b/Jenkinsfile
index 6f0ae53c..8cec3b3b 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,12 +1,15 @@
-def sendProductionWeComNotification(String status, String color, String detail) {
+def sendProductionWeComNotification(String title, String status, String color, String detail) {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
- String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—'
- String content = """## 生产部署通知
-> 项目:${env.JOB_NAME}
+ String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
+ String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
+ String content = """## ${title}
+> 应用:管理后台
+> Job:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:${status}
-> 详情:${detail}
+${detail}
+> 耗时:${duration}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
@@ -123,13 +126,6 @@ pipeline {
}
}
- stage('确认部署生产前端') {
- when { expression { env.IS_PRODUCTION == 'true' } }
- steps {
- input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 admin.buggoglobal.com?", ok: '确认部署'
- }
- }
-
stage('部署生产前端') {
when { expression { env.IS_PRODUCTION == 'true' } }
steps {
@@ -199,7 +195,12 @@ pipeline {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
- sendProductionWeComNotification('部署成功', 'info', 'Admin 生产前端已完成发布和部署检查')
+ sendProductionWeComNotification(
+ '✅ 生产部署成功',
+ '成功',
+ 'info',
+ '> 结果:Admin 生产前端已完成发布和部署流程'
+ )
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
@@ -219,7 +220,12 @@ pipeline {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
- sendProductionWeComNotification('部署失败', 'warning', '请打开 Jenkins 构建日志查看失败阶段')
+ sendProductionWeComNotification(
+ '❌ 生产发布失败',
+ '失败',
+ 'warning',
+ '> 说明:请打开 Jenkins 构建日志查看失败阶段'
+ )
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
@@ -230,7 +236,12 @@ pipeline {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
- sendProductionWeComNotification('部署已取消', 'comment', '生产部署流程被人工取消或中止')
+ sendProductionWeComNotification(
+ '⏹ 生产部署已取消',
+ '已取消',
+ 'comment',
+ '> 说明:生产部署流程被人工取消或中止'
+ )
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
diff --git a/README.md b/README.md
index ab44a2fa..6384a94d 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,14 @@ pnpm build
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/`:应用壳层、根路由和启动装配。
diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts
index ee88a86c..a1ae3425 100644
--- a/src/core/auth/auth.test.ts
+++ b/src/core/auth/auth.test.ts
@@ -184,29 +184,17 @@ describe('admin auth session', () => {
expect(auth.isAuthenticated).toBe(true)
})
- it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
+ it('reuses a valid persisted session without blocking reload on refresh', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
- const refreshedToken = token({
- token_version: 2,
- })
- mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
- mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
- 'platform:warehouse:list',
- 'platform:firstleg:inbound:list',
- ])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
- expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
- expect([...auth.permissionCodes]).toEqual([
- 'platform:warehouse:list',
- 'platform:firstleg:inbound:list',
- ])
- expect(localStorage.getItem('cc-admin-permission-codes'))
- .toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
+ expect(mocks.post).not.toHaveBeenCalled()
+ expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
+ expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
})
it('reuses a session rotated by another tab without refreshing it again', async () => {
diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts
index 6d995ae4..56510cd7 100644
--- a/src/core/auth/auth.ts
+++ b/src/core/auth/auth.ts
@@ -224,15 +224,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function restoreSession() {
- if (isAuthenticated.value) {
- if (!refreshToken.value) return true
- try {
- await refreshSession()
- return true
- } catch {
- return false
- }
- }
+ // The constructor has already validated a persisted access token. Reusing
+ // a still-valid token lets the app mount immediately; refresh only remains
+ // necessary after the access token has expired and the constructor has
+ // retained the persisted refresh token for recovery.
+ if (isAuthenticated.value) return true
if (!refreshToken.value) return false
try {
await refreshSession()
diff --git a/src/core/routing/navigationRecovery.test.ts b/src/core/routing/navigationRecovery.test.ts
index fac05a03..3a145008 100644
--- a/src/core/routing/navigationRecovery.test.ts
+++ b/src/core/routing/navigationRecovery.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import {
- clearNavigationRecoveryAttempt,
+ createNavigationRecoveryHandler,
+ installAssetRecoveryListeners,
isStaleRouteAssetError,
recoverNavigationError,
+ reloadNavigationTarget,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial = {}) {
}
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, 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, '/orders?page=3#details')
+ expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
+ expect(location.reload).not.toHaveBeenCalled()
+ })
+
it.each([
'Failed to fetch dynamically imported module: /assets/page.js',
'Importing a module script failed',
'ChunkLoadError: Loading chunk 42 failed',
'Loading CSS chunk page failed',
+ 'Unable to preload CSS for /assets/page-123.css',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
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 deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
await recoverNavigationError(error, '/system/users', deps)
expect(deps.navigate).toHaveBeenCalledOnce()
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
- clearNavigationRecoveryAttempt(storage)
- await recoverNavigationError(error, '/system/users', deps)
- expect(deps.navigate).toHaveBeenCalledTimes(2)
+ const reloaded = dependencies({ storage })
+ await recoverNavigationError(error, '/system/redirect', reloaded)
+ 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 () => {
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
expect(deps.navigate).not.toHaveBeenCalled()
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((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()
+ }
+ })
})
diff --git a/src/core/routing/navigationRecovery.ts b/src/core/routing/navigationRecovery.ts
index 9852ab18..1fe50e22 100644
--- a/src/core/routing/navigationRecovery.ts
+++ b/src/core/routing/navigationRecovery.ts
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
+ /unable to preload css for/i,
]
export interface NavigationRecoveryDependencies {
- storage: Pick
+ storage: Pick
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
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(
error: unknown,
target: string,
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
dependencies.showError('页面切换失败,请重试')
return false
}
- if (recentAttempt(dependencies, target)) {
- dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
+ // Keep this marker across successful routing and full reloads. A redirect to
+ // 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
}
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
- dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
- target,
- attemptedAt: dependencies.now(),
- } satisfies RecoveryAttempt))
+ try {
+ dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
+ target,
+ attemptedAt: dependencies.now(),
+ } satisfies RecoveryAttempt))
+ } catch {
+ dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
+ return false
+ }
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
-export function clearNavigationRecoveryAttempt(storage: Pick) {
- storage.removeItem(RECOVERY_STORAGE_KEY)
+export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
+ let handledErrors = new WeakSet