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

22 KiB
Raw Blame History

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&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
// 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
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 无任务

手工验证(实现合并前)

  1. pnpm dev 打开管理端,网络面板没有 version.json。
  2. 若需要看真实弹窗:把 check-app-update.ts 里临时将 isProd 设为 true,并在 public/ 放一份 buildId 不同的 version.json(验证完还原,不要提交这处临时改动)。切走标签页超过 30 秒再回来应弹窗;点「立即刷新」会重载。遮罩点击和 Esc 不能关掉弹窗。