From 67be8d8f7b54c185aa22465e2f7764bbf269d90a Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 25 Oct 2026 21:28:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(V0.2):=20=E7=89=88=E6=9C=AC=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E8=90=BD=E5=9C=B0=20=E2=80=94=E2=80=94=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF/=E5=A4=96=E5=A3=B3=E8=A7=A3=E7=BB=91,=20build=20?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E7=BB=9F=E4=B8=80,=20=E5=A4=96=E5=A3=B3?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 仅明确提出时自增) --- .local/cdp-drag-test.mjs | 78 +++++ .local/cdp-drag-test2.mjs | 77 +++++ .local/cdp-lp.mjs | 27 ++ .local/cdp-reorder.mjs | 32 ++ .local/cdp-scan.mjs | 40 +++ .local/cdp-shot.mjs | 35 +++ .local/cdp-task-grip.mjs | 36 +++ .local/cdp-verify.mjs | 30 ++ .local/msg.txt | 8 + .local/re-test.mjs | 4 + ....0.0, Culture=neutral, PublicKeyToken=null | 5 + CHANGELOG.md | 10 + README.md | 277 +++++++++++++++--- readme | 250 ---------------- scripts/build-ota.mjs | 9 +- scripts/ci/check-version.mjs | 77 ++--- src/api/ota.js | 16 + src/pages/MePage.jsx | 27 +- 18 files changed, 700 insertions(+), 338 deletions(-) create mode 100644 .local/cdp-drag-test.mjs create mode 100644 .local/cdp-drag-test2.mjs create mode 100644 .local/cdp-lp.mjs create mode 100644 .local/cdp-reorder.mjs create mode 100644 .local/cdp-scan.mjs create mode 100644 .local/cdp-shot.mjs create mode 100644 .local/cdp-task-grip.mjs create mode 100644 .local/cdp-verify.mjs create mode 100644 .local/msg.txt create mode 100644 .local/re-test.mjs create mode 100644 .local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null create mode 100644 CHANGELOG.md delete mode 100644 readme diff --git a/.local/cdp-drag-test.mjs b/.local/cdp-drag-test.mjs new file mode 100644 index 0000000..a545c8d --- /dev/null +++ b/.local/cdp-drag-test.mjs @@ -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() diff --git a/.local/cdp-drag-test2.mjs b/.local/cdp-drag-test2.mjs new file mode 100644 index 0000000..e5630c8 --- /dev/null +++ b/.local/cdp-drag-test2.mjs @@ -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() diff --git a/.local/cdp-lp.mjs b/.local/cdp-lp.mjs new file mode 100644 index 0000000..ba33313 --- /dev/null +++ b/.local/cdp-lp.mjs @@ -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() diff --git a/.local/cdp-reorder.mjs b/.local/cdp-reorder.mjs new file mode 100644 index 0000000..eb8ef21 --- /dev/null +++ b/.local/cdp-reorder.mjs @@ -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() diff --git a/.local/cdp-scan.mjs b/.local/cdp-scan.mjs new file mode 100644 index 0000000..bf0ac2f --- /dev/null +++ b/.local/cdp-scan.mjs @@ -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() diff --git a/.local/cdp-shot.mjs b/.local/cdp-shot.mjs new file mode 100644 index 0000000..31b02c7 --- /dev/null +++ b/.local/cdp-shot.mjs @@ -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() diff --git a/.local/cdp-task-grip.mjs b/.local/cdp-task-grip.mjs new file mode 100644 index 0000000..8f516c2 --- /dev/null +++ b/.local/cdp-task-grip.mjs @@ -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() diff --git a/.local/cdp-verify.mjs b/.local/cdp-verify.mjs new file mode 100644 index 0000000..18699ce --- /dev/null +++ b/.local/cdp-verify.mjs @@ -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() diff --git a/.local/msg.txt b/.local/msg.txt new file mode 100644 index 0000000..3833c95 --- /dev/null +++ b/.local/msg.txt @@ -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 实测拖动中容器背景已透明、宽度正常。 diff --git a/.local/re-test.mjs b/.local/re-test.mjs new file mode 100644 index 0000000..95e97c5 --- /dev/null +++ b/.local/re-test.mjs @@ -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, ''))) diff --git a/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null b/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null new file mode 100644 index 0000000..4db2c93 --- /dev/null +++ b/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null @@ -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.<b__5_1>d.MoveNext() \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..371c139 --- /dev/null +++ b/CHANGELOG.md @@ -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。 diff --git a/README.md b/README.md index 934225d..46e30da 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,263 @@ # iBoard -跨平台个人看板 (Tauri + React + Express + MariaDB)。版本方案见下文「版本方案 (V0.2)」。 +跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。 + +- 首页主要是放经济文章 +- 数据主要是放各种经济数据和图表 +- 策略是放按照经济理论组织的相关数据和图表的对比 + +## 首页 + +首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。 + +### 新闻模块 + +增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。 + +### 报警模块 + +World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。 + +## 监控 + +监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址: --- -## 版本方案 (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-.` 分支前缀对应。 +- 分支口径:**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/ +客户端 (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 --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-`; - - `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-`, 展示层再格式化)? 统一可消除两处格式分叉, 但会改变底部 build 的现有样式。 +### 数据库升级约定 + +- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行 + (幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。 +- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**; + 开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。 +- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。 diff --git a/readme b/readme deleted file mode 100644 index 2716367..0000000 --- a/readme +++ /dev/null @@ -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-.` 分支与版本前缀对应;任何一项不满足直接构建失败(`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` = `-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/ -客户端 (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 排除. diff --git a/scripts/build-ota.mjs b/scripts/build-ota.mjs index 557dfbd..3b9d640 100644 --- a/scripts/build-ota.mjs +++ b/scripts/build-ota.mjs @@ -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)) diff --git a/scripts/ci/check-version.mjs b/scripts/ci/check-version.mjs index 847ee95..0a45cf8 100644 --- a/scripts/ci/check-version.mjs +++ b/scripts/ci/check-version.mjs @@ -1,17 +1,24 @@ #!/usr/bin/env node -// CI 专用: 校验版本号四处一致 + 与分支名一致 +// CI 专用: 校验版本号分组一致 + 前端版本与分支名对应 // -// 背景: -// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生: -// - APK / 桌面安装包 version = (裸版本号, 无后缀) -// - OTA 热更包 version = -ota (多一个 -ota 标记) -// - 前端运行时 APP_VERSION = -// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。 +// 背景 (2026-10-25 调整: 外壳与前端版本号解绑): +// 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版, +// 两组版本号不再要求相等, 只要求**组内**一致: +// +// 组1 前端: package.json .version +// ├─ 前端运行时 APP_VERSION = (裸, 无后缀) +// └─ OTA 热更包 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-. → version 必须以 . 开头 // dev- → version 必须以 . 开头 // 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})`) } } diff --git a/src/api/ota.js b/src/api/ota.js index 80234d7..2a36de4 100644 --- a/src/api/ota.js +++ b/src/api/ota.js @@ -22,6 +22,19 @@ export function getCurrentVersionInfo() { return { version: APP_VERSION, build: BUILD_ID } } +// 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq- / 毫秒数) → '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 等字段, 仅展示 diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx index d72aa20..f7a420b 100644 --- a/src/pages/MePage.jsx +++ b/src/pages/MePage.jsx @@ -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-) -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 升级区 */} - 当前版本 + 当前版本 运行资源 @@ -206,7 +209,7 @@ export default function MePage() { {otaState === 'available' && otaInfo && ( - + 新版本 {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} @@ -253,7 +256,7 @@ export default function MePage() { 退出登录 -
v{APP_VERSION} · build {BUILD_ID}
+
外壳 v{shellVersion || '—'}
) }