diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml
index eaf337b..bc41287 100644
--- a/.gitea/workflows/docker-build.yml
+++ b/.gitea/workflows/docker-build.yml
@@ -85,13 +85,16 @@ jobs:
- name: Build & sign OTA package
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
+ # 发布说明 = 触发构建的 commit message (标题+正文, 截断 400 字),
+ # commit sha 通过 --commit 单独写入 manifest, 前端分行展示
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
- OTA_NOTES: ${{ github.sha }}
run: |
set -euo pipefail
- node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY}
+ MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d' | head -c 400)"
+ echo "notes: ${MSG}"
+ node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
echo "===== latest.json ====="
cat ota-package/latest.json
echo "===== bundle sha256 ====="
diff --git a/scripts/build-ota.mjs b/scripts/build-ota.mjs
index 15fd6f9..557dfbd 100644
--- a/scripts/build-ota.mjs
+++ b/scripts/build-ota.mjs
@@ -1,147 +1,150 @@
-#!/usr/bin/env node
-// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
-// 用法:
-// node scripts/build-ota.mjs [--out
] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ]
-// 流程:
-// 1) 校验 dist/ 已构建
-// 2) 生成 bundle.tar.gz (dist/ 内容)
-// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
-// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
-// 5) 全部产物输出到 out 目录 (默认 ota-package/)
-//
-// minisign 安装: 见 readme "OTA 升级" 章节
-import fs from 'node:fs'
-import path from 'node:path'
-import crypto from 'node:crypto'
-import { fileURLToPath } from 'node:url'
-import { spawnSync } from 'node:child_process'
-
-const __filename = fileURLToPath(import.meta.url)
-const __dirname = path.dirname(__filename)
-const ROOT = path.resolve(__dirname, '..')
-
-const args = process.argv.slice(2)
-function getArg(name, def) {
- const i = args.indexOf(name)
- return i >= 0 && args[i + 1] ? args[i + 1] : def
-}
-const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
-const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
-const MIN_BINARY = getArg('--min-binary', '')
-const NOTES = getArg('--notes', '')
-const NO_SIGN = args.includes('--no-sign')
-// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
-const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
-const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
-// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
-const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
-
-const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
-const APP_VERSION = pkg.version || '0.0.0'
-
-const DIST = path.join(ROOT, 'dist')
-if (!fs.existsSync(path.join(DIST, 'index.html'))) {
- console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
- process.exit(1)
-}
-
-// 准备输出目录
-fs.mkdirSync(OUT_DIR, { recursive: true })
-
-// 1) 生成 bundle.tar.gz
-const BUNDLE_NAME = 'iboard-ota.tar.gz'
-const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
-const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
-if (r.status !== 0) {
- console.error('[build-ota] tar 打包失败')
- process.exit(1)
-}
-const bundleStat = fs.statSync(BUNDLE_PATH)
-const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
-
-// 2) 签名 (minisign)
-let signature = ''
-let pubkey = ''
-if (!NO_SIGN) {
- // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
- let KEY_PATH_TMP = null
- let KEY_PATH_USE = KEY_PATH
- if (KEY_FROM_ENV) {
- KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
- fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
- KEY_PATH_USE = KEY_PATH_TMP
- }
- if (!fs.existsSync(KEY_PATH_USE)) {
- console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
- console.error(' 注入方式二选一:')
- console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
- console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key ')
- console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
- process.exit(1)
- }
- // minisign -Sm -s 生成 detached signature
- const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
- const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
- // 清理 CI 临时私钥 (无论成功失败都删)
- if (KEY_PATH_TMP) {
- try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
- KEY_PATH_TMP = null
- }
- if (sigR.status !== 0) {
- console.error('[build-ota] minisign 签名失败')
- process.exit(1)
- }
- const sigPath = BUNDLE_PATH + '.minisig'
- signature = fs.readFileSync(sigPath, 'utf8').trim()
-
- // 尝试同步 pubkey 到 tauri.conf.json (如果存在)
- const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
- if (fs.existsSync(PUB_PATH)) {
- pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
- const lines = pubkey.split(/\r?\n/)
- pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
- }
-}
-
-// 3) 写 latest.json
-const minBinary = MIN_BINARY || (() => {
- // 默认从 src-tauri/tauri.conf.json 读 version
- try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
- catch { return APP_VERSION }
-})()
-const manifest = {
- version: APP_VERSION + '-ota',
- sequence: SEQUENCE,
- min_binary_version: minBinary,
- bundle: BUNDLE_NAME,
- bundle_size: bundleStat.size,
- bundle_sha256: bundleSha256,
- signature,
- notes: NOTES,
- pub_date: new Date().toISOString(),
- mandatory: false,
- // 内部用: 前端 web 模式比对 build_id
- build_id: 'seq-' + SEQUENCE,
-}
-fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
-
-// 4) 提示
-console.log('')
-console.log('=== OTA 包生成完成 ===')
-console.log('输出目录: ', OUT_DIR)
-console.log('版本: ', manifest.version)
-console.log('sequence: ', manifest.sequence)
-console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
-console.log('bundle sha256:', bundleSha256)
-console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
-if (pubkey) {
- console.log('')
- console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
- console.log(' ' + pubkey)
-}
-
-console.log('')
-console.log('下一步: 把这两个文件发布到 OTA 存储:')
-console.log(' ' + BUNDLE_NAME)
-console.log(' latest.json')
-console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
+#!/usr/bin/env node
+// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
+// 用法:
+// node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ]
+// 流程:
+// 1) 校验 dist/ 已构建
+// 2) 生成 bundle.tar.gz (dist/ 内容)
+// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
+// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
+// 5) 全部产物输出到 out 目录 (默认 ota-package/)
+//
+// minisign 安装: 见 readme "OTA 升级" 章节
+import fs from 'node:fs'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+import { spawnSync } from 'node:child_process'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const ROOT = path.resolve(__dirname, '..')
+
+const args = process.argv.slice(2)
+function getArg(name, def) {
+ const i = args.indexOf(name)
+ return i >= 0 && args[i + 1] ? args[i + 1] : def
+}
+const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
+const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
+const MIN_BINARY = getArg('--min-binary', '')
+const NOTES = getArg('--notes', '')
+// 构建对应的 git commit sha (发布说明=commit message, sha 单独一行展示)
+const COMMIT = getArg('--commit', '')
+const NO_SIGN = args.includes('--no-sign')
+// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
+const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
+const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
+// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
+const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
+
+const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
+const APP_VERSION = pkg.version || '0.0.0'
+
+const DIST = path.join(ROOT, 'dist')
+if (!fs.existsSync(path.join(DIST, 'index.html'))) {
+ console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
+ process.exit(1)
+}
+
+// 准备输出目录
+fs.mkdirSync(OUT_DIR, { recursive: true })
+
+// 1) 生成 bundle.tar.gz
+const BUNDLE_NAME = 'iboard-ota.tar.gz'
+const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
+const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
+if (r.status !== 0) {
+ console.error('[build-ota] tar 打包失败')
+ process.exit(1)
+}
+const bundleStat = fs.statSync(BUNDLE_PATH)
+const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
+
+// 2) 签名 (minisign)
+let signature = ''
+let pubkey = ''
+if (!NO_SIGN) {
+ // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
+ let KEY_PATH_TMP = null
+ let KEY_PATH_USE = KEY_PATH
+ if (KEY_FROM_ENV) {
+ KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
+ fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
+ KEY_PATH_USE = KEY_PATH_TMP
+ }
+ if (!fs.existsSync(KEY_PATH_USE)) {
+ console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
+ console.error(' 注入方式二选一:')
+ console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
+ console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key ')
+ console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
+ process.exit(1)
+ }
+ // minisign -Sm -s 生成 detached signature
+ const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
+ const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
+ // 清理 CI 临时私钥 (无论成功失败都删)
+ if (KEY_PATH_TMP) {
+ try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
+ KEY_PATH_TMP = null
+ }
+ if (sigR.status !== 0) {
+ console.error('[build-ota] minisign 签名失败')
+ process.exit(1)
+ }
+ const sigPath = BUNDLE_PATH + '.minisig'
+ signature = fs.readFileSync(sigPath, 'utf8').trim()
+
+ // 尝试同步 pubkey 到 tauri.conf.json (如果存在)
+ const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
+ if (fs.existsSync(PUB_PATH)) {
+ pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
+ const lines = pubkey.split(/\r?\n/)
+ pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
+ }
+}
+
+// 3) 写 latest.json
+const minBinary = MIN_BINARY || (() => {
+ // 默认从 src-tauri/tauri.conf.json 读 version
+ try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
+ catch { return APP_VERSION }
+})()
+const manifest = {
+ version: APP_VERSION + '-ota',
+ sequence: SEQUENCE,
+ min_binary_version: minBinary,
+ bundle: BUNDLE_NAME,
+ bundle_size: bundleStat.size,
+ bundle_sha256: bundleSha256,
+ signature,
+ notes: String(NOTES).slice(0, 400),
+ commit: COMMIT || undefined,
+ pub_date: new Date().toISOString(),
+ mandatory: false,
+ // 内部用: 前端 web 模式比对 build_id
+ build_id: 'seq-' + SEQUENCE,
+}
+fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
+
+// 4) 提示
+console.log('')
+console.log('=== OTA 包生成完成 ===')
+console.log('输出目录: ', OUT_DIR)
+console.log('版本: ', manifest.version)
+console.log('sequence: ', manifest.sequence)
+console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
+console.log('bundle sha256:', bundleSha256)
+console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
+if (pubkey) {
+ console.log('')
+ console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
+ console.log(' ' + pubkey)
+}
+
+console.log('')
+console.log('下一步: 把这两个文件发布到 OTA 存储:')
+console.log(' ' + BUNDLE_NAME)
+console.log(' latest.json')
+console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
diff --git a/server/routes/ota.js b/server/routes/ota.js
index 1242a49..21e92ff 100644
--- a/server/routes/ota.js
+++ b/server/routes/ota.js
@@ -1,102 +1,105 @@
-// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
-// GET /api/ota/check/:current_sequence
-// - 客户端传当前 sequence, 服务端判断是否有更新
-// - 有: 200 + manifest JSON
-// - 无: 204 No Content
-// GET /api/ota/bundle/:filename
-// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
-// GET /api/ota/manifest-web
-// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
-//
-// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
-// - latest.json (由 scripts/build-ota.mjs 生成)
-// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
-// - (tar.gz 升级包)
-import { Router } from 'express'
-import fs from 'node:fs'
-import path from 'node:path'
-
-const router = Router()
-
-function resolveOtaDir() {
- const dir = process.env.OTA_PACKAGE_DIR
- ? path.resolve(process.env.OTA_PACKAGE_DIR)
- : path.resolve(process.cwd(), 'ota-package')
- return dir
-}
-
-function readLatest() {
- const p = path.join(resolveOtaDir(), 'latest.json')
- if (!fs.existsSync(p)) return null
- try {
- return JSON.parse(fs.readFileSync(p, 'utf8'))
- } catch (e) {
- console.error('[ota] latest.json 解析失败:', e.message)
- return null
- }
-}
-
-// GET /api/ota/check/:current_sequence
-// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
-router.get('/ota/check/:current_sequence', (req, res) => {
- const currentSeq = Number(req.params.current_sequence)
- if (!Number.isFinite(currentSeq) || currentSeq < 0) {
- return res.status(400).json({ error: '无效的 sequence' })
- }
- const latest = readLatest()
- if (!latest) {
- // 无可用包, 按约定返回 204
- return res.status(204).end()
- }
- // 不新于当前则 204
- if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
- return res.status(204).end()
- }
- // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤)
- // 客户端传 binary_version query: 暂不强制, 让客户端兜底
- const dir = resolveOtaDir()
- const base = process.env.OTA_PUBLIC_BASE || ''
- res.json({
- version: latest.version,
- sequence: latest.sequence,
- min_binary_version: latest.min_binary_version || '0.0.0',
- url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
- signature: latest.signature,
- notes: latest.notes || '',
- pub_date: latest.pub_date || new Date().toISOString(),
- mandatory: !!latest.mandatory,
- bundle_size: latest.bundle_size || 0,
- })
-})
-
-// GET /api/ota/bundle/:filename - 流式下载 bundle
-router.get('/ota/bundle/:filename', (req, res) => {
- const dir = resolveOtaDir()
- // 防越界: 只取 basename
- const name = path.basename(req.params.filename)
- const p = path.join(dir, name)
- if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
- const stat = fs.statSync(p)
- res.setHeader('Content-Type', 'application/gzip')
- res.setHeader('Content-Length', stat.size)
- res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
- fs.createReadStream(p).pipe(res)
-})
-
-// GET /api/ota/manifest-web - Web 浏览器用的简化版
-// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
-router.get('/ota/manifest-web', (_req, res) => {
- const latest = readLatest()
- if (!latest) return res.json({ available: false })
- res.json({
- available: true,
- version: latest.version,
- build_id: latest.build_id || latest.version,
- note: latest.notes || '',
- size: latest.bundle_size || 0,
- })
-})
-
-// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
-
-export default router
+// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
+// GET /api/ota/check/:current_sequence
+// - 客户端传当前 sequence, 服务端判断是否有更新
+// - 有: 200 + manifest JSON
+// - 无: 204 No Content
+// GET /api/ota/bundle/:filename
+// - 下载前端 bundle (tar.gz), 供 manifest.url 引用
+// GET /api/ota/manifest-web
+// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
+//
+// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
+// - latest.json (由 scripts/build-ota.mjs 生成)
+// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
+// - (tar.gz 升级包)
+import { Router } from 'express'
+import fs from 'node:fs'
+import path from 'node:path'
+
+const router = Router()
+
+function resolveOtaDir() {
+ const dir = process.env.OTA_PACKAGE_DIR
+ ? path.resolve(process.env.OTA_PACKAGE_DIR)
+ : path.resolve(process.cwd(), 'ota-package')
+ return dir
+}
+
+function readLatest() {
+ const p = path.join(resolveOtaDir(), 'latest.json')
+ if (!fs.existsSync(p)) return null
+ try {
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
+ } catch (e) {
+ console.error('[ota] latest.json 解析失败:', e.message)
+ return null
+ }
+}
+
+// GET /api/ota/check/:current_sequence
+// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
+router.get('/ota/check/:current_sequence', (req, res) => {
+ const currentSeq = Number(req.params.current_sequence)
+ if (!Number.isFinite(currentSeq) || currentSeq < 0) {
+ return res.status(400).json({ error: '无效的 sequence' })
+ }
+ const latest = readLatest()
+ if (!latest) {
+ // 无可用包, 按约定返回 204
+ return res.status(204).end()
+ }
+ // 不新于当前则 204
+ if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
+ return res.status(204).end()
+ }
+ // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤)
+ // 客户端传 binary_version query: 暂不强制, 让客户端兜底
+ const dir = resolveOtaDir()
+ const base = process.env.OTA_PUBLIC_BASE || ''
+ res.json({
+ version: latest.version,
+ sequence: latest.sequence,
+ min_binary_version: latest.min_binary_version || '0.0.0',
+ url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
+ signature: latest.signature,
+ notes: latest.notes || '',
+ commit: latest.commit || '',
+ pub_date: latest.pub_date || new Date().toISOString(),
+ mandatory: !!latest.mandatory,
+ bundle_size: latest.bundle_size || 0,
+ })
+})
+
+// GET /api/ota/bundle/:filename - 流式下载 bundle
+router.get('/ota/bundle/:filename', (req, res) => {
+ const dir = resolveOtaDir()
+ // 防越界: 只取 basename
+ const name = path.basename(req.params.filename)
+ const p = path.join(dir, name)
+ if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
+ const stat = fs.statSync(p)
+ res.setHeader('Content-Type', 'application/gzip')
+ res.setHeader('Content-Length', stat.size)
+ res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
+ fs.createReadStream(p).pipe(res)
+})
+
+// GET /api/ota/manifest-web - Web 浏览器用的简化版
+// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
+router.get('/ota/manifest-web', (_req, res) => {
+ const latest = readLatest()
+ if (!latest) return res.json({ available: false })
+ res.json({
+ available: true,
+ version: latest.version,
+ sequence: latest.sequence,
+ commit: latest.commit || '',
+ build_id: latest.build_id || latest.version,
+ note: latest.notes || '',
+ size: latest.bundle_size || 0,
+ })
+})
+
+// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
+
+export default router
diff --git a/src/api/ota.js b/src/api/ota.js
index ec2214a..80234d7 100644
--- a/src/api/ota.js
+++ b/src/api/ota.js
@@ -1,259 +1,262 @@
-// 前端 OTA 封装
-// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...)
-// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效)
-//
-// 设计原则:
-// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
-// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
-import { getApiBase } from './client.js'
-import { APP_VERSION, BUILD_ID } from '../version.js'
-
-// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
-const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
-
-// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源
-function resolveUrl(p) {
- const base = getApiBase()
- return base ? base + p : p
-}
-
-// 当前版本信息 (前端展示用)
-export function getCurrentVersionInfo() {
- return { version: APP_VERSION, build: BUILD_ID }
-}
-
-// 启动时: 确认当前资源可用 (Tauri only)
-// 失败/异常都不抛, 仅 console.warn
-export function initOta() {
- if (!inTauri) return
- // 动态 import: web 模式根本不打这个 chunk
- import('tauri-plugin-hotswap-api')
- .then((mod) => mod.notifyReady())
- .then(() => console.log('[ota] notifyReady ok'))
- .catch((e) => console.warn('[ota] notifyReady failed:', e))
-}
-
-// ---------------------------------------------------------------------------
-// 判新模式 (V0.2)
-// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
-// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
-// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
-// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
-// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
-// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
-// ---------------------------------------------------------------------------
-const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
-
-export function getOtaDevMode() {
- try {
- return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
- } catch { /* ignore */ }
- return false
-}
-
-export function setOtaDevMode(v) {
- try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
-}
-
-// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
-import { compareVersion, parseVersionFields } from '../utils/version.js'
-
-export { compareVersion, parseVersionFields }
-
-// 拉服务端最新 manifest (不分平台)
-// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
-export async function fetchOtaManifest() {
- try {
- const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
- if (res.status === 204) return null
- if (!res.ok) return null
- return await res.json()
- } catch (e) {
- console.warn('[ota] manifest 拉取失败:', e)
- return null
- }
-}
-
-// 检查更新
-// 返回统一结构, 调用方不关心是 Tauri 还是 web:
-// {
-// available: boolean,
-// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
-// info: { version, sequence, notes, mandatory, bundle_size }
-// }
-// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
-export async function checkOtaUpdate(opts) {
- const devMode = !!(opts && opts.devMode)
- if (!inTauri) {
- return checkOtaUpdateWeb(devMode)
- }
- return checkOtaUpdateTauri(devMode)
-}
-
-// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
-async function currentRunningVersion() {
- try {
- const v = await getOtaVersionInfo()
- if (v?.active && v.version) return v.version
- } catch { /* ignore */ }
- return APP_VERSION
-}
-
-// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
-async function checkByVersion() {
- const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
- if (!manifest || !manifest.version) {
- return { available: false, reason: 'no_package', info: null }
- }
- if (compareVersion(manifest.version, current) <= 0) {
- return { available: false, reason: 'same_version', info: manifest }
- }
- return {
- available: true,
- reason: 'ok',
- info: {
- version: manifest.version,
- sequence: manifest.sequence,
- notes: manifest.notes || '',
- mandatory: !!manifest.mandatory,
- bundle_size: manifest.bundle_size || 0,
- },
- }
-}
-
-async function checkOtaUpdateTauri(devMode) {
- // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
- if (devMode) {
- const { checkUpdate } = await import('tauri-plugin-hotswap-api')
- const r = await checkUpdate()
- if (!r.available) {
- return { available: false, reason: 'no_package', info: r }
- }
- return {
- available: true,
- reason: 'ok',
- info: {
- version: r.version,
- sequence: r.sequence,
- notes: r.notes,
- mandatory: r.mandatory,
- bundle_size: r.bundle_size,
- },
- }
- }
- // 生产口径: 版本号判新
- return checkByVersion()
-}
-
-async function checkOtaUpdateWeb(devMode) {
- // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
- try {
- const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
- if (!res.ok) {
- return { available: false, reason: 'unsupported', info: null }
- }
- const data = await res.json()
- if (!data.available) return { available: false, reason: 'no_package', info: data }
- // Web 端拿不到本地 sequence:
- // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
- // 生产口径 → 严格比较版本号大小, 只有更高版本才算新
- const cmp = compareVersion(data.version, APP_VERSION)
- if (devMode ? cmp === 0 : cmp <= 0) {
- return { available: false, reason: 'same_version', info: data }
- }
- return {
- available: true,
- reason: 'ok',
- info: {
- version: data.version,
- notes: data.note || '',
- // web 端没有强制/mandatory 等字段, 仅展示
- mandatory: false,
- bundle_size: data.size || 0,
- },
- }
- } catch (e) {
- console.warn('[ota] web check failed:', e)
- return { available: false, reason: 'unsupported', info: null }
- }
-}
-
-// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理
-// onProgress(received, total) 进度回调
-// 成功后必须 reload, 新资源才会生效
-export async function applyOtaUpdate(onProgress) {
- if (!inTauri) {
- throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面')
- }
- const mod = await import('tauri-plugin-hotswap-api')
-
- // 注册一次性进度监听 (applyUpdate 期间触发)
- let unlisten = null
- let unlistenLifecycle = null
- if (onProgress) {
- try {
- unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total))
- } catch (e) {
- console.warn('[ota] onDownloadProgress 监听失败:', e)
- }
- }
-
- // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因)
- // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里
- let lifecycleError = ''
- try {
- unlistenLifecycle = await mod.onLifecycle((ev) => {
- if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') {
- if (ev.error) {
- lifecycleError = ev.error
- console.error('[ota] lifecycle', ev.event, 'error:', ev.error)
- }
- }
- })
- } catch (e) {
- console.warn('[ota] onLifecycle 监听失败:', e)
- }
-
- try {
- const newVersion = await mod.applyUpdate()
- return newVersion
- } catch (e) {
- // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因
- const base = e?.message || '升级失败'
- const detail = lifecycleError ? ` (${lifecycleError})` : ''
- const err = new Error(base + detail)
- err.cause = e
- throw err
- } finally {
- if (unlisten) {
- try { unlisten() } catch { /* ignore */ }
- }
- if (unlistenLifecycle) {
- try { unlistenLifecycle() } catch { /* ignore */ }
- }
- }
-}
-
-// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复)
-export async function rollbackOta() {
- if (!inTauri) throw new Error('仅 Tauri 客户端可用')
- const mod = await import('tauri-plugin-hotswap-api')
- return mod.rollback()
-}
-
-// 取当前 OTA 状态 (Tauri only)
-export async function getOtaVersionInfo() {
- if (!inTauri) return null
- try {
- const mod = await import('tauri-plugin-hotswap-api')
- return await mod.getVersionInfo()
- } catch (e) {
- console.warn('[ota] getVersionInfo failed:', e)
- return null
- }
-}
-
-// reload 当前页 (新资源生效)
-export function reloadApp() {
- window.location.reload()
-}
+// 前端 OTA 封装
+// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...)
+// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效)
+//
+// 设计原则:
+// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
+// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
+import { getApiBase } from './client.js'
+import { APP_VERSION, BUILD_ID } from '../version.js'
+
+// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
+const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
+
+// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源
+function resolveUrl(p) {
+ const base = getApiBase()
+ return base ? base + p : p
+}
+
+// 当前版本信息 (前端展示用)
+export function getCurrentVersionInfo() {
+ return { version: APP_VERSION, build: BUILD_ID }
+}
+
+// 启动时: 确认当前资源可用 (Tauri only)
+// 失败/异常都不抛, 仅 console.warn
+export function initOta() {
+ if (!inTauri) return
+ // 动态 import: web 模式根本不打这个 chunk
+ import('tauri-plugin-hotswap-api')
+ .then((mod) => mod.notifyReady())
+ .then(() => console.log('[ota] notifyReady ok'))
+ .catch((e) => console.warn('[ota] notifyReady failed:', e))
+}
+
+// ---------------------------------------------------------------------------
+// 判新模式 (V0.2)
+// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
+// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
+// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
+// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
+// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
+// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
+// ---------------------------------------------------------------------------
+const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
+
+export function getOtaDevMode() {
+ try {
+ return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
+ } catch { /* ignore */ }
+ return false
+}
+
+export function setOtaDevMode(v) {
+ try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
+}
+
+// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
+import { compareVersion, parseVersionFields } from '../utils/version.js'
+
+export { compareVersion, parseVersionFields }
+
+// 拉服务端最新 manifest (不分平台)
+// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
+export async function fetchOtaManifest() {
+ try {
+ const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
+ if (res.status === 204) return null
+ if (!res.ok) return null
+ return await res.json()
+ } catch (e) {
+ console.warn('[ota] manifest 拉取失败:', e)
+ return null
+ }
+}
+
+// 检查更新
+// 返回统一结构, 调用方不关心是 Tauri 还是 web:
+// {
+// available: boolean,
+// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
+// info: { version, sequence, notes, mandatory, bundle_size }
+// }
+// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
+export async function checkOtaUpdate(opts) {
+ const devMode = !!(opts && opts.devMode)
+ if (!inTauri) {
+ return checkOtaUpdateWeb(devMode)
+ }
+ return checkOtaUpdateTauri(devMode)
+}
+
+// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
+async function currentRunningVersion() {
+ try {
+ const v = await getOtaVersionInfo()
+ if (v?.active && v.version) return v.version
+ } catch { /* ignore */ }
+ return APP_VERSION
+}
+
+// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
+async function checkByVersion() {
+ const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
+ if (!manifest || !manifest.version) {
+ return { available: false, reason: 'no_package', info: null }
+ }
+ if (compareVersion(manifest.version, current) <= 0) {
+ return { available: false, reason: 'same_version', info: manifest }
+ }
+ return {
+ available: true,
+ reason: 'ok',
+ info: {
+ version: manifest.version,
+ sequence: manifest.sequence,
+ notes: manifest.notes || '',
+ commit: manifest.commit || '',
+ mandatory: !!manifest.mandatory,
+ bundle_size: manifest.bundle_size || 0,
+ },
+ }
+}
+
+async function checkOtaUpdateTauri(devMode) {
+ // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
+ if (devMode) {
+ const { checkUpdate } = await import('tauri-plugin-hotswap-api')
+ const r = await checkUpdate()
+ if (!r.available) {
+ return { available: false, reason: 'no_package', info: r }
+ }
+ return {
+ available: true,
+ reason: 'ok',
+ info: {
+ version: r.version,
+ sequence: r.sequence,
+ notes: r.notes,
+ mandatory: r.mandatory,
+ bundle_size: r.bundle_size,
+ },
+ }
+ }
+ // 生产口径: 版本号判新
+ return checkByVersion()
+}
+
+async function checkOtaUpdateWeb(devMode) {
+ // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
+ try {
+ const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
+ if (!res.ok) {
+ return { available: false, reason: 'unsupported', info: null }
+ }
+ const data = await res.json()
+ if (!data.available) return { available: false, reason: 'no_package', info: data }
+ // Web 端拿不到本地 sequence:
+ // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
+ // 生产口径 → 严格比较版本号大小, 只有更高版本才算新
+ const cmp = compareVersion(data.version, APP_VERSION)
+ if (devMode ? cmp === 0 : cmp <= 0) {
+ return { available: false, reason: 'same_version', info: data }
+ }
+ return {
+ available: true,
+ reason: 'ok',
+ info: {
+ version: data.version,
+ sequence: data.sequence,
+ notes: data.note || '',
+ commit: data.commit || '',
+ // web 端没有强制/mandatory 等字段, 仅展示
+ mandatory: false,
+ bundle_size: data.size || 0,
+ },
+ }
+ } catch (e) {
+ console.warn('[ota] web check failed:', e)
+ return { available: false, reason: 'unsupported', info: null }
+ }
+}
+
+// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理
+// onProgress(received, total) 进度回调
+// 成功后必须 reload, 新资源才会生效
+export async function applyOtaUpdate(onProgress) {
+ if (!inTauri) {
+ throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面')
+ }
+ const mod = await import('tauri-plugin-hotswap-api')
+
+ // 注册一次性进度监听 (applyUpdate 期间触发)
+ let unlisten = null
+ let unlistenLifecycle = null
+ if (onProgress) {
+ try {
+ unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total))
+ } catch (e) {
+ console.warn('[ota] onDownloadProgress 监听失败:', e)
+ }
+ }
+
+ // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因)
+ // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里
+ let lifecycleError = ''
+ try {
+ unlistenLifecycle = await mod.onLifecycle((ev) => {
+ if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') {
+ if (ev.error) {
+ lifecycleError = ev.error
+ console.error('[ota] lifecycle', ev.event, 'error:', ev.error)
+ }
+ }
+ })
+ } catch (e) {
+ console.warn('[ota] onLifecycle 监听失败:', e)
+ }
+
+ try {
+ const newVersion = await mod.applyUpdate()
+ return newVersion
+ } catch (e) {
+ // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因
+ const base = e?.message || '升级失败'
+ const detail = lifecycleError ? ` (${lifecycleError})` : ''
+ const err = new Error(base + detail)
+ err.cause = e
+ throw err
+ } finally {
+ if (unlisten) {
+ try { unlisten() } catch { /* ignore */ }
+ }
+ if (unlistenLifecycle) {
+ try { unlistenLifecycle() } catch { /* ignore */ }
+ }
+ }
+}
+
+// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复)
+export async function rollbackOta() {
+ if (!inTauri) throw new Error('仅 Tauri 客户端可用')
+ const mod = await import('tauri-plugin-hotswap-api')
+ return mod.rollback()
+}
+
+// 取当前 OTA 状态 (Tauri only)
+export async function getOtaVersionInfo() {
+ if (!inTauri) return null
+ try {
+ const mod = await import('tauri-plugin-hotswap-api')
+ return await mod.getVersionInfo()
+ } catch (e) {
+ console.warn('[ota] getVersionInfo failed:', e)
+ return null
+ }
+}
+
+// reload 当前页 (新资源生效)
+export function reloadApp() {
+ window.location.reload()
+}
diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx
index f67c8bd..d72aa20 100644
--- a/src/pages/MePage.jsx
+++ b/src/pages/MePage.jsx
@@ -1,247 +1,259 @@
-import { useEffect, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
-import { useAuth } from '../auth/AuthContext.jsx'
-import { APP_VERSION, BUILD_ID } from '../version.js'
-import {
- applyOtaUpdate,
- checkOtaUpdate,
- getCurrentVersionInfo,
- getOtaDevMode,
- getOtaVersionInfo,
- reloadApp,
- rollbackOta,
- setOtaDevMode,
-} from '../api/ota.js'
-
-// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
-// OTA 区:
-// - 显示当前 APP_VERSION (前端包, 对应内置 dist)
-// - 显示当前 hotswap 状态 (是否已应用覆盖包)
-// - [检查更新]: 走 checkOtaUpdate()
-// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload
-// - [回滚]: 清除覆盖包, 回到内置 dist
-export default function MePage() {
- const { user, logout } = useAuth()
- const navigate = useNavigate()
-
- // 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
- // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
- // 离开本页时移除标记, 其他页恢复默认底色.
- useEffect(() => {
- document.body.classList.add('me-safe-top')
- return () => { document.body.classList.remove('me-safe-top') }
- }, [])
-
- // OTA 状态
- const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error
- const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
- const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
- const [progress, setProgress] = useState({ received: 0, total: 0 })
- const [error, setError] = useState('')
- // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
- const [devMode, setDevMode] = useState(() => getOtaDevMode())
-
- // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
- useEffect(() => {
- let cancelled = false
- getOtaVersionInfo().then((v) => {
- if (!cancelled) setOtaVersion(v)
- })
- return () => { cancelled = true }
- }, [])
-
- async function onCheck() {
- setError('')
- setOtaState('checking')
- try {
- const r = await checkOtaUpdate({ devMode })
- if (r.available) {
- setOtaInfo(r.info)
- setOtaState('available')
- Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') })
- } else {
- setOtaInfo(null)
- setOtaState('none')
- if (r.reason === 'same_version') {
- Toast.show({ content: '已是最新版本' })
- } else if (r.reason === 'no_package') {
- Toast.show({ content: '当前没有可用更新' })
- } else {
- Toast.show({ content: '当前没有可用更新' })
- }
- }
- } catch (e) {
- setError(e?.message || '检查失败')
- setOtaState('error')
- Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') })
- }
- }
-
- async function onApply() {
- setError('')
- setOtaState('downloading')
- setProgress({ received: 0, total: 0 })
- try {
- const newVer = await applyOtaUpdate((received, total) => {
- setProgress({ received, total })
- })
- Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' })
- setOtaState('ready')
- // 给用户 1s 看提示, 然后 reload
- setTimeout(() => reloadApp(), 1000)
- } catch (e) {
- setError(e?.message || '升级失败')
- setOtaState('error')
- Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') })
- }
- }
-
- async function onRollback() {
- if (!window.confirm('确认回滚到内置版本?')) return
- try {
- await rollbackOta()
- Toast.show({ icon: 'success', content: '已回滚, 正在重启' })
- setTimeout(() => reloadApp(), 800)
- } catch (e) {
- Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') })
- }
- }
-
- async function onLogout() {
- const confirmed = window.confirm('确定退出登录?')
- if (!confirmed) return
- await logout()
- Toast.show({ icon: 'success', content: '已退出' })
- navigate('/login', { replace: true })
- }
-
- const cur = getCurrentVersionInfo()
- const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
- const showProgress = otaState === 'downloading' && progress.total > 0
- const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0
-
- return (
-
-
-
- {(user?.name || '?').slice(0, 1).toUpperCase()}
-
-
{user?.name || '用户'}
-
{user?.email}
-
-
- {/* OTA 升级区 */}
-
- 当前版本
-
- 运行资源
-
- {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
- {
- setDevMode(v)
- setOtaDevMode(v)
- // 切换口径后旧检查结果失效, 重置避免误导
- setOtaState('idle')
- setOtaInfo(null)
- setError('')
- Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
- }}
- />
- }
- description={
- devMode
- ? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
- : '按版本号判新:仅当服务端版本号更高时才提示更新'
- }
- >
- 开发版本
-
-
-
-
-
- {isTauri && otaVersion?.active && (
-
- )}
-
-
- {otaState === 'available' && otaInfo && (
-
- 新版本
- {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
-
- )}
- {otaState === 'downloading' && (
-
-
-
- 正在下载 {showProgress ? progressPct + '%' : ''}
- {' '}({(progress.received / 1024).toFixed(0)} KB
- {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
-
-
-
-
- )}
- {otaInfo?.notes && (
- 发布说明
- )}
- {error && (
-
- {error}
-
- )}
- {!isTauri && (
- 提示
- )}
-
-
-
- 用户 ID
-
- 注册时间
-
-
- 退出登录
-
-
-
v{APP_VERSION} · build {BUILD_ID}
-
- )
-}
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
+import { useAuth } from '../auth/AuthContext.jsx'
+import { APP_VERSION, BUILD_ID } from '../version.js'
+import {
+ applyOtaUpdate,
+ checkOtaUpdate,
+ getCurrentVersionInfo,
+ getOtaDevMode,
+ getOtaVersionInfo,
+ reloadApp,
+ rollbackOta,
+ setOtaDevMode,
+} from '../api/ota.js'
+
+// seq/build 时间戳 → 'MM-dd HH:mm' (sequence 为毫秒时间戳, build_id 形如 seq-)
+export function fmtSeqTime(v) {
+ const ms = typeof v === 'string' && v.startsWith('seq-') ? Number(v.slice(4)) : Number(v)
+ if (!Number.isFinite(ms) || ms <= 0) return ''
+ const p = (n) => String(n).padStart(2, '0')
+ const d = new Date(ms)
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
+}
+
+// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
+// OTA 区:
+// - 显示当前 APP_VERSION (前端包, 对应内置 dist)
+// - 显示当前 hotswap 状态 (是否已应用覆盖包)
+// - [检查更新]: 走 checkOtaUpdate()
+// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload
+// - [回滚]: 清除覆盖包, 回到内置 dist
+export default function MePage() {
+ const { user, logout } = useAuth()
+ const navigate = useNavigate()
+
+ // 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
+ // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
+ // 离开本页时移除标记, 其他页恢复默认底色.
+ useEffect(() => {
+ document.body.classList.add('me-safe-top')
+ return () => { document.body.classList.remove('me-safe-top') }
+ }, [])
+
+ // OTA 状态
+ const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error
+ const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
+ const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
+ const [progress, setProgress] = useState({ received: 0, total: 0 })
+ const [error, setError] = useState('')
+ // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
+ const [devMode, setDevMode] = useState(() => getOtaDevMode())
+
+ // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
+ useEffect(() => {
+ let cancelled = false
+ getOtaVersionInfo().then((v) => {
+ if (!cancelled) setOtaVersion(v)
+ })
+ return () => { cancelled = true }
+ }, [])
+
+ async function onCheck() {
+ setError('')
+ setOtaState('checking')
+ try {
+ const r = await checkOtaUpdate({ devMode })
+ if (r.available) {
+ setOtaInfo(r.info)
+ setOtaState('available')
+ Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') })
+ } else {
+ setOtaInfo(null)
+ setOtaState('none')
+ if (r.reason === 'same_version') {
+ Toast.show({ content: '已是最新版本' })
+ } else if (r.reason === 'no_package') {
+ Toast.show({ content: '当前没有可用更新' })
+ } else {
+ Toast.show({ content: '当前没有可用更新' })
+ }
+ }
+ } catch (e) {
+ setError(e?.message || '检查失败')
+ setOtaState('error')
+ Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') })
+ }
+ }
+
+ async function onApply() {
+ setError('')
+ setOtaState('downloading')
+ setProgress({ received: 0, total: 0 })
+ try {
+ const newVer = await applyOtaUpdate((received, total) => {
+ setProgress({ received, total })
+ })
+ Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' })
+ setOtaState('ready')
+ // 给用户 1s 看提示, 然后 reload
+ setTimeout(() => reloadApp(), 1000)
+ } catch (e) {
+ setError(e?.message || '升级失败')
+ setOtaState('error')
+ Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') })
+ }
+ }
+
+ async function onRollback() {
+ if (!window.confirm('确认回滚到内置版本?')) return
+ try {
+ await rollbackOta()
+ Toast.show({ icon: 'success', content: '已回滚, 正在重启' })
+ setTimeout(() => reloadApp(), 800)
+ } catch (e) {
+ Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') })
+ }
+ }
+
+ async function onLogout() {
+ const confirmed = window.confirm('确定退出登录?')
+ if (!confirmed) return
+ await logout()
+ Toast.show({ icon: 'success', content: '已退出' })
+ navigate('/login', { replace: true })
+ }
+
+ const cur = getCurrentVersionInfo()
+ const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
+ const showProgress = otaState === 'downloading' && progress.total > 0
+ const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0
+
+ return (
+
+
+
+ {(user?.name || '?').slice(0, 1).toUpperCase()}
+
+
{user?.name || '用户'}
+
{user?.email}
+
+
+ {/* OTA 升级区 */}
+
+ 当前版本
+
+ 运行资源
+
+ {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
+ {
+ setDevMode(v)
+ setOtaDevMode(v)
+ // 切换口径后旧检查结果失效, 重置避免误导
+ setOtaState('idle')
+ setOtaInfo(null)
+ setError('')
+ Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
+ }}
+ />
+ }
+ description={
+ devMode
+ ? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
+ : '按版本号判新:仅当服务端版本号更高时才提示更新'
+ }
+ >
+ 开发版本
+
+
+
+
+
+ {isTauri && otaVersion?.active && (
+
+ )}
+
+
+ {otaState === 'available' && otaInfo && (
+
+ 新版本
+ {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
+
+ )}
+ {otaState === 'downloading' && (
+
+
+
+ 正在下载 {showProgress ? progressPct + '%' : ''}
+ {' '}({(progress.received / 1024).toFixed(0)} KB
+ {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
+
+
+
+
+ )}
+ {otaInfo?.notes && (
+ 发布说明
+ )}
+ {otaInfo?.commit && (
+ 提交
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {!isTauri && (
+ 提示
+ )}
+
+
+
+ 用户 ID
+
+ 注册时间
+
+
+ 退出登录
+
+
+
v{APP_VERSION} · build {BUILD_ID}
+
+ )
+}