# 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 onReload: () => void alertUpdate: (message: string) => Promise setInterval: (fn: () => void, ms: number) => ReturnType clearInterval: (id: ReturnType) => 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, '>') } 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}
当前 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 | 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 不能关掉弹窗。