Files
shanxia2.0/docs/superpowers/plans/2026-10-16-web-admin-version-refresh.md
T
2026-10-19 18:28:02 +08:00

719 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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&lt;0/)
assert.match(text, /v3&gt;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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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 不能关掉弹窗。