719 lines
22 KiB
Markdown
719 lines
22 KiB
Markdown
# web-admin 发版后强制刷新 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 生产环境的 web-admin 在仍打开旧标签页时,发现新构建就弹出不可关闭的「立即刷新」。
|
||
|
||
**Architecture:** 每次 `pnpm build` 用「短 git hash + 构建时间戳」生成 `buildId`,注入 `__APP_BUILD_ID__` 并写入 `dist/version.json`。运行时只在生产环境拉取该文件(`cache: 'no-store'` + `?t=`),`buildId` 不同则 `ElMessageBox.alert` 强制刷新。纯判断与调度放在可单测模块;浏览器和弹窗只在很薄的启动函数里接线。
|
||
|
||
**Tech Stack:** Vite 7 · Vue 3 · Element Plus `ElMessageBox`(`src/types/import/auto-imports.d.ts` 已全局声明,`.ts` 里不要手写 import)· Node test(`pnpm exec tsx --test`)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-16-web-admin-version-refresh-design.md`
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 路径 | 职责 |
|
||
|------|------|
|
||
| `web-admin/scripts/app-build-meta.ts` | 生成 `buildId`、拼 `version.json` 文本、写入 `outDir/version.json` |
|
||
| `web-admin/scripts/app-build-meta.test.ts` | 上述纯函数与写文件 |
|
||
| `web-admin/src/utils/app-update.ts` | URL、是否提示、文案、30 秒防抖、调度器(依赖注入,无 DOM) |
|
||
| `web-admin/src/utils/app-update.test.ts` | 调度器与文案 |
|
||
| `web-admin/src/utils/check-app-update.ts` | 生产环境接线:`fetch`、`ElMessageBox`、`location.reload`、可见性 |
|
||
| `web-admin/src/env.d.ts` | 声明 `__APP_BUILD_ID__` |
|
||
| `web-admin/vite.config.ts` | `define` 注入 `buildId`;`apply: 'build'` 插件写出 `dist/version.json` |
|
||
| `web-admin/src/main.ts` | `app.mount` 之后调用 `startAppUpdateCheck()` |
|
||
|
||
**明确不改:** 后端、Nginx 配置、`VITE_VERSION` 的人工升版流程、小程序更新、未保存表单拦截。`index.html` / `version.json` 的短缓存仍由现有发布方式保证(规格第 6 节)。
|
||
|
||
---
|
||
|
||
### Task 1: 构建指纹与 version.json 文本
|
||
|
||
**Files:**
|
||
- Create: `web-admin/scripts/app-build-meta.ts`
|
||
- Test: `web-admin/scripts/app-build-meta.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// web-admin/scripts/app-build-meta.test.ts
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { describe, it } from 'node:test'
|
||
import {
|
||
createAppBuildMeta,
|
||
readGitShort,
|
||
versionJsonText,
|
||
writeVersionJson
|
||
} from './app-build-meta.ts'
|
||
|
||
describe('createAppBuildMeta', () => {
|
||
it('joins git short hash and timestamp', () => {
|
||
assert.deepEqual(createAppBuildMeta('3.0.2', 1730000000000, 'a1b2c3d'), {
|
||
version: '3.0.2',
|
||
buildId: 'a1b2c3d-1730000000000'
|
||
})
|
||
})
|
||
|
||
it('uses nogit when the hash is empty', () => {
|
||
assert.equal(createAppBuildMeta('3.0.2', 5, ' ').buildId, 'nogit-5')
|
||
})
|
||
|
||
it('changes buildId when the timestamp changes', () => {
|
||
const a = createAppBuildMeta('3.0.2', 1, 'abc')
|
||
const b = createAppBuildMeta('3.0.2', 2, 'abc')
|
||
assert.notEqual(a.buildId, b.buildId)
|
||
})
|
||
})
|
||
|
||
describe('readGitShort', () => {
|
||
it('trims command output', () => {
|
||
assert.equal(readGitShort(() => 'abc123\n'), 'abc123')
|
||
})
|
||
|
||
it('returns nogit when the command throws or is blank', () => {
|
||
assert.equal(
|
||
readGitShort(() => {
|
||
throw new Error('no git')
|
||
}),
|
||
'nogit'
|
||
)
|
||
assert.equal(readGitShort(() => ' '), 'nogit')
|
||
})
|
||
})
|
||
|
||
describe('version.json', () => {
|
||
it('writes pretty json with a trailing newline', () => {
|
||
const text = versionJsonText({ version: '3.0.2', buildId: 'abc-1' })
|
||
assert.equal(text, '{\n "version": "3.0.2",\n "buildId": "abc-1"\n}\n')
|
||
})
|
||
|
||
it('writes dist/version.json', () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'app-ver-'))
|
||
writeVersionJson(dir, { version: '3.0.3', buildId: 'def-9' })
|
||
const raw = fs.readFileSync(path.join(dir, 'version.json'), 'utf8')
|
||
assert.deepEqual(JSON.parse(raw), { version: '3.0.3', buildId: 'def-9' })
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd web-admin && pnpm exec tsx --test scripts/app-build-meta.test.ts`
|
||
|
||
Expected: FAIL with cannot find module `./app-build-meta.ts`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```ts
|
||
// web-admin/scripts/app-build-meta.ts
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
|
||
export type AppBuildMeta = {
|
||
version: string
|
||
buildId: string
|
||
}
|
||
|
||
export function readGitShort(run: () => string): string {
|
||
try {
|
||
const text = run().trim()
|
||
return text || 'nogit'
|
||
} catch {
|
||
return 'nogit'
|
||
}
|
||
}
|
||
|
||
export function createAppBuildMeta(version: string, nowMs: number, gitShort: string): AppBuildMeta {
|
||
const hash = gitShort.trim() || 'nogit'
|
||
return { version, buildId: `${hash}-${nowMs}` }
|
||
}
|
||
|
||
export function versionJsonText(meta: AppBuildMeta): string {
|
||
return `${JSON.stringify({ version: meta.version, buildId: meta.buildId }, null, 2)}\n`
|
||
}
|
||
|
||
export function writeVersionJson(outDir: string, meta: AppBuildMeta): void {
|
||
fs.mkdirSync(outDir, { recursive: true })
|
||
fs.writeFileSync(path.join(outDir, 'version.json'), versionJsonText(meta), 'utf8')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd web-admin && pnpm exec tsx --test scripts/app-build-meta.test.ts`
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-admin/scripts/app-build-meta.ts web-admin/scripts/app-build-meta.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
test: add web-admin build id and version.json helpers.
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 更新判断与调度器
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/utils/app-update.ts`
|
||
- Test: `web-admin/src/utils/app-update.test.ts`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
// web-admin/src/utils/app-update.test.ts
|
||
import assert from 'node:assert/strict'
|
||
import { describe, it } from 'node:test'
|
||
import {
|
||
APP_UPDATE_POLL_MS,
|
||
APP_UPDATE_VISIBILITY_DEBOUNCE_MS,
|
||
formatUpdateMessage,
|
||
shouldPromptUpdate,
|
||
shouldSkipVisibilityCheck,
|
||
startAppUpdateChecker,
|
||
versionJsonUrl
|
||
} from './app-update.ts'
|
||
|
||
function flush() {
|
||
return new Promise((resolve) => setImmediate(resolve))
|
||
}
|
||
|
||
describe('versionJsonUrl', () => {
|
||
it('appends a cache-busting query on the base url', () => {
|
||
assert.equal(versionJsonUrl('/', 5), '/version.json?t=5')
|
||
assert.equal(versionJsonUrl('/admin/', 5), '/admin/version.json?t=5')
|
||
assert.equal(versionJsonUrl('/admin', 5), '/admin/version.json?t=5')
|
||
})
|
||
})
|
||
|
||
describe('shouldPromptUpdate', () => {
|
||
it('prompts only when remote buildId is a different non-empty string', () => {
|
||
assert.equal(shouldPromptUpdate('a-1', { buildId: 'b-2' }), true)
|
||
assert.equal(shouldPromptUpdate('a-1', { buildId: 'a-1' }), false)
|
||
assert.equal(shouldPromptUpdate('a-1', { buildId: '' }), false)
|
||
assert.equal(shouldPromptUpdate('a-1', { buildId: 2 }), false)
|
||
assert.equal(shouldPromptUpdate('a-1', {}), false)
|
||
assert.equal(shouldPromptUpdate('a-1', null), false)
|
||
})
|
||
})
|
||
|
||
describe('formatUpdateMessage', () => {
|
||
it('includes escaped versions when remote version is a non-empty string', () => {
|
||
const text = formatUpdateMessage('3.0.2', '3.0.3')
|
||
assert.match(text, /系统已发布更新/)
|
||
assert.match(text, /当前 v3\.0\.2/)
|
||
assert.match(text, /最新 v3\.0\.3/)
|
||
})
|
||
|
||
it('omits the version line when remote version is missing or blank', () => {
|
||
assert.doesNotMatch(formatUpdateMessage('3.0.2', ''), /当前 v/)
|
||
assert.doesNotMatch(formatUpdateMessage('3.0.2', ' '), /当前 v/)
|
||
assert.equal(formatUpdateMessage('3.0.2', null), formatUpdateMessage('3.0.2', undefined))
|
||
})
|
||
|
||
it('escapes html in version strings', () => {
|
||
const text = formatUpdateMessage('3<0', '3>1')
|
||
assert.match(text, /v3<0/)
|
||
assert.match(text, /v3>1/)
|
||
assert.doesNotMatch(text, /v3<0/)
|
||
})
|
||
})
|
||
|
||
describe('shouldSkipVisibilityCheck', () => {
|
||
it('skips only when the gap is under 30 seconds', () => {
|
||
assert.equal(APP_UPDATE_VISIBILITY_DEBOUNCE_MS, 30_000)
|
||
assert.equal(shouldSkipVisibilityCheck(1_000, 1_000 + 29_999), true)
|
||
assert.equal(shouldSkipVisibilityCheck(1_000, 1_000 + 30_000), false)
|
||
})
|
||
})
|
||
|
||
describe('startAppUpdateChecker', () => {
|
||
it('does nothing outside production', async () => {
|
||
let fetches = 0
|
||
const stop = startAppUpdateChecker({
|
||
isProd: false,
|
||
localBuildId: 'a-1',
|
||
localVersion: '3.0.2',
|
||
baseUrl: '/',
|
||
now: () => 1,
|
||
fetchRemote: async () => {
|
||
fetches += 1
|
||
return { buildId: 'b-2', version: '3.0.3' }
|
||
},
|
||
onReload: () => {},
|
||
alertUpdate: async () => {},
|
||
setInterval: () => 1,
|
||
clearInterval: () => {},
|
||
subscribeVisible: () => () => {}
|
||
})
|
||
await flush()
|
||
assert.equal(fetches, 0)
|
||
stop()
|
||
})
|
||
|
||
it('polls every 10 minutes and stays quiet when buildId matches', async () => {
|
||
let fetches = 0
|
||
const timers: Array<{ fn: () => void; ms: number }> = []
|
||
startAppUpdateChecker({
|
||
isProd: true,
|
||
localBuildId: 'a-1',
|
||
localVersion: '3.0.2',
|
||
baseUrl: '/',
|
||
now: () => 10,
|
||
fetchRemote: async (url) => {
|
||
fetches += 1
|
||
assert.equal(url, '/version.json?t=10')
|
||
return { buildId: 'a-1', version: '3.0.2' }
|
||
},
|
||
onReload: () => {},
|
||
alertUpdate: async () => {
|
||
throw new Error('should not alert')
|
||
},
|
||
setInterval: (fn, ms) => {
|
||
timers.push({ fn, ms })
|
||
return timers.length
|
||
},
|
||
clearInterval: () => {},
|
||
subscribeVisible: () => () => {}
|
||
})
|
||
await flush()
|
||
assert.equal(fetches, 1)
|
||
assert.equal(timers.length, 1)
|
||
assert.equal(timers[0].ms, APP_UPDATE_POLL_MS)
|
||
assert.equal(APP_UPDATE_POLL_MS, 600_000)
|
||
})
|
||
|
||
it('alerts once, reloads once, and stops later checks when buildId differs', async () => {
|
||
let fetches = 0
|
||
let reloads = 0
|
||
const alerts: string[] = []
|
||
let cleared = 0
|
||
let unsubscribed = 0
|
||
let visible: (() => void) | null = null
|
||
const timers: Array<{ fn: () => void }> = []
|
||
startAppUpdateChecker({
|
||
isProd: true,
|
||
localBuildId: 'a-1',
|
||
localVersion: '3.0.2',
|
||
baseUrl: '/',
|
||
now: () => 10,
|
||
fetchRemote: async () => {
|
||
fetches += 1
|
||
return { buildId: 'b-2', version: '3.0.3' }
|
||
},
|
||
onReload: () => {
|
||
reloads += 1
|
||
},
|
||
alertUpdate: async (message) => {
|
||
alerts.push(message)
|
||
},
|
||
setInterval: (fn) => {
|
||
timers.push({ fn })
|
||
return 1
|
||
},
|
||
clearInterval: () => {
|
||
cleared += 1
|
||
},
|
||
subscribeVisible: (handler) => {
|
||
visible = handler
|
||
return () => {
|
||
unsubscribed += 1
|
||
}
|
||
}
|
||
})
|
||
await flush()
|
||
assert.equal(alerts.length, 1)
|
||
assert.match(alerts[0], /最新 v3\.0\.3/)
|
||
assert.equal(reloads, 1)
|
||
assert.equal(cleared, 1)
|
||
assert.equal(unsubscribed, 1)
|
||
visible?.()
|
||
timers[0]?.fn()
|
||
await flush()
|
||
assert.equal(fetches, 1)
|
||
assert.equal(reloads, 1)
|
||
})
|
||
|
||
it('keeps polling when fetch fails or the payload is unusable', async () => {
|
||
const urls = ['throw', 'bad', 'empty']
|
||
let fetches = 0
|
||
let alerts = 0
|
||
const timers: Array<{ fn: () => void }> = []
|
||
startAppUpdateChecker({
|
||
isProd: true,
|
||
localBuildId: 'a-1',
|
||
localVersion: '3.0.2',
|
||
baseUrl: '/',
|
||
now: () => 10,
|
||
fetchRemote: async () => {
|
||
fetches += 1
|
||
const kind = urls[fetches - 1]
|
||
if (kind === 'throw') throw new Error('offline')
|
||
if (kind === 'bad') return { nope: true }
|
||
return { buildId: '' }
|
||
},
|
||
onReload: () => {
|
||
throw new Error('should not reload')
|
||
},
|
||
alertUpdate: async () => {
|
||
alerts += 1
|
||
},
|
||
setInterval: (fn) => {
|
||
timers.push({ fn })
|
||
return 1
|
||
},
|
||
clearInterval: () => {
|
||
throw new Error('should not stop')
|
||
},
|
||
subscribeVisible: () => () => {}
|
||
})
|
||
await flush()
|
||
timers[0].fn()
|
||
await flush()
|
||
timers[0].fn()
|
||
await flush()
|
||
assert.equal(fetches, 3)
|
||
assert.equal(alerts, 0)
|
||
})
|
||
|
||
it('skips a visible-tab check inside 30 seconds and runs one after that', async () => {
|
||
let now = 1_000_000
|
||
let fetches = 0
|
||
let visible: (() => void) | null = null
|
||
startAppUpdateChecker({
|
||
isProd: true,
|
||
localBuildId: 'a-1',
|
||
localVersion: '3.0.2',
|
||
baseUrl: '/',
|
||
now: () => now,
|
||
fetchRemote: async () => {
|
||
fetches += 1
|
||
return { buildId: 'a-1' }
|
||
},
|
||
onReload: () => {},
|
||
alertUpdate: async () => {},
|
||
setInterval: () => 1,
|
||
clearInterval: () => {},
|
||
subscribeVisible: (handler) => {
|
||
visible = handler
|
||
return () => {}
|
||
}
|
||
})
|
||
await flush()
|
||
assert.equal(fetches, 1)
|
||
now = 1_000_000 + 10_000
|
||
visible?.()
|
||
await flush()
|
||
assert.equal(fetches, 1)
|
||
now = 1_000_000 + 30_000
|
||
visible?.()
|
||
await flush()
|
||
assert.equal(fetches, 2)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd web-admin && pnpm exec tsx --test src/utils/app-update.test.ts`
|
||
|
||
Expected: FAIL with cannot find module `./app-update.ts`
|
||
|
||
- [ ] **Step 3: Write minimal implementation**
|
||
|
||
```ts
|
||
// web-admin/src/utils/app-update.ts
|
||
export const APP_UPDATE_POLL_MS = 10 * 60 * 1000
|
||
export const APP_UPDATE_VISIBILITY_DEBOUNCE_MS = 30 * 1000
|
||
|
||
const UPDATE_BODY = '系统已发布更新,当前页面仍在使用旧功能。请刷新后继续操作。'
|
||
|
||
export type RemoteVersion = {
|
||
version?: unknown
|
||
buildId?: unknown
|
||
} | null
|
||
|
||
export type AppUpdateCheckerDeps = {
|
||
isProd: boolean
|
||
localBuildId: string
|
||
localVersion: string
|
||
baseUrl: string
|
||
now: () => number
|
||
fetchRemote: (url: string) => Promise<unknown>
|
||
onReload: () => void
|
||
alertUpdate: (message: string) => Promise<void>
|
||
setInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval>
|
||
clearInterval: (id: ReturnType<typeof setInterval>) => void
|
||
subscribeVisible: (handler: () => void) => () => void
|
||
}
|
||
|
||
export function versionJsonUrl(baseUrl: string, nowMs: number): string {
|
||
const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
|
||
return `${base}version.json?t=${nowMs}`
|
||
}
|
||
|
||
export function shouldPromptUpdate(localBuildId: string, remote: unknown): boolean {
|
||
if (!remote || typeof remote !== 'object') return false
|
||
const buildId = (remote as { buildId?: unknown }).buildId
|
||
return typeof buildId === 'string' && buildId.length > 0 && buildId !== localBuildId
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
export function formatUpdateMessage(localVersion: string, remoteVersion: unknown): string {
|
||
if (typeof remoteVersion !== 'string' || !remoteVersion.trim()) return UPDATE_BODY
|
||
const current = escapeHtml(localVersion)
|
||
const latest = escapeHtml(remoteVersion.trim())
|
||
return `${UPDATE_BODY}<br>当前 v${current} / 最新 v${latest}`
|
||
}
|
||
|
||
export function shouldSkipVisibilityCheck(lastStartedAt: number, nowMs: number): boolean {
|
||
return nowMs - lastStartedAt < APP_UPDATE_VISIBILITY_DEBOUNCE_MS
|
||
}
|
||
|
||
export function startAppUpdateChecker(deps: AppUpdateCheckerDeps): () => void {
|
||
if (!deps.isProd) return () => {}
|
||
|
||
let prompted = false
|
||
let reloading = false
|
||
let inFlight = false
|
||
let lastStartedAt = 0
|
||
let timer: ReturnType<typeof setInterval> | null = null
|
||
let unsubscribe = () => {}
|
||
|
||
const stop = () => {
|
||
if (timer != null) deps.clearInterval(timer)
|
||
timer = null
|
||
unsubscribe()
|
||
unsubscribe = () => {}
|
||
}
|
||
|
||
const check = async () => {
|
||
if (prompted || inFlight) return
|
||
inFlight = true
|
||
lastStartedAt = deps.now()
|
||
try {
|
||
const remote = await deps.fetchRemote(versionJsonUrl(deps.baseUrl, lastStartedAt))
|
||
if (!shouldPromptUpdate(deps.localBuildId, remote)) return
|
||
prompted = true
|
||
stop()
|
||
const version =
|
||
remote && typeof remote === 'object' ? (remote as { version?: unknown }).version : undefined
|
||
await deps.alertUpdate(formatUpdateMessage(deps.localVersion, version))
|
||
if (reloading) return
|
||
reloading = true
|
||
deps.onReload()
|
||
} catch {
|
||
// 下一轮再试
|
||
} finally {
|
||
inFlight = false
|
||
}
|
||
}
|
||
|
||
const checkFromVisibility = () => {
|
||
if (prompted) return
|
||
const now = deps.now()
|
||
if (lastStartedAt !== 0 && shouldSkipVisibilityCheck(lastStartedAt, now)) return
|
||
void check()
|
||
}
|
||
|
||
void check()
|
||
timer = deps.setInterval(() => {
|
||
void check()
|
||
}, APP_UPDATE_POLL_MS)
|
||
unsubscribe = deps.subscribeVisible(checkFromVisibility)
|
||
return stop
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd web-admin && pnpm exec tsx --test src/utils/app-update.test.ts`
|
||
|
||
Expected: PASS(5 组 describe 全部通过)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/utils/app-update.ts web-admin/src/utils/app-update.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
test: add web-admin stale-tab update checker.
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 构建注入与生产环境弹窗
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/vite.config.ts`(`define` 与 `plugins`)
|
||
- Modify: `web-admin/src/env.d.ts`(`__APP_VERSION__` 声明旁)
|
||
- Create: `web-admin/src/utils/check-app-update.ts`
|
||
- Modify: `web-admin/src/main.ts`(`app.mount('#app')` 之后)
|
||
|
||
- [ ] **Step 1: Declare the build id**
|
||
|
||
在 `web-admin/src/env.d.ts` 现有行旁增加:
|
||
|
||
```ts
|
||
declare const __APP_VERSION__: string // 版本号
|
||
declare const __APP_BUILD_ID__: string // 构建指纹:短 git hash + 构建时间戳
|
||
```
|
||
|
||
- [ ] **Step 2: Inject the same build id and write version.json on build**
|
||
|
||
`web-admin/vite.config.ts` 顶部增加:
|
||
|
||
```ts
|
||
import { execSync } from 'node:child_process'
|
||
import { createAppBuildMeta, readGitShort, writeVersionJson } from './scripts/app-build-meta'
|
||
```
|
||
|
||
`path` 已从 `'path'` 导入。在 `export default ({ mode }) => {` 里、`defineConfig` 之前:
|
||
|
||
```ts
|
||
const gitShort = readGitShort(() =>
|
||
execSync('git rev-parse --short HEAD', {
|
||
stdio: ['ignore', 'pipe', 'ignore']
|
||
}).toString()
|
||
)
|
||
const appBuildMeta = createAppBuildMeta(VITE_VERSION || '', Date.now(), gitShort)
|
||
```
|
||
|
||
把现有 `define` 改成同时注入两个常量(仍使用同一次算出的 `appBuildMeta`):
|
||
|
||
```ts
|
||
define: {
|
||
__APP_VERSION__: JSON.stringify(VITE_VERSION),
|
||
__APP_BUILD_ID__: JSON.stringify(appBuildMeta.buildId),
|
||
global: 'globalThis'
|
||
},
|
||
```
|
||
|
||
在 `plugins` 数组最前面加入仅生产构建写文件的插件(`closeBundle` 时 `dist` 已存在):
|
||
|
||
```ts
|
||
{
|
||
name: 'emit-version-json',
|
||
apply: 'build',
|
||
closeBundle() {
|
||
writeVersionJson(path.resolve(root, 'dist'), appBuildMeta)
|
||
}
|
||
},
|
||
```
|
||
|
||
`root` 已是 `process.cwd()`。不要在 `apply: 'serve'` 时写 `version.json`。
|
||
|
||
- [ ] **Step 3: Wire the browser**
|
||
|
||
```ts
|
||
// web-admin/src/utils/check-app-update.ts
|
||
import { startAppUpdateChecker } from './app-update'
|
||
|
||
/** 生产环境检查 dist/version.json,发现新 buildId 后强制刷新。 */
|
||
export function startAppUpdateCheck(): () => void {
|
||
return startAppUpdateChecker({
|
||
isProd: import.meta.env.PROD,
|
||
localBuildId: __APP_BUILD_ID__,
|
||
localVersion: __APP_VERSION__,
|
||
baseUrl: import.meta.env.BASE_URL,
|
||
now: () => Date.now(),
|
||
fetchRemote: async (url) => {
|
||
const res = await fetch(url, { cache: 'no-store' })
|
||
if (!res.ok) throw new Error(String(res.status))
|
||
return res.json()
|
||
},
|
||
onReload: () => {
|
||
location.reload()
|
||
},
|
||
alertUpdate: async (message) => {
|
||
await ElMessageBox.alert(message, '发现新版本', {
|
||
confirmButtonText: '立即刷新',
|
||
showClose: false,
|
||
closeOnClickModal: false,
|
||
closeOnPressEscape: false,
|
||
showCancelButton: false,
|
||
dangerouslyUseHTMLString: true
|
||
})
|
||
},
|
||
setInterval: (fn, ms) => window.setInterval(fn, ms),
|
||
clearInterval: (id) => window.clearInterval(id),
|
||
subscribeVisible: (handler) => {
|
||
const listener = () => {
|
||
if (document.visibilityState === 'visible') handler()
|
||
}
|
||
document.addEventListener('visibilitychange', listener)
|
||
return () => document.removeEventListener('visibilitychange', listener)
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
`ElMessageBox` 使用自动导入,不要写 `import { ElMessageBox } from 'element-plus'`。
|
||
|
||
`web-admin/src/main.ts` 在 `app.mount('#app')` 之后:
|
||
|
||
```ts
|
||
import { startAppUpdateCheck } from './utils/check-app-update'
|
||
```
|
||
|
||
该 import 与文件顶部其它 import 放在一起。调用:
|
||
|
||
```ts
|
||
app.mount('#app')
|
||
startAppUpdateCheck()
|
||
```
|
||
|
||
- [ ] **Step 4: Run unit tests again**
|
||
|
||
Run: `cd web-admin && pnpm exec tsx --test scripts/app-build-meta.test.ts src/utils/app-update.test.ts`
|
||
|
||
Expected: PASS
|
||
|
||
不在此步骤跑完整 `pnpm build`(该构建内存占用大)。指纹与写文件已由 Task 1 覆盖;`vite.config.ts` 只负责把同一 `appBuildMeta` 注入并在 `closeBundle` 调用 `writeVersionJson`。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add web-admin/vite.config.ts web-admin/src/env.d.ts web-admin/src/utils/check-app-update.ts web-admin/src/main.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): force a refresh when a newer build is deployed.
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## 规格对照
|
||
|
||
| 规格 | 任务 |
|
||
|------|------|
|
||
| `version` + `buildId`(hash-时间戳,空 hash 为 `nogit`) | Task 1、Task 3 |
|
||
| 每次构建 `buildId` 变化 | Task 1 `timestamp changes` |
|
||
| 生产环境才检查;`pnpm dev` 不请求 | Task 2 `does nothing outside production`;Task 3 `import.meta.env.PROD` |
|
||
| 立即查、每 10 分钟、可见且距上次 ≥ 30 秒再查 | Task 2 |
|
||
| 失败 / 非 2xx / 坏 JSON / 空 `buildId` 不打断 | Task 2;Task 3 `!res.ok` 抛错进入 catch |
|
||
| 强制弹窗文案、版本行、只刷新一次、弹出后停止监听 | Task 2、Task 3 `ElMessageBox` 选项 |
|
||
| `cache: 'no-store'` 与 `?t=` | Task 2 URL、Task 3 fetch |
|
||
| 不改 Nginx | 无任务 |
|
||
|
||
## 手工验证(实现合并前)
|
||
|
||
1. `pnpm dev` 打开管理端,网络面板没有 `version.json`。
|
||
2. 若需要看真实弹窗:把 `check-app-update.ts` 里临时将 `isProd` 设为 `true`,并在 `public/` 放一份 `buildId` 不同的 `version.json`(验证完还原,不要提交这处临时改动)。切走标签页超过 30 秒再回来应弹窗;点「立即刷新」会重载。遮罩点击和 Esc 不能关掉弹窗。
|