feat(V0.2): 版本方案落地 —— 前端/外壳解绑, build 标识统一, 外壳版本展示
Docker Build and Push / build-image (push) Successful in 1m5s

- check-version: 分组校验 (前端 package.json / 外壳 tauri.conf+Cargo),
  两组不再要求相等; 分支前缀校验只针对前端版本
- build-ota: build_id 改为 YYYYMMDDHHmmss (北京时间, 与前端 BUILD_ID
  同格式); sequence 保持毫秒仅用于判新
- 我的页: 当前版本恢复 v0.2.0 · build <原始BUILD_ID>; 新版本附 OTA
  build_id; 底部替换为外壳版本 (Tauri getVersion, Web 端 —)
- README: 合并遗留 readme 全部内容并按决议重写版本管理章节;
  新增 CHANGELOG.md (PATCH 仅明确提出时自增)
This commit is contained in:
jif2.zhang
2026-10-25 21:28:06 +08:00
parent 06aa48f6ad
commit 67be8d8f7b
18 changed files with 700 additions and 338 deletions
+78
View File
@@ -0,0 +1,78 @@
// CDP 触摸手势驱动: 长按 400ms 激活 + 水平拖动, 观察 useRowDrag 行为
import WebSocket from 'ws'
import { spawn } from 'node:child_process'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0
const pending = new Map()
const send = (method, params = {}, sessionId) => new Promise((res) => {
const i = ++id
pending.set(i, res)
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
ws.on('message', (d) => {
const m = JSON.parse(d)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) }
if (m.method === 'Runtime.consoleAPICalled') {
console.log('[console]', m.params.type, m.params.args.map((a) => a.value ?? a.description).join(' '))
}
})
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable')
await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
await new Promise((r) => setTimeout(r, 3000))
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
const ev = async (expr) => (await S('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value
let rect = null
for (let i = 0; i < 30 && !rect; i++) {
await sleep(1000)
const v = await ev(`(() => { const el = document.querySelector('[data-task-id="3"]'); return el ? JSON.stringify(el.getBoundingClientRect()) : null })()`)
if (v) rect = JSON.parse(v)
}
if (!rect) { console.log('page never loaded'); ws.close(); process.exit(1) }
console.log('row rect', JSON.stringify(rect))
const r = rect
const x0 = r.left + r.width / 2
const y0 = r.top + r.height / 2
const touch = (type, x, y) => S('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }],
})
// 1) touchStart + 按住 500ms (激活长按)
await touch('touchStart', x0, y0)
console.log('--- touchStart, holding 500ms')
await sleep(500)
// 2) 水平拖动 +200px, 每步 +25px / 40ms (纯水平)
for (let dx = 25; dx <= 200; dx += 25) {
await touch('touchMove', x0 + dx, y0)
await sleep(40)
}
console.log('--- moved to +200px (pure horizontal)')
// 3) 带纵向分量的拖动 (模拟真实手指): +160px 横 / +40px 纵
await touch('touchMove', x0 + 60, y0 + 12)
await sleep(40)
await touch('touchMove', x0 + 120, y0 + 26)
await sleep(40)
await touch('touchMove', x0 + 160, y0 + 40)
await sleep(40)
await touch('touchEnd')
await sleep(600)
console.log('--- events on page:')
console.log((await ev('window.__ev.join("\\n")')))
ws.close()
+77
View File
@@ -0,0 +1,77 @@
// CDP 触摸手势驱动 v2: 把手立即拖动 + 长按路径, 组内/跨组落点
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0
const pending = new Map()
const send = (method, params = {}, sessionId) => new Promise((res) => {
const i = ++id
pending.set(i, res)
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
ws.on('message', (d) => {
const m = JSON.parse(d)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) }
})
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
const ev = async (expr) => (await S('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value
const center = async (sel) => {
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('${sel}').getBoundingClientRect())`))
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
}
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-habit-id="1"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', {
type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }],
})
// 用例 1: 按住 habit-1 的把手, 立即垂直拖到 habit-4 中部, 松手 → 应 DROP before/after 4
let p = await center('[data-habit-id="1"] .grip')
await touch('touchStart', p.x, p.y)
await sleep(60)
const t = await center('[data-habit-id="4"]')
await touch('touchMove', p.x, p.y + 20)
await sleep(30)
await touch('touchMove', t.x, t.y)
await sleep(60)
const cls1 = await ev(`document.querySelector('[data-habit-id="4"]').className`)
console.log('case1 target class:', cls1)
await touch('touchEnd')
await sleep(300)
// 用例 2: 按住 habit-1 把手拖到每周组 (habit-5) → 跨组, 应无落点无 DROP
p = await center('[data-habit-id="1"] .grip')
await touch('touchStart', p.x, p.y)
await sleep(60)
const w = await center('[data-habit-id="5"]')
await touch('touchMove', w.x, w.y)
await sleep(60)
const cls2 = await ev(`document.querySelector('[data-habit-id="5"]').className`)
console.log('case2 target class (应无 dragover):', cls2)
await touch('touchEnd')
await sleep(300)
// 用例 3: 长按卡片主体 500ms 后拖到 habit-3 下半 → DROP after 3
p = await center('[data-habit-id="2"]')
await touch('touchStart', p.x, p.y)
await sleep(520)
const t3 = await center('[data-habit-id="3"]')
await touch('touchMove', t3.x, t3.y + 20)
await sleep(60)
await touch('touchEnd')
await sleep(300)
console.log('--- events:')
console.log(await ev('window.__ev.join("\\n")'))
ws.close()
+27
View File
@@ -0,0 +1,27 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
// 长按 habit-2 主体, 分别在 450ms 后检查激活态, 再拖动
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"]').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await S('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] })
await sleep(460)
console.log('after 460ms class:', await ev(`document.querySelector('[data-habit-id="2"]').className`))
const r3 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="3"]').getBoundingClientRect())`))
await S('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: r3.left + r3.width / 2, y: r3.top + r3.height - 8, id: 1 }] })
await sleep(80)
console.log('class of 3 after move:', await ev(`document.querySelector('[data-habit-id="3"]').className`))
await S('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await sleep(300)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+32
View File
@@ -0,0 +1,32 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-task-id="101"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
// 用例1: 任务B(102) 把手拖到任务D(104) 下半 → 期望 REORDER 102 after 104
const g = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="102"] .grip').getBoundingClientRect())`))
await touch('touchStart', g.left + g.width / 2, g.top + g.height / 2)
await sleep(60)
const d = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="104"]').getBoundingClientRect())`))
await touch('touchMove', d.left + d.width / 2, d.top + d.height - 10)
await sleep(120)
console.log('row104 class:', await ev(`document.querySelector('[data-task-id="104"]').className`))
console.log('drag state:', await ev(`JSON.stringify({drag: !!document.querySelector('.task-row.dragging'), ghost: !!document.querySelector('.drag-ghost')})`))
await touch('touchEnd')
await sleep(200)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+40
View File
@@ -0,0 +1,40 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t4 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
const fx = t4.left + t4.width / 2, fy = t4.top + t4.height / 2
await touch('touchMove', fx, fy)
await sleep(150)
const scan = await ev(`(() => {
const out = []
document.querySelectorAll('*').forEach((el) => {
const cs = getComputedStyle(el)
const b = el.getBoundingClientRect()
if (b.width === 0 || b.height === 0) return
const bg = cs.backgroundColor
// 找带蓝色背景的元素
if (/rgb\\((22|2[0-9]|1[0-9]), ?1[0-9]{2}/.test(bg) || bg.includes('22, 119, 255') || bg.includes('0, 149, 255')) {
out.push({ tag: el.tagName, cls: el.className && String(el.className).slice(0, 40), bg, rect: [Math.round(b.left), Math.round(b.top), Math.round(b.width), Math.round(b.height)], text: (el.textContent || '').slice(0, 12) })
}
})
return JSON.stringify(out)
})()`)
console.log('blue elements:', scan)
console.log('ghost children:', await ev(`(() => { const g = document.querySelector('.drag-ghost'); return g ? g.children.length + ' kids, inner w ' + g.getBoundingClientRect().width : 'none' })()`))
await touch('touchEnd')
ws.close()
+35
View File
@@ -0,0 +1,35 @@
import WebSocket from 'ws'
import fs from 'node:fs'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-habit-id="1"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
await touch('touchMove', t.x ?? t.left + t.width / 2, t.top + t.height / 2)
await sleep(120)
// 拖动中截图
const shot = await S('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.USERPROFILE + '\\AppData\\Local\\Temp\\drag-mid.png', Buffer.from(shot.data, 'base64'))
console.log('shot saved; ghost exists:', await ev(`!!document.querySelector('.drag-ghost')`))
console.log('ghost html head:', (await ev(`document.querySelector('.drag-ghost') ? document.querySelector('.drag-ghost').outerHTML.slice(0, 200) : 'NONE'`)))
await touch('touchEnd')
await sleep(200)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+36
View File
@@ -0,0 +1,36 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-task-id="3"]')`)) break }
await sleep(1000) // 等延迟数据加载完
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
// 用例: 按住任务3的把手 → 水平拖 +200px (带 +30 纵向漂移, 复现真机抖动) → 松手
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="3"] .grip').getBoundingClientRect())`))
let x = r.left + r.width / 2, y = r.top + r.height / 2
console.log('grip rect', JSON.stringify(r))
await touch('touchStart', x, y)
await sleep(60)
for (const [dx, dy] of [[25, 4], [60, 10], [110, 18], [160, 26], [200, 30]]) {
await touch('touchMove', x + dx, y + dy)
await sleep(50)
}
console.log('row class after moves:', await ev(`document.querySelector('[data-task-id="3"]').className`))
console.log('meta:', await ev(`document.querySelector('[data-task-id="3"] .task-meta').textContent`))
await touch('touchEnd')
await sleep(300)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+30
View File
@@ -0,0 +1,30 @@
import WebSocket from 'ws'
import fs from 'node:fs'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
await S('Page.reload')
await sleep(2500)
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t4 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
await touch('touchMove', t4.left + t4.width / 2, t4.top + t4.height / 2)
await sleep(150)
console.log('ghost bg:', await ev(`getComputedStyle(document.querySelector('.drag-ghost')).backgroundColor`))
console.log('ghost rect:', await ev(`JSON.stringify(document.querySelector('.drag-ghost').getBoundingClientRect())`))
const shot = await S('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.USERPROFILE + '\\AppData\\Local\\Temp\\drag-fixed.png', Buffer.from(shot.data, 'base64'))
await touch('touchEnd')
ws.close()
+8
View File
@@ -0,0 +1,8 @@
fix(V0.2): 拖动浮层残留蓝色背景 —— 类名与遗留 .drag-ghost 规则冲突
globals.css 里有一条无人使用的旧 .drag-ghost 规则 (蓝色气泡,
max-width 180px, 看板早期方案遗留), 排在新增规则之后同特异性胜出,
克隆卡片容器被套上蓝色背景并被压缩到 180px 宽, 即真机上手下面的
蓝色 div。删除遗留规则; 新规则显式清零背景/内边距并禁用
tap-highlight/user-select (克隆脱离 .mv-view/.task-content 祖先后
需自带防选中)。CDP 实测拖动中容器背景已透明、宽度正常。
+4
View File
@@ -0,0 +1,4 @@
const s = '问题,;、!'
console.log(JSON.stringify(s.replace(/[\s\p{P}]+$/u, '')))
console.log(JSON.stringify('正常标题'.replace(/[\s\p{P}]+$/u, '')))
console.log(JSON.stringify('a-b_c'.replace(/[\s\p{P}]+$/u, '')))
@@ -0,0 +1,5 @@
Exception during registration:
System.ComponentModel.Win32Exception (0x80004005): 系统找不到指定的文件。
在 System.Diagnostics.Process.StartWithCreateProcess(ProcessStartInfo startInfo)
在 System.CommandLine.Invocation.Process.StartProcess(String command, String args, String workingDir, Action`1 stdOut, Action`1 stdErr, ValueTuple`2[] environmentVariables)
在 System.CommandLine.Builder.CommandLineBuilderExtensions.<>c.<<RegisterWithDotnetSuggest>b__5_1>d.MoveNext()
+10
View File
@@ -0,0 +1,10 @@
# CHANGELOG
版本号规则见 [README.md](README.md)「版本管理」。**PATCH (第三位) 只在明确提出时自增, 否则一直沿用**;
每次自增在本文件追加一行。MINOR 对应开发分支 (V0.2 ↔ dev-0.2), MAJOR 对应大迭代。
## 0.2.0
- 2026-10-25 起: V0.2 基线。前端与外壳 (Android 0.2.0) 版本号解绑, 外壳独立维护;
OTA 版本 = 前端版本 + `-ota`; build 标识统一 `YYYYMMDDHHmmss` (北京时间)。
- 本条为基线记录, PATCH 仍为 0。
+239 -38
View File
@@ -1,62 +1,263 @@
# iBoard
跨平台个人看板 (Tauri + React + Express + MariaDB)。版本方案见下文「版本方案 (V0.2)」。
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表
- 策略是放按照经济理论组织的相关数据和图表的对比
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
---
## 版本方案 (V0.2) — 整理稿 (2026-10-25, 待评审)
## 分支规则
> 本节是版本管理口径的整理与约定草案;标注 ⚠️ 的条目为待确认问题,确认后据此落地代码。
### 分支类型与职责
### 1. 版本号构成
| 分支 | 职责 | 部署 | 合并方向 |
| --- | --- | --- | --- |
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
| `android-*` | **Android 外壳构建分支** | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
> 下文所有「main 主分支」均指 `master`。
### 流程
```
V MAJOR.MINOR.PATCH 例: V0.2.0
│ └── PATCH 小版本号 (当前未启用, 见 §4)
tmp-xxx ──合并──▶ dev-0.2 ──合并──▶ master ──push──▶ 自动部署生产
│ │
│ └──push──▶ 只跑 CI(版本一致性校验),不部署
│
└── 问题解决后删除
android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构建 / 发布到 Packages
```
1. **日常开发**:从 `dev-*` 拉 `tmp-*`,改完合回 `dev-*`,`tmp-*` 用完即删。
2. **上线**:`dev-*` 验收通过后合并到 `master`,**推送 `master` 才部署生产**。
3. **打 Android 包**:`android-*` 与 `master` 同步(`git merge master` 或 `git reset --hard origin/master`)后推送,
触发 [android-build.yml](.gitea/workflows/android-build.yml),产物发布到 Gitea Generic Packages。
### CI 触发范围
| 工作流 | 触发分支 | 动作 |
| --- | --- | --- |
| [docker-build.yml](.gitea/workflows/docker-build.yml) | **仅 `master`** | 构建前端 + 生成签名 OTA 包 + 构建镜像 + 迁移数据库 + 部署生产 |
| [android-build.yml](.gitea/workflows/android-build.yml) | `android-*` | 构建并签名 release APK,上传到 Packages |
| [ota-build.yml](.gitea/workflows/ota-build.yml) | 仅手动 `workflow_dispatch` | 单独构建/签发 OTA 包 |
| [sync-registry.yml](.gitea/workflows/sync-registry.yml) | 见文件 | 同步注册中心 |
> ⚠️ **重要变更(2026-10)**:`dev-*` 分支**已取消** push 自动部署。
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
---
## 版本管理
### 版本号构成与口径(2026-10-25 决议)
```
V MAJOR.MINOR.PATCH 例: V0.2.0
│ └── PATCH 小版本号: 仅在明确提出时自增, 否则沿用 (记录见 CHANGELOG.md)
└── MINOR 大版本迭代, 对应一个开发分支 (V0.2 ↔ dev-0.2)
```
- **程序版本 (APP_VERSION)**: 唯一管理点是 `package.json` 的 `version` 字段, 手动修改; 前端经 `src/version.js` 读取。
- **build (构建时间戳)**: 每次 vite 编译生成 `__BUILD_ID__`, 格式 `YYYYMMDDHHmmss` (固定北京时间, 见 `vite.config.js#makeBuildId`)。展示格式统一为 `v0.2.0 · build 20261025204300`。
- **外壳版本 (Android 壳)**: 独立管理, 管理点在 `src-tauri/tauri.conf.json` 的 `version` (当前 0.2.0; 生成 APK 的 versionName)。CI 已有四处版本一致性校验 (`scripts/ci/check-version.mjs`)。
- ⚠️ **待确认 A**: 外壳与前端版本号当前同为 0.2.0, 但二者生命周期不同 (外壳随 APK 发版, 前端随 OTA 高频发版)。是否解除 `check-version.mjs` 里二者必须相等的约束, 改为只校验格式?
**前端与外壳解绑,独立维护、独立发版**(2026-10-25 决议):
### 2. 版本 ↔ 分支映射
| 组 | 版本 | 管理点 | 产物 | 发版节奏 |
| --- | --- | --- | --- | --- |
| **前端** (Web/OTA) | `0.2.0` | `package.json` 的 `version`(唯一手动管理点,只写裸版本号,不允许带 `-ota`) | 前端运行时 `APP_VERSION` = `0.2.0`;OTA 包 `version` = `0.2.0-ota` | 随 master push 高频发版 |
| **外壳** | `0.2.0` | `src-tauri/tauri.conf.json` 的 `version`(须与 `Cargo.toml`/`Cargo.lock` 组内一致) | APK versionName、桌面安装包版本;后续 Windows 外壳同理 | 独立分支(如 `android-*`)低频发版 |
| 版本 | 开发分支 | 发布出口 |
- 两组版本号**不要求相等**,CI(`scripts/ci/check-version.mjs`)只校验组内一致 + 前端版本与 `dev-<major>.<minor>` 分支前缀对应。
- 分支口径:**dev-0.2 开发,发布一律走 master**。
### build 标识
- 格式统一为 **`YYYYMMDDHHmmss`(北京时间)**:
- 前端 `BUILD_ID`:vite 编译时生成(`vite.config.js#makeBuildId`),无需手动维护;
- OTA 包 `build_id`:打包时按同一格式生成(`scripts/build-ota.mjs`)。
- OTA 包的 `sequence`(Unix 毫秒,单调递增)保留,仅用于判新与热插拔协议,不再用于展示。
### PATCH 小版本号
- 仅在**明确提出**时自增(改 `package.json` version 第三位),否则一直沿用。
- 每次自增在 [CHANGELOG.md](CHANGELOG.md) 追加一行(patch 号 / 日期 / commit / 要点)。
### 改版本号时要同步的位置
| 组 | 文件 | 字段 | 说明 |
| --- | --- | --- | --- |
| 前端 | `package.json` | `version` | **前端唯一源**,`src/version.js` 与 OTA 打包脚本均读取此值 |
| 外壳 | `src-tauri/tauri.conf.json` | `version` | APK 的 versionName、桌面安装包版本 |
| 外壳 | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,须与 tauri.conf.json 一致 |
| 外壳 | `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 随 Cargo.toml 一起改 |
### APK 版本号是怎么来的
`tauri android init` 会按 `src-tauri/tauri.conf.json` 的 `version` 生成
`src-tauri/gen/android/app/tauri.properties`(该目录被 gitignore,CI 每次 `android init` 重新生成):
```
tauri.android.versionName = 0.2.0 # 人类可见版本,与 tauri.conf.json 一致
tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000 + minor*1000 + patch
```
注意:
- `versionCode` 必须**单调递增**才能覆盖安装,所以外壳版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
删掉该目录重新 `npx tauri android init` 即可。
### 其它
- `src/version.js` 从 `package.json` 取值,前端运行时与 OTA 检查接口展示的即此版本,不要另写死版本号。
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
否则装了 0.1.0 APK 的设备会直接收不到热更。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
### "我的"页版本展示口径
| 行 | 展示 |
| --- | --- |
| 当前版本 | `v0.2.0 · build 20261025204300`(前端 APP_VERSION + BUILD_ID 原始格式) |
| 运行资源 | OTA 覆盖包版本 / 内置 |
| 新版本 | `v0.2.0-ota · build 20261025210000`(OTA 包 build_id) |
| 发布说明 | 触发构建的 commit message |
| 提交 | commit sha 前 10 位 |
| 页面底部 | `外壳 v0.2.0`(Tauri API 读取;Web 端显示 `—`) |
---
## 前端 OTA 升级
只升级前端资源 (`dist/`),**不触碰** Tauri 框架/原生层,不重打 APK。
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
(签名/回滚/资源替换全部由插件处理)。
### 工作流
```
开发者 服务端
npm run build ──┐
scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
│ 输出 latest.json (sequence/version/...)
▼
ota-package/ ── 部署到服务器 ──▶ /srv/iboard/ota-package
│
▼
GET /api/ota/check/<seq>
客户端 (Tauri) │
启动 ─▶ notifyReady() ▼
我页 ─▶ checkUpdate() ◀──── 200 + manifest (含 url/signature) ─────┘
applyUpdate() ─▶ 下载 tar.gz ─▶ minisign 校验 ─▶ 解压到 AppData/ota-dist
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
```
### 发布说明与提交号(2026-10-25 起)
- CI(docker-build.yml)把**触发构建的 commit message**(标题+正文,去空行,截断 400 字)作为 `notes` 写入 latest.json;
- commit sha 经 `--commit` 参数单独写入 `commit` 字段;
- "我的"页:发布说明展示 commit message,「提交」行展示短 sha。
### 首次部署
1. **生成签名密钥** (一次性):
```bash
# 安装 minisign: Windows (scoop) / macOS (brew) / Linux (apt)
node scripts/ota-genkey.mjs
# 产物: ota-package/hotswap.{key, pub}
```
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段(脚本会打印)。
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
```json
"hotswap": {
"endpoint": "https://your-server/api/ota/check/{{current_sequence}}",
"pubkey": "RWR...",
"require_https": true
}
```
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录。
### 发布新版前端
```bash
npm run build # 生成 dist/
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
# 也可加 --notes "修复登录按钮" --commit <sha> --min-binary 0.1.0
```
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
服务端 `OTA_PACKAGE_DIR` 指向的目录。sequence 单调递增即可。
### 客户端使用
打开 App 的"我"页,点 [检查更新] → [立即升级] 即可。失败会自动回滚到内置 dist。
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server,**OTA 不生效**。
> OTA 仅在 release/打包构建中可用,测试请用 `tauri build` 产物。
### 客户端判新口径(V0.2)
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
| 开关 | 判新依据 | 适用 |
| --- | --- | --- |
| V0.2.x | `dev-0.2` | `master` (push 触发 docker-build: 服务镜像 + OTA) |
| 外壳 APK | `android-*` 分支 push 触发 | Gitea Generic Package Registry |
| 关(默认) | **版本号**逐字段比大小(`0.2.0-ota` > `0.1.0` 才算新) | 生产。旧分支被重建(sequence 变大但版本号更小)时不会把设备拉回旧版 |
| 开 | **build sequence**(服务端 sequence 比本地大就算新) | 开发联调。每次构建都能推到设备 |
- ⚠️ **待确认 B**: 当前 CI 口径 (2026-10 调整) 是「只有 `master` push 部署; `dev-*` 只做 CI 校验不部署」。按"V0.2 对应 dev-0.2"的说法, 确认口径为: **dev-0.2 上开发合并, 发布一律走 master**; 还是希望 dev-0.2 也能直接出 OTA?
- 开关值持久化在设备端 `localStorage.iboard_ota_dev_mode`(`'1'`/`'0'`),默认 `'0'`。
- 版本比较在 `src/utils/version.js`(纯函数,`src/api/ota.js` 统一封装两种模式)。
- **`min_binary_version` 与应用版本是两件事**:前者是前端 bundle 要求的最低原生二进制版本,
当前 APK 原生层仍是 `0.1.0`,所以 CI 里固定传 `--min-binary 0.1.0`(见 `docker-build.yml` 的
`OTA_MIN_BINARY`)。只改前端版本号时**不要**跟着抬这个值,否则旧 APK 会收不到更新。
### 3. OTA 版本号
### Web 端
- OTA 包 `version` = 程序版本 + `-ota` 标记, 即 `0.2.0-ota`, **始终与程序版本保持一致** (已实现, `scripts/build-ota.mjs`)。
- OTA 包另带:
- `sequence`: 构建时刻的 Unix 毫秒时间戳 (单调递增, 判新用);
- `build_id`: `seq-<sequence>`;
- `commit`: 触发构建的 git commit sha;
- `notes`: commit message (标题+正文, ≤400 字)。
- 判新口径: 生产 = 版本号比较; 开发者开关 = sequence 比较 ("我的"页)。
Web 浏览器访问 (同源) 时,服务端返回简化版 manifest,仅显示"有新版本请刷新"。
Web 端不会自动下载包,也不会替换前端代码。
### 4. PATCH 小版本号 (待落地)
### CI 自动签名 (Gitea Actions)
现状: 第三位 PATCH 一直是 0, 没有实际使用。需要一个**记录处**。提案:
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流(每次发版前点一次)。
- 在本仓库根目录建 `CHANGELOG.md`, 按 PATCH 记行: `patch 号 / 日期 / commit / 要点`。
- 发版 (每次合并到 master 产生新 OTA) 时手动将 `package.json` version 的 PATCH +1 并补一行 CHANGELOG。
- ⚠️ **待确认 C**: 是否按"每次 master 发版 PATCH 必 +1"执行? 还是 PATCH 仅在有用户可感知变更时手动递增? 记录用 `CHANGELOG.md` 还是仓库内其他位置?
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容,**整段**(含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行),整段作为多行 secret 粘贴即可。
### 5. "我的"页版本展示 (待修复)
**使用流程**:
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
2. 填 sequence(可选,不填用时间戳)和 notes(可选)
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
4. 下载 artifact,scp 到服务器 OTA_PACKAGE_DIR 目录
5. 客户端"我"页 [检查更新] 即可触发升级
| 行 | 展示 | 现状 |
| --- | --- | --- |
| 当前版本 | `v0.2.0 · build 20261025204300` | ⚠️ 上一轮改坏: 误把前端 BUILD_ID 当毫秒时间戳格式化, 显示成了错误日期; 恢复为与页面底部 `v0.2.0 · build xxx` 完全一致的样式 |
| 运行资源 | OTA 覆盖包版本 / 内置 | — |
| 新版本 | `v0.2.0-ota · <构建时间>` | 构建时间来自 sequence (毫秒), 展示 `MM-dd HH:mm` |
| 发布说明 | commit message | — |
| 提交 | commit sha 前 10 位 | — |
| 外壳版本 | `v0.2.0` | ⚠️ **待确认 D**: 计划经 `@tauri-apps/api/app#getVersion()` 读取外壳版本显示 (Web 端显示 `—`); 放在 OTA 列表「运行资源」下方, 是否可以? |
**注意**:
- CI 用的私钥必须**与** `tauri.conf.json` 里 plugins.hotswap.pubkey **配对**;换密钥时需要同时更新 CI secret 和 conf。
- 不要把私钥 commit 到 git,整个 ota-package/hotswap.key 已被 .gitignore 排除。
- ⚠️ **待确认 E**: 前端 BUILD_ID (`YYYYMMDDHHmmss`) 与 OTA sequence (Unix ms) 是两套时间戳。是否统一为同一来源 (建议都改为 `seq-<Unix ms>`, 展示层再格式化)? 统一可消除两处格式分叉, 但会改变底部 build 的现有样式。
### 数据库升级约定
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
-250
View File
@@ -1,250 +0,0 @@
# iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
## 分支规则
### 分支类型与职责
| 分支 | 职责 | 部署 | 合并方向 |
| --- | --- | --- | --- |
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
> 下文所有「main 主分支」均指 `master`。
### 流程
```
tmp-xxx ──合并──▶ dev-0.2 ──合并──▶ master ──push──▶ 自动部署生产
│ │
│ └──push──▶ 只跑 CI(版本一致性校验),不部署
│
└── 问题解决后删除
android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构建 / 发布到 Packages
```
1. **日常开发**:从 `dev-*` 拉 `tmp-*`,改完合回 `dev-*`,`tmp-*` 用完即删。
2. **上线**:`dev-*` 验收通过后合并到 `master`,**推送 `master` 才部署生产**。
3. **打 Android 包**:`android-*` 与 `master` 同步(`git merge master` 或 `git reset --hard origin/master`)后推送,
触发 [android-build.yml](.gitea/workflows/android-build.yml),产物发布到 Gitea Generic Packages。
### CI 触发范围
| 工作流 | 触发分支 | 动作 |
| --- | --- | --- |
| [docker-build.yml](.gitea/workflows/docker-build.yml) | **仅 `master`** | 构建前端 + 生成签名 OTA 包 + 构建镜像 + 迁移数据库 + 部署生产 |
| [android-build.yml](.gitea/workflows/android-build.yml) | `android-*` | 构建并签名 release APK,上传到 Packages |
| [ota-build.yml](.gitea/workflows/ota-build.yml) | 仅手动 `workflow_dispatch` | 单独构建/签发 OTA 包 |
| [sync-registry.yml](.gitea/workflows/sync-registry.yml) | 见文件 | 同步注册中心 |
> ⚠️ **重要变更(2026-10)**:`dev-*` 分支**已取消** push 自动部署。
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
## 版本管理
### 一句话规则
**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。**
```
分支 dev-0.2 → 版本 0.2.0
├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀)
├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀)
└─ OTA 热更包 version = 0.2.0-ota (多一个 -ota)
```
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`),
该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。
### 改版本号时要同步的位置
| 文件 | 字段 | 说明 |
| --- | --- | --- |
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 |
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
### APK 版本号是怎么来的
`tauri android init` 会按 `src-tauri/tauri.conf.json` 的 `version` 生成
`src-tauri/gen/android/app/tauri.properties`(该目录被 gitignore,CI 每次 `android init` 重新生成):
```
tauri.android.versionName = 0.2.0 # 人类可见版本,与 tauri.conf.json 一致
tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000 + minor*1000 + patch
```
注意:
- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
删掉该目录重新 `npx tauri android init` 即可。
### 其它
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
- OTA 升级包的 `version` = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
所以版本号错位会直接导致线上 OTA 判新失准。
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
否则装了 0.1.0 APK 的设备会直接收不到热更。
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
## 分支与 CI/CD (Gitea Actions)
分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。
职责与合并方向见上文「分支规则」。
| 分支 | 触发的 workflow | 行为 |
| --- | --- | --- |
| `master`(主分支) | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) |
| `dev-*` | **不触发部署** | 推送只走版本一致性校验;上线须先合并到 `master` |
| `tmp-*` | 不触发 | 临时分支,合到 `dev-*` 后删除 |
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry(构建前须与 `master` 同步) |
| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 |
数据库升级约定:
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。
服务器监控对接 komari,地址:https://komari.honor3.com/
## 前端 OTA 升级
只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK.
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
(签名/回滚/资源替换全部由插件处理).
### 工作流
```
开发者 服务端
npm run build ──┐
scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
│ 输出 latest.json (sequence/version/...)
▼
ota-package/ ── 部署到服务器 ──▶ /srv/iboard/ota-package
│
▼
GET /api/ota/check/<seq>
客户端 (Tauri) │
启动 ─▶ notifyReady() ▼
我页 ─▶ checkUpdate() ◀──── 200 + manifest (含 url/signature) ─────┘
applyUpdate() ─▶ 下载 tar.gz ─▶ minisign 校验 ─▶ 解压到 AppData/ota-dist
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
```
### 首次部署
1. **生成签名密钥** (一次性):
```bash
# 安装 minisign: Windows (scoop) / macOS (brew) / Linux (apt)
node scripts/ota-genkey.mjs
# 产物: ota-package/hotswap.{key, pub}
```
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段
(脚本会打印).
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
```json
"hotswap": {
"endpoint": "https://your-server/api/ota/check/{{current_sequence}}",
"pubkey": "RWR...",
"require_https": true
}
```
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录.
### 发布新版前端
```bash
npm run build # 生成 dist/
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
# 也可加 --notes "修复登录按钮" --min-binary 0.1.0
```
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可.
### 客户端使用
打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist.
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
### 客户端判新口径(V0.2 新增)
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
| 开关 | 判新依据 | 适用 |
| --- | --- | --- |
| 关(默认) | **版本号**逐字段比大小(`0.2.0-ota` > `0.1.0` 才算新) | 生产。旧分支被重建(sequence 变大但版本号更小)时不会把设备拉回旧版 |
| 开 | **build sequence**(服务端 sequence 比本地大就算新) | 开发联调。每次构建都能推到设备 |
- 开关值持久化在设备端 `localStorage.iboard_ota_dev_mode`(`'1'`/`'0'`),默认 `'0'`。
- 版本比较在 `src/utils/version.js`(纯函数,`src/api/ota.js` 统一封装两种模式)。
- **`min_binary_version` 与应用版本是两件事**:前者是前端 bundle 要求的最低原生二进制版本,
当前 APK 原生层仍是 `0.1.0`,所以 CI 里固定传 `--min-binary 0.1.0`(见 `docker-build.yml` 的
`OTA_MIN_BINARY`)。只改前端版本号时**不要**跟着抬这个值,否则旧 APK 会收不到更新。
### Web 端
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
Web 端不会自动下载包, 也不会替换前端代码.
### CI 自动签名 (Gitea Actions)
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次).
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可.
**使用流程**:
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
2. 填 sequence (可选, 不填用时间戳) 和
otes (可选)
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录
5. 客户端"我"页 [检查更新] 即可触发升级
**注意**:
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf.
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除.
+7 -2
View File
@@ -123,8 +123,13 @@ const manifest = {
commit: COMMIT || undefined,
pub_date: new Date().toISOString(),
mandatory: false,
// 内部用: 前端 web 模式比对 build_id
build_id: 'seq-' + SEQUENCE,
// 内部用: 前端 web 模式比对 build_id。
// 格式与前端 vite BUILD_ID 一致 (YYYYMMDDHHmmss, 北京时间); sequence 保持毫秒单调递增
build_id: (() => {
const d = new Date(Date.now() + 8 * 3600 * 1000)
const p = (n, w) => String(n).padStart(w || 2, '0')
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
})(),
}
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
+41 -36
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env node
// CI 专用: 校验版本号四处一致 + 与分支名一致
// CI 专用: 校验版本号分组一致 + 前端版本与分支名对应
//
// 背景:
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生:
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀)
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记)
// - 前端运行时 APP_VERSION = <version>
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。
// 背景 (2026-10-25 调整: 外壳与前端版本号解绑):
// 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版,
// 两组版本号不再要求相等, 只要求**组内**一致:
//
// 组1 前端: package.json .version
// ├─ 前端运行时 APP_VERSION = <version> (裸, 无后缀)
// └─ OTA 热更包 version = <version>-ota (打包脚本追加)
//
// 组2 外壳: src-tauri/tauri.conf.json .version (= APK versionName)
// ├─ src-tauri/Cargo.toml [package].version
// └─ src-tauri/Cargo.lock (iboard 条目)
// 外壳随 android-*/桌面发布独立演进, 与 package.json 无关。
//
// 另: package.json 的 version 不允许带 "-ota" (OTA 后缀由打包脚本追加)。
//
// 校验分两段:
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加)
// B. 分支对应 (仅 dev-<版本> 分支):
// A. 组内一致性 (任何分支都查)
// B. 分支对应 (仅 dev-<版本> 分支, 针对前端组):
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
// dev-<major> → version 必须以 <major>. 开头
// main / master / 其它 → 跳过
@@ -53,61 +60,59 @@ function readCargoLockVersion() {
return null
}
const sources = [
{ file: 'package.json', version: String(readJson('package.json').version || '') },
// 组1 前端 (唯一手动管理点 = package.json)
const webVersion = String(readJson('package.json').version || '')
const webSources = [{ file: 'package.json', version: webVersion }]
const otaVersion = webVersion + '-ota'
// 组2 外壳 (独立维护; 三处必须组内一致)
const shellSources = [
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') },
{ file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() },
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
]
const version = sources[0].version
const otaVersion = version + '-ota'
console.log('[check-version] 分支: ', branch || '(未知)')
for (const s of sources) {
console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
}
console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion)
for (const s of webSources) console.log(`[check-version] [前端] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
for (const s of shellSources) console.log(`[check-version] [外壳] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
console.log('[check-version] 产物版本: OTA =', otaVersion, '/ 外壳(APK versionName) =', shellSources[0].version)
let failed = false
// ---- A. 四处一致性 + 裸版本号 ----
if (/-ota$/i.test(version)) {
// ---- A1. 前端组: 裸版本号 ----
if (/-ota$/i.test(webVersion)) {
console.error('')
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`)
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${webVersion}`)
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
failed = true
}
const mismatched = sources.filter((s) => s.version !== version)
if (mismatched.length) {
// ---- A2. 外壳组内一致 ----
const shellBroken = shellSources.filter((s) => s.version !== shellSources[0].version || !s.version)
if (shellBroken.length) {
console.error('')
console.error('============================================================')
console.error('[check-version] 版本号不一致, 构建中止')
console.error('[check-version] 外壳版本号不一致 (tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock), 构建中止')
console.error('============================================================')
for (const s of mismatched) {
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`)
for (const s of shellBroken) {
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ tauri.conf.json: ${shellSources[0].version}`)
}
console.error('')
console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);')
console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。')
console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。')
failed = true
}
// ---- B. 分支对应 ----
// ---- B. 分支对应 (仅前端组; 外壳在 android-* 分支独立演进, 不参与) ----
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
if (!m) {
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
} else {
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
if (!version.startsWith(want)) {
if (!webVersion.startsWith(want)) {
console.error('')
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`)
console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock')
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求前端版本以 ${want} 开头, 实际是 ${webVersion}`)
console.error('[check-version] 请修改 package.json 的 version')
failed = true
} else {
console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`)
console.log(`[check-version] 分支一致 (${branch} ↔ ${webVersion})`)
}
}
+16
View File
@@ -22,6 +22,19 @@ export function getCurrentVersionInfo() {
return { version: APP_VERSION, build: BUILD_ID }
}
// 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq-<ms> / 毫秒数) → 'YYYYMMDDHHmmss'
// (北京时间, 与 vite BUILD_ID / OTA manifest.build_id 同格式; 无法解析返回 '')
export function fmtBuildId(v) {
if (v == null) return ''
if (typeof v === 'string' && v.startsWith('seq-')) return fmtBuildId(Number(v.slice(4)))
if (typeof v === 'string' && /^\d{14}$/.test(v)) return v
const ms = Number(v)
if (!Number.isFinite(ms) || ms <= 0) return ''
const d = new Date(ms + 8 * 3600 * 1000)
const p = (n, w) => String(n).padStart(w || 2, '0')
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
}
// 启动时: 确认当前资源可用 (Tauri only)
// 失败/异常都不抛, 仅 console.warn
export function initOta() {
@@ -114,6 +127,7 @@ async function checkByVersion() {
info: {
version: manifest.version,
sequence: manifest.sequence,
build: manifest.build_id || fmtBuildId(manifest.sequence),
notes: manifest.notes || '',
commit: manifest.commit || '',
mandatory: !!manifest.mandatory,
@@ -136,6 +150,7 @@ async function checkOtaUpdateTauri(devMode) {
info: {
version: r.version,
sequence: r.sequence,
build: fmtBuildId(r.sequence),
notes: r.notes,
mandatory: r.mandatory,
bundle_size: r.bundle_size,
@@ -168,6 +183,7 @@ async function checkOtaUpdateWeb(devMode) {
info: {
version: data.version,
sequence: data.sequence,
build: fmtBuildId(data.build_id || data.sequence),
notes: data.note || '',
commit: data.commit || '',
// web 端没有强制/mandatory 等字段, 仅展示
+15 -12
View File
@@ -2,10 +2,10 @@ 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,
fmtBuildId,
getCurrentVersionInfo,
getOtaDevMode,
getOtaVersionInfo,
@@ -14,14 +14,7 @@ import {
setOtaDevMode,
} from '../api/ota.js'
// seq/build 时间戳 → 'MM-dd HH:mm' (sequence 为毫秒时间戳, build_id 形如 seq-<ms>)
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())}`
}
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
// OTA 区:
@@ -47,6 +40,16 @@ export default function MePage() {
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
const [progress, setProgress] = useState({ received: 0, total: 0 })
const [shellVersion, setShellVersion] = useState('') // 外壳 (APK/桌面包) 版本, 独立维护
// 外壳版本: 经 Tauri API 读取 (Web 端为空, 底部显示 —)
useEffect(() => {
if (!inTauri) return
import('@tauri-apps/api/app')
.then((m) => m.getVersion())
.then(setShellVersion)
.catch((e) => console.warn('[shell] getVersion failed:', e))
}, [])
const [error, setError] = useState('')
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
const [devMode, setDevMode] = useState(() => getOtaDevMode())
@@ -146,7 +149,7 @@ export default function MePage() {
{/* OTA 升级区 */}
<List className="me-list" mode="card" header="前端升级 (OTA)">
<List.Item extra={'v' + cur.version + (fmtSeqTime(cur.build) ? ' · ' + fmtSeqTime(cur.build) : '')}>当前版本</List.Item>
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
运行资源
</List.Item>
@@ -206,7 +209,7 @@ export default function MePage() {
</div>
</List.Item>
{otaState === 'available' && otaInfo && (
<List.Item extra={'v' + otaInfo.version + (fmtSeqTime(otaInfo.sequence) ? ' · ' + fmtSeqTime(otaInfo.sequence) : '')}>
<List.Item extra={'v' + otaInfo.version + (otaInfo.build ? ' · build ' + otaInfo.build : '')}>
新版本
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
</List.Item>
@@ -253,7 +256,7 @@ export default function MePage() {
退出登录
</List.Item>
</List>
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
<div className="me-version">外壳 v{shellVersion || '—'}</div>
</div>
)
}