22 KiB
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
// 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
// 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
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
// 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
// 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
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 现有行旁增加:
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 顶部增加:
import { execSync } from 'node:child_process'
import { createAppBuildMeta, readGitShort, writeVersionJson } from './scripts/app-build-meta'
path 已从 'path' 导入。在 export default ({ mode }) => { 里、defineConfig 之前:
const gitShort = readGitShort(() =>
execSync('git rev-parse --short HEAD', {
stdio: ['ignore', 'pipe', 'ignore']
}).toString()
)
const appBuildMeta = createAppBuildMeta(VITE_VERSION || '', Date.now(), gitShort)
把现有 define 改成同时注入两个常量(仍使用同一次算出的 appBuildMeta):
define: {
__APP_VERSION__: JSON.stringify(VITE_VERSION),
__APP_BUILD_ID__: JSON.stringify(appBuildMeta.buildId),
global: 'globalThis'
},
在 plugins 数组最前面加入仅生产构建写文件的插件(closeBundle 时 dist 已存在):
{
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
// 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') 之后:
import { startAppUpdateCheck } from './utils/check-app-update'
该 import 与文件顶部其它 import 放在一起。调用:
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
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 | 无任务 |
手工验证(实现合并前)
pnpm dev打开管理端,网络面板没有version.json。- 若需要看真实弹窗:把
check-app-update.ts里临时将isProd设为true,并在public/放一份buildId不同的version.json(验证完还原,不要提交这处临时改动)。切走标签页超过 30 秒再回来应弹窗;点「立即刷新」会重载。遮罩点击和 Esc 不能关掉弹窗。