From 7e3cd934259b0047fe88ebbf081a4cc56c89cbcc Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 18 Sep 2026 18:50:53 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Jenkinsfile | 41 ++++++++++++++++++++++++++---------------
1 file changed, 26 insertions(+), 15 deletions(-)
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}"
}
--
2.54.0
From 3475cdfe499a81ee31b70532ef04d9b198edd03d Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Sat, 19 Sep 2026 17:02:17 +0800
Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 8 ++
src/core/routing/navigationRecovery.test.ts | 113 +++++++++++++++++-
src/core/routing/navigationRecovery.ts | 105 ++++++++++++----
src/main.ts | 63 ++++++----
src/modules/finance/api/finance.ts | 2 +-
.../pages/FinanceCustomerAccountsView.vue | 6 +-
src/modules/finance/types.ts | 1 -
7 files changed, 243 insertions(+), 55 deletions(-)
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/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