feat(V0.2): OTA 版本与判新口径 + Tab 结构调整

1) 版本对齐分支
- package.json / tauri.conf.json / Cargo.toml / Cargo.lock: 0.1.0 -> 0.2.0, OTA 包版本变为 0.2.0-ota
- 新增 scripts/ci/check-version.mjs: 分支 dev-x.y 与 package.json 版本不一致时 CI 直接失败
- docker-build.yml / ota-build.yml: min_binary_version 与前端版本解耦, 固定 0.1.0
  (现有 APK 原生层仍是 0.1.0, 抬高会导致旧 APK 收不到更新)

2) 我的页 OTA「开发版本」开关 (默认关, 持久化 localStorage.iboard_ota_dev_mode)
- 关: 按版本号逐字段比较 (src/utils/version.js), 服务端 version 更高才算新
- 开: 按 build sequence 判新 (hotswap checkUpdate), 每次构建都能推到设备
- Web 端: 开发口径退化为版本不同即算新; 生产口径严格比较大小

3) 打卡 Tab 移到最左并默认选中, 移除特效开关
- BottomNav: 打卡/宏观/监控/我; 删除 home 图标
- App: / 与 404 均重定向到 /checkin
- RingProgress/Celebrate: 删除 effectsEnabled/setEffectsEnabled, 特效恒开
- HabitView: 去掉头部「特效开/关」按钮

4) 取消首页 Tab, 报警/新闻并入宏观
- 新增 pages/macro/AlertsView.jsx、NewsView.jsx; 删除 HomePage.jsx
- MacroPage: 6 个子 Tab (油价/经济数据/策略研究/经济文章/报警/新闻), 报警角标保留
- 新闻详情 /news/:id -> /macro/news/:id (宏观 Tab 保持高亮)

5) 文档: 需求文档补 F-OT 需求点与已决策项; 设计文档新增 §10 变更记录与判新设计;
   界面设计.html 11 屏 (Tab 4 项/去掉特效开关/宏观合并/我的 OTA 开关 关+开 两态)
This commit is contained in:
cheney
2026-10-22 17:17:29 +08:00
parent e1e0880ce6
commit 4a04720a5e
25 changed files with 884 additions and 401 deletions
+13 -1
View File
@@ -21,6 +21,11 @@ jobs:
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致 # 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。 # "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
VITE_API_BASE: http://bh.vps.honor3.com:3001 VITE_API_BASE: http://bh.vps.honor3.com:3001
# OTA bundle 要求的最低原生二进制版本。
# 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层,
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。
# 重打 APK 并确认原生层无破坏后, 再提升此值。
OTA_MIN_BINARY: '0.1.0'
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
@@ -29,6 +34,13 @@ jobs:
with: with:
node-version: 22 node-version: 22
- name: Check version matches branch
# 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: node scripts/ci/check-version.mjs
- name: Install deps - name: Install deps
run: npm ci --no-audit --no-fund run: npm ci --no-audit --no-fund
@@ -47,7 +59,7 @@ jobs:
OTA_NOTES: ${{ github.sha }} OTA_NOTES: ${{ github.sha }}
run: | run: |
set -euo pipefail set -euo pipefail
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY}
echo "===== latest.json =====" echo "===== latest.json ====="
cat ota-package/latest.json cat ota-package/latest.json
echo "===== bundle sha256 =====" echo "===== bundle sha256 ====="
+3 -3
View File
@@ -21,10 +21,10 @@ on:
type: string type: string
default: '' default: ''
min_binary: min_binary:
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)' description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)'
required: false required: false
type: string type: string
default: '' default: '0.1.0'
jobs: jobs:
build-ota: build-ota:
@@ -56,7 +56,7 @@ jobs:
set -euo pipefail set -euo pipefail
OUT="ota-package" OUT="ota-package"
ARGS="--out ${OUT}" ARGS="--out ${OUT}"
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version # sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦)
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
+241 -48
View File
@@ -197,6 +197,43 @@
.legend span{display:flex;align-items:center;gap:6px} .legend span{display:flex;align-items:center;gap:6px}
.note{font-size:12px;color:var(--weak);margin-top:8px} .note{font-size:12px;color:var(--weak);margin-top:8px}
.flow{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:16px 18px;font-size:13px;color:#333;white-space:pre;overflow-x:auto;line-height:1.7} .flow{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:16px 18px;font-size:13px;color:#333;white-space:pre;overflow-x:auto;line-height:1.7}
/* ---------- 宏观子 Tab(6 项,可横向滚动) ---------- */
.mtabs{display:flex;gap:18px;padding:10px 14px 8px;overflow-x:auto;background:#fff;border-bottom:1px solid var(--line);font-size:13px;color:var(--muted);white-space:nowrap}
.mtabs::-webkit-scrollbar{display:none}
.mtabs .on{color:var(--primary);font-weight:600;position:relative}
.mtabs .on::after{content:"";position:absolute;left:50%;transform:translateX(-50%);bottom:-9px;width:22px;height:3px;border-radius:2px;background:var(--primary)}
.mtabs .badge-dot{display:inline-block;min-width:15px;height:15px;line-height:15px;padding:0 4px;margin-left:3px;
background:var(--danger);color:#fff;font-size:10px;border-radius:8px;text-align:center;vertical-align:1px}
/* ---------- 资讯卡片(报警 / 新闻) ---------- */
.feed{padding:10px 14px 14px;display:flex;flex-direction:column;gap:8px}
.feed-card{background:#fff;border:1px solid #eef0f2;border-radius:10px;padding:10px 12px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.feed-title{font-size:14px;font-weight:600;line-height:1.45;color:var(--text)}
.feed-sub{font-size:12px;color:var(--muted);margin-top:4px;line-height:1.5}
.feed-meta{display:flex;gap:10px;font-size:11px;color:var(--weak);margin-top:6px}
.feed-meta .rk{color:var(--primary)}
.alert-name{color:var(--danger);font-weight:600}
/* ---------- 我的页 ---------- */
.me-head{display:flex;flex-direction:column;align-items:center;gap:4px;padding:18px 0 16px;background:#fff}
.me-avatar{width:60px;height:60px;border-radius:50%;background:#e8effd;color:var(--primary);
display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:600}
.me-name{font-size:16px;font-weight:600}
.me-mail{font-size:12px;color:var(--weak)}
.me-group{margin:10px 12px 0;background:#fff;border-radius:10px;overflow:hidden;border:1px solid #eef0f2}
.me-group .g-head{font-size:12px;color:var(--weak);padding:9px 14px 6px}
.me-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 14px;border-top:1px solid var(--line);font-size:14px}
.me-group .g-head + .me-row{border-top:none}
.me-row .val{font-size:12px;color:var(--weak)}
.me-row .hint{font-size:11px;color:var(--weak);margin-top:2px;line-height:1.45}
.sw{width:42px;height:25px;border-radius:999px;background:#e5e7eb;position:relative;flex-shrink:0;transition:background .2s}
.sw::after{content:"";position:absolute;top:2px;left:2px;width:21px;height:21px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
.sw.on{background:var(--success)}
.sw.on::after{left:19px}
.me-actions{display:flex;gap:8px;padding:12px 14px;border-top:1px solid var(--line)}
.me-actions .mini-btn{flex:1;text-align:center;padding:7px 0;font-size:13px}
.me-actions .mini-btn.ghost{border-color:#e5e7eb;color:var(--muted)}
</style> </style>
</head> </head>
<body> <body>
@@ -206,8 +243,8 @@
<h1>iBoard V0.2 · 界面设计</h1> <h1>iBoard V0.2 · 界面设计</h1>
<p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p> <p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p>
<div class="meta"> <div class="meta">
<span>版本 V0.2</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span> <span>版本 V0.2(0.2.0)</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span>
<span>底部 5 Tab(新增「打卡」)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span> <span>底部 4 Tab(打卡默认)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span>
</div> </div>
</div> </div>
@@ -275,7 +312,6 @@
<div class="habit-summary">已完成 <strong>2</strong> / 4</div> <div class="habit-summary">已完成 <strong>2</strong> / 4</div>
</div> </div>
<div class="head-right"> <div class="head-right">
<div class="mini-btn">特效开</div>
<div class="mini-btn solid">+ 新建</div> <div class="mini-btn solid">+ 新建</div>
</div> </div>
</div> </div>
@@ -369,14 +405,12 @@
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"> <div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
@@ -472,11 +506,14 @@
</div> </div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
@@ -533,11 +570,14 @@
</div> </div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
@@ -556,7 +596,7 @@
<div class="habit-date">10月22日 周四</div> <div class="habit-date">10月22日 周四</div>
<div class="habit-summary">已完成 <strong>2</strong> / 4</div> <div class="habit-summary">已完成 <strong>2</strong> / 4</div>
</div> </div>
<div class="head-right"><div class="mini-btn">特效开</div><div class="mini-btn solid">+ 新建</div></div> <div class="head-right"><div class="mini-btn solid">+ 新建</div></div>
</div> </div>
<div class="habit-list"> <div class="habit-list">
<div class="habit-card"> <div class="habit-card">
@@ -595,11 +635,14 @@
<div class="btn-ghost">取消</div> <div class="btn-ghost">取消</div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
@@ -615,7 +658,7 @@
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>3</strong> / 4</div></div> <div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>3</strong> / 4</div></div>
<div class="head-right"><div class="mini-btn">特效开</div><div class="mini-btn solid">+ 新建</div></div> <div class="head-right"><div class="mini-btn solid">+ 新建</div></div>
</div> </div>
<div class="habit-list"> <div class="habit-list">
<div class="habit-card done" style="overflow:visible"> <div class="habit-card done" style="overflow:visible">
@@ -664,16 +707,19 @@
</div> </div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
<b>2.5 达标瞬间 · 完成特效</b> <b>2.5 达标瞬间 · 完成特效</b>
<p>环形闭合 + 对勾缩放入场(0.32s);同时 14 颗粒子沿圆周扩散(26~58px,交替主色与金色)+ 主色光环脉冲(0.7s),总时长 ≤900ms,<code>pointer-events:none</code> 不阻塞后续操作;并触发 18ms 轻震动。可在头部一键关闭(持久化开关),系统「减弱动效」时自动降级。</p> <p>环形闭合 + 对勾缩放入场(0.32s);同时 14 颗粒子沿圆周扩散(26~58px,交替主色与金色)+ 主色光环脉冲(0.7s),总时长 ≤900ms,<code>pointer-events:none</code> 不阻塞后续操作;并触发 18ms 轻震动。<b>特效恒开</b>(V0.2 设计变更:头部不再有开关)。</p>
</div> </div>
</div> </div>
@@ -727,11 +773,14 @@
<div class="cal-item"><i class="cdot" style="background:#a855f7"></i><span class="cname">吃药</span><span class="cmeta">每天 1 次 · 当日 0/1</span><span class="cplan">待办</span></div> <div class="cal-item"><i class="cdot" style="background:#a855f7"></i><span class="cname">吃药</span><span class="cmeta">每天 1 次 · 当日 0/1</span><span class="cplan">待办</span></div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
@@ -771,11 +820,14 @@
</div> </div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
@@ -791,7 +843,7 @@
<div class="body"> <div class="body">
<div class="habit-head"> <div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>0</strong> / 0</div></div> <div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>0</strong> / 0</div></div>
<div class="head-right"><div class="mini-btn">特效关</div><div class="mini-btn solid">+ 新建</div></div> <div class="head-right"><div class="mini-btn solid">+ 新建</div></div>
</div> </div>
<div class="empty"> <div class="empty">
<div class="ico">○</div> <div class="ico">○</div>
@@ -800,16 +852,157 @@
</div> </div>
</div> </div>
<div class="tabbar"> <div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div> <div class="tab">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div> <div class="tab">
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div> <div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
<b>2.8 空态</b> <b>2.8 空态</b>
<p>三个视图均具备加载中 / 空态 / 错误(带重试)三态。空态给出明确引导按钮;头部「特效开 / 特效关」为全局动效开关,关闭后仅保留环形与对勾,不播放粒子与震动。</p> <p>三个视图均具备加载中 / 空态 / 错误(带重试)三态。空态给出明确引导按钮。<b>V0.2 设计变更</b>:打卡页头部不再有「特效开 / 特效关」开关,完成特效与触感反馈<b>恒开</b>,没有任何关闭入口。</p>
</div>
</div>
<!-- ---------- 2.9 宏观(原首页报警/新闻并入) ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">宏观</div>
<div class="body">
<div class="mtabs">
<div>油价</div><div>经济数据</div><div>策略研究</div><div>经济文章</div>
<div>报警<span class="badge-dot">3</span></div><div class="on">新闻</div>
</div>
<div class="feed">
<div class="feed-card">
<div class="feed-title">央行开展 5000 亿元 MLF 操作,中标利率持平</div>
<div class="feed-sub">本月 MLF 净投放 2000 亿元,市场流动性保持合理充裕…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-22</span><span class="rk">#1</span></div>
</div>
<div class="feed-card">
<div class="feed-title">9 月社零同比增长 3.2%,服务消费延续回暖</div>
<div class="feed-sub">餐饮收入增速回升至 5.1%,可选消费分化明显…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-21</span><span class="rk">#2</span></div>
</div>
<div class="feed-card">
<div class="feed-title">美联储会议纪要偏鹰,美元指数小幅走强</div>
<div class="feed-sub">点阵图显示年内或仍有一次降息空间,但分歧加大…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-21</span><span class="rk">#3</span></div>
</div>
<div class="feed-card">
<div class="feed-title">OPEC+ 维持减产节奏,国际油价日内涨逾 1%</div>
<div class="feed-sub">布伦特原油重回 78 美元上方,化工链成本端承压…</div>
<div class="feed-meta"><span>妙想财经</span><span>2026-10-20</span><span class="rk">#4</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.9 宏观 · 原「首页」报警/新闻并入<span class="tag">设计变更</span></b>
<p>「首页」Tab 取消,报警与新闻成为宏观的第 5、6 个子 Tab(<code>/macro/alerts</code>、<code>/macro/news</code>),共 6 个子 Tab 可横向滚动,当前项底部有 3px 主色指示条;报警数量以红色角标挂在子 Tab 上。新闻详情改为 <code>/macro/news/:id</code>,底部「宏观」保持高亮。</p>
</div>
</div>
<!-- ---------- 2.10 我的 · OTA(开发版本 关 = 默认) ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">我</div>
<div class="body">
<div class="me-head">
<div class="me-avatar">C</div>
<div class="me-name">Cheney</div>
<div class="me-mail">qian.huang@sunyard.com</div>
</div>
<div class="me-group">
<div class="g-head">前端升级 (OTA)</div>
<div class="me-row"><span>当前版本</span><span class="val">v0.2.0 · 20261022.1530</span></div>
<div class="me-row"><span>运行资源</span><span class="val">内置</span></div>
<div class="me-row">
<div>
<div>开发版本</div>
<div class="hint">按版本号判新:仅当服务端版本号更高时才提示更新</div>
</div>
<div class="sw"></div>
</div>
<div class="me-actions">
<div class="mini-btn solid">检查更新</div>
<div class="mini-btn ghost">立即升级</div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.10 我的 · OTA「开发版本」关闭(默认)<span class="tag">新功能</span></b>
<p>开关默认关闭 = <b>按版本号判新</b>:取服务端 <code>latest.json</code> 的 <code>version</code>(如 <code>0.2.0-ota</code>),与当前运行版本按数字字段逐段比较,<b>更高</b>才提示更新。用于生产,避免旧分支被重建(sequence 变大但版本更小)把设备拉回旧版。开关值保存在设备端 <code>localStorage.iboard_ota_dev_mode</code>。</p>
</div>
</div>
<!-- ---------- 2.11 我的 · OTA(开发版本 开) ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">我</div>
<div class="body">
<div class="me-head">
<div class="me-avatar">C</div>
<div class="me-name">Cheney</div>
<div class="me-mail">qian.huang@sunyard.com</div>
</div>
<div class="me-group">
<div class="g-head">前端升级 (OTA)</div>
<div class="me-row"><span>当前版本</span><span class="val">v0.2.0 · 20261022.1530</span></div>
<div class="me-row"><span>运行资源</span><span class="val">内置</span></div>
<div class="me-row">
<div>
<div>开发版本</div>
<div class="hint">按 build sequence 判新:每次构建都算新版本,便于开发联调</div>
</div>
<div class="sw on"></div>
</div>
<div class="me-row"><span>新版本</span><span class="val">v0.2.0-ota (288 KB)</span></div>
<div class="me-actions">
<div class="mini-btn solid">立即升级</div>
<div class="mini-btn ghost">回滚</div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.11 我的 · OTA「开发版本」打开</b>
<p>开关打开 = <b>按 build sequence 判新</b>:走 hotswap 插件的 <code>checkUpdate()</code>,只要服务端 <code>sequence</code> 比本地大就算新版本——每次 CI 构建 sequence 都会变(默认取时间戳),所以任何一次提交都能推到设备上,适合开发联调。切换开关会清空上一次的检查结果,避免上一种口径的结论误导。</p>
</div> </div>
</div> </div>
@@ -875,9 +1068,9 @@
<h2>5 适配与可达性</h2> <h2>5 适配与可达性</h2>
<table> <table>
<tr><th>项</th><th>约定</th></tr> <tr><th>项</th><th>约定</th></tr>
<tr><td>视口</td><td>设计稿 420×820;375~430px 内元素不换行、不裁切;底部 TabBar 5 项每项 ≥ 40px 可点宽度</td></tr> <tr><td>视口</td><td>设计稿 420×820;375~430px 内元素不换行、不裁切;底部 TabBar 4 项每项 ≥ 44px 可点宽度</td></tr>
<tr><td>安全区</td><td>TabBar 使用 antd-mobile <code>safeArea</code>,页面底部留出 <code>tabbar-h + safe-bottom</code></td></tr> <tr><td>安全区</td><td>TabBar 使用 antd-mobile <code>safeArea</code>,页面底部留出 <code>tabbar-h + safe-bottom</code></td></tr>
<tr><td>减弱动效</td><td>系统开启「减弱动效」或用户关闭特效开关 → 不播放粒子 / 光环 / 震动,只保留环形与对勾</td></tr> <tr><td>动效</td><td><b>特效恒开</b>(V0.2 设计变更,无开关):单次 ≤900ms、<code>pointer-events:none</code>,不阻塞连续打卡;不再响应 <code>prefers-reduced-motion</code></td></tr>
<tr><td>大字号</td><td>卡片采用 grid 自适应列宽,文字超长换行不截断;名称限制 20 字、检查项 40 字</td></tr> <tr><td>大字号</td><td>卡片采用 grid 自适应列宽,文字超长换行不截断;名称限制 20 字、检查项 40 字</td></tr>
<tr><td>深色模式</td><td>V0.2 沿用现有浅色主题,暂不适配</td></tr> <tr><td>深色模式</td><td>V0.2 沿用现有浅色主题,暂不适配</td></tr>
</table> </table>
+108 -16
View File
@@ -2,8 +2,8 @@
| 项 | 内容 | | 项 | 内容 |
| --- | --- | | --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`) | | 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
| 文档状态 | 已实现(与代码一致) | | 文档状态 | 已实现(与代码一致;含 2026-10-22 设计变更,见 §10) |
| 上游文档 | `需求文档.md`(需求编号 F-CK / F-CL / F-LS / F-SY / U / D / N / E 沿用本文) | | 上游文档 | `需求文档.md`(需求编号 F-CK / F-CL / F-LS / F-SY / U / D / N / E 沿用本文) |
| 配套文档 | `数据库设计文档.md`、`界面设计.html` | | 配套文档 | `数据库设计文档.md`、`界面设计.html` |
| 技术栈 | Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma/MariaDB | | 技术栈 | Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma/MariaDB |
@@ -15,7 +15,7 @@
### 1.1 目标与范围 ### 1.1 目标与范围
在既有 iBoard(首页 / 宏观 / 监控 / 我)之上新增**第 5 个底部 Tab「打卡」**,页面内以三个子视图承载三类需求: 在既有 iBoard 之上**新增「打卡」模块并升为默认首页**(底部 Tab 由 5 个精简为 4 个:打卡 / 宏观 / 监控 / 我),页面内以三个子视图承载三类需求:
| 子视图 | 解决问题 | 核心隐喻 | | 子视图 | 解决问题 | 核心隐喻 |
| --- | --- | --- | | --- | --- | --- |
@@ -23,17 +23,29 @@
| **日历** | 回看昨天、执行今天、预知明天 | 月历网格 + 日状态点 | | **日历** | 回看昨天、执行今天、预知明天 | 月历网格 + 日状态点 |
| **清单** | 出差/回老家等一次性场景的漏项检查 | 勾选清单 + 进度条 | | **清单** | 出差/回老家等一次性场景的漏项检查 | 勾选清单 + 进度条 |
> **Tab 结构(V0.2 终态)**
>
> | 顺序 | Tab | 路由 | 说明 |
> | --- | --- | --- | --- |
> | 1 | **打卡(默认)** | `/checkin` | 根路径 `/` 重定向到此;`/checkin` 内含 打卡/日历/清单 三视图 |
> | 2 | 宏观 | `/macro/*` | 油价 / 经济数据 / 策略研究 / 经济文章 / **报警** / **新闻**(后两者由原「首页」并入) |
> | 3 | 监控 | `/monitor` | 服务器 + 米家环境 |
> | 4 | 我 | `/me` | 用户信息 + 前端 OTA 升级 |
>
> 原「首页」Tab 已移除:其中的**报警**、**新闻** 两个模块并入宏观,作为其第 5、6 个子 Tab(`/macro/alerts`、`/macro/news`),新闻详情改为 `/macro/news/:id` 以保持宏观 Tab 高亮。
**不纳入 V0.2**:多用户共享清单、提醒推送、数据导出、打卡项分组、年视图热力图(见 §9 演进)。 **不纳入 V0.2**:多用户共享清单、提醒推送、数据导出、打卡项分组、年视图热力图(见 §9 演进)。
### 1.2 架构总览 ### 1.2 架构总览
``` ```
┌──────────────────────── 前端 (React + antd-mobile, 打包进 Tauri / OTA) ────────────────────────┐ ┌──────────────────────── 前端 (React + antd-mobile, 打包进 Tauri / OTA) ────────────────────────┐
│ App.jsx 路由 │ │ App.jsx 路由 (默认 / → /checkin) │
│ └ /checkin ──► CheckinPage ── SwipeTabs ──┬── HabitView (打卡) │ │ └ /checkin ──► CheckinPage ── SwipeTabs ──┬── HabitView (打卡) │
│ ├── CalendarView (日历) │ │ ├── CalendarView (日历) │
│ └── ListView (清单) │ │ └── ListView (清单) │
│ 组件: RingProgress(环形) / Celebrate(特效) / BottomNav / TabIcon │ │ └ /macro/* ──► MacroPage ── 油价/经济数据/策略研究/经济文章/报警/新闻 │
│ 组件: RingProgress(环形) / Celebrate(特效, 恒开) / BottomNav / TabIcon │
│ 工具: src/pages/checkin/date.js (本地日期, 与后端同口径) apiClient: src/api/checkin.js │ │ 工具: src/pages/checkin/date.js (本地日期, 与后端同口径) apiClient: src/api/checkin.js │
└──────────────────────────────────┬─────────────────────────────────────────────────────────────┘ └──────────────────────────────────┬─────────────────────────────────────────────────────────────┘
│ HTTPS + JWT (Authorization: Bearer) │ HTTPS + JWT (Authorization: Bearer)
@@ -52,7 +64,7 @@
| 层 | 位置 | 职责 | | 层 | 位置 | 职责 |
| --- | --- | --- | | --- | --- | --- |
| 页面 | `src/pages/checkin/*.jsx` | 视图编排、本地状态、乐观更新与回滚 | | 页面 | `src/pages/checkin/*.jsx` | 视图编排、本地状态、乐观更新与回滚 |
| 组件 | `src/components/RingProgress.jsx` | 纯展示:SVG 环形 + 完成特效 + 特效开关 | | 组件 | `src/components/RingProgress.jsx` | 纯展示:SVG 环形 + 完成特效(恒开,无开关) |
| 日期工具 | `src/pages/checkin/date.js` | 与后端同口径的本地日期运算(**改一处需同步另一处**) | | 日期工具 | `src/pages/checkin/date.js` | 与后端同口径的本地日期运算(**改一处需同步另一处**) |
| API 封装 | `src/api/checkin.js` | 统一走 `api()`(自带 token / 错误解析) | | API 封装 | `src/api/checkin.js` | 统一走 `api()`(自带 token / 错误解析) |
| 路由 | `server/routes/checkin.js`、`checklists.js` | 参数校验、鉴权、组装展示态 | | 路由 | `server/routes/checkin.js`、`checklists.js` | 参数校验、鉴权、组装展示态 |
@@ -72,16 +84,24 @@ server/
index.js # 挂载两个路由 index.js # 挂载两个路由
src/ src/
api/checkin.js # checkinApi / checklistApi api/checkin.js # checkinApi / checklistApi
api/ota.js # OTA 封装 + 判新模式(version/sequence)
utils/version.js # 版本号字段比较(纯函数, 可单测)
utils/format.js # 日期格式化
components/RingProgress.jsx # 环形进度 + Celebrate 特效 components/RingProgress.jsx # 环形进度 + Celebrate 特效
components/BottomNav.jsx # 新增「打卡」Tab components/BottomNav.jsx # Tab: 打卡(默认)/宏观/监控/我
components/TabIcon.jsx # 新增 checkin 图标 components/TabIcon.jsx # 图标集(移除 home)
pages/checkin/CheckinPage.jsx # 容器:三视图切换 pages/checkin/CheckinPage.jsx # 容器:三视图切换
pages/checkin/HabitView.jsx # 打卡视图 pages/checkin/HabitView.jsx # 打卡视图
pages/checkin/CalendarView.jsx # 日历视图 pages/checkin/CalendarView.jsx # 日历视图
pages/checkin/ListView.jsx # 清单视图 pages/checkin/ListView.jsx # 清单视图
pages/checkin/date.js # 前端日期工具 pages/checkin/date.js # 前端日期工具
App.jsx # 新增 /checkin 路由 pages/MacroPage.jsx # 宏观: 6 个子 Tab(含报警/新闻)
pages/macro/AlertsView.jsx # 报警(原首页)
pages/macro/NewsView.jsx # 新闻(原首页)
pages/MePage.jsx # 我: OTA 组内「开发版本」开关
App.jsx # / → /checkin; /macro/*; 移除 /news/:id
styles/globals.css # 打卡相关样式段 styles/globals.css # 打卡相关样式段
scripts/ci/check-version.mjs # CI: 校验分支名 ↔ package.json 版本
docs/V0.2/ # 需求 / 设计 / 数据库设计 / 界面设计 docs/V0.2/ # 需求 / 设计 / 数据库设计 / 界面设计
``` ```
@@ -126,14 +146,19 @@ docs/V0.2/ # 需求 / 设计 / 数据
### 3.1 路由与容器 ### 3.1 路由与容器
``` ```
底部 Tab (BottomNav) 打卡(默认) ─ 宏观 ─ 监控 ─ 我
/ → <Navigate to="/checkin" replace />
/checkin → CheckinPage /checkin → CheckinPage
├─ NavBar「打卡」 ├─ NavBar「打卡」
└─ SwipeTabs(左右滑动切换,与既有模块交互一致) └─ SwipeTabs(左右滑动切换,与既有模块交互一致)
├─ habit → HabitView (受 date 影响) ├─ habit → HabitView (受 date 影响)
├─ calendar → CalendarView (持有选中 date,可回写容器) ├─ calendar → CalendarView (持有选中 date,可回写容器)
└─ list → ListView (独立) └─ list → ListView (独立)
/macro/* → MacroPage (油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻)
``` ```
`activeKeyFromPath()` 对未匹配路径兜底返回**第一个 Tab(`/checkin`)**,与默认路由保持一致。
`date` 状态提升到 `CheckinPage`:日历里点某天 → 切回打卡视图时即为该日,实现「日历与打卡联动」。 `date` 状态提升到 `CheckinPage`:日历里点某天 → 切回打卡视图时即为该日,实现「日历与打卡联动」。
### 3.2 组件树与关键状态 ### 3.2 组件树与关键状态
@@ -144,8 +169,7 @@ CheckinPage
HabitView(date) HabitView(date)
├── data: { today, date, items[], summary:{total,done} } ├── data: { today, date, items[], summary:{total,done} }
├── bursts: { habitId: n } // 特效触发计数(+1 即播放一次) ├── bursts: { habitId: n } // 特效触发计数(+1 即播放一次)
├── form / showForm // 新建/编辑弹窗 └── form / showForm // 新建/编辑弹窗
└── effects: boolean // 特效开关(持久化 localStorage)
CalendarView(date, onDateChange) CalendarView(date, onDateChange)
├── month, cal(月历), detail(当日), bursts ├── month, cal(月历), detail(当日), bursts
└── quickValue: -1|0|1|null // 昨天/今天/明天 分段控件 └── quickValue: -1|0|1|null // 昨天/今天/明天 分段控件
@@ -167,15 +191,15 @@ ListView()
| 动画 | `transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1)`(400ms 级,U-01) | | 动画 | `transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1)`(400ms 级,U-01) |
| 中心内容 | 未达标 → `done / target` 数字;**达标 → 对勾 SVG**(`ring-check-in` 缩放入场);超额 → 右上角 `+n` 徽标 | | 中心内容 | 未达标 → `done / target` 数字;**达标 → 对勾 SVG**(`ring-check-in` 缩放入场);超额 → 右上角 `+n` 徽标 |
| 交互 | 整个环可点 = 打卡(`clickable`),同时卡片右侧保留显式「打卡」按钮,降低发现成本 | | 交互 | 整个环可点 = 打卡(`clickable`),同时卡片右侧保留显式「打卡」按钮,降低发现成本 |
| 无障碍/降级 | `disabled` 态半透明;系统「减弱动效」时特效自动关闭 | | 无障碍/降级 | `disabled` 态半透明;**完成特效恒开**(V0.2 设计变更:取消页面内特效开关,无任何关闭入口) |
**完成特效 `Celebrate`**(F-CK-07 / U-02):`fire` 计数器 +1 即触发一次 → 14 个粒子沿圆周角度扩散(`--a/--d` CSS 变量)+ 主色光环脉冲 0.7s,总时长 ≤900ms,`pointer-events:none` 不阻塞后续操作;同时 `navigator.vibrate(18)` 轻震动(U-03)。开关持久化在 `localStorage.iboard_celebrate`,`prefers-reduced-motion` 自动降级(U-07)。 **完成特效 `Celebrate`**(F-CK-07 / U-02):`fire` 计数器 +1 即触发一次 → 14 个粒子沿圆周角度扩散(`--a/--d` CSS 变量)+ 主色光环脉冲 0.7s,总时长 ≤900ms,`pointer-events:none` 不阻塞后续操作;同时 `navigator.vibrate(18)` 轻震动(U-03)。**特效永远播放**,不再读 `localStorage.iboard_celebrate`,也不再响应 `prefers-reduced-motion` 关闭(用户明确要求「特效永远打开」)。
### 3.4 交互清单 ### 3.4 交互清单
| 视图 | 主要交互 | | 视图 | 主要交互 |
| --- | --- | | --- | --- |
| 打卡 | 点环/点按钮打卡(乐观更新,失败回滚 + Toast)、撤销、编辑、归档、新建弹窗(名称/周期/目标次数/颜色/备注)、特效开关 | | 打卡 | 点环/点按钮打卡(乐观更新,失败回滚 + Toast)、撤销、编辑、归档、新建弹窗(名称/周期/目标次数/颜色/备注) |
| 日历 | 月份左右切换、回到今天、「昨天/今天/明天」分段切换、点日期选中、当日详情内打卡(未来日期只读显示「计划」)、本月完成率 | | 日历 | 月份左右切换、回到今天、「昨天/今天/明天」分段切换、点日期选中、当日详情内打卡(未来日期只读显示「计划」)、本月完成率 |
| 清单 | 进行中/已归档切换、展开/收起、逐条勾选、添加检查项、全选/全不选、复制、存为模板、归档/恢复、删除、从模板新建 | | 清单 | 进行中/已归档切换、展开/收起、逐条勾选、添加检查项、全选/全不选、复制、存为模板、归档/恢复、删除、从模板新建 |
@@ -298,6 +322,7 @@ ListView()
| 层级 | 内容 | 结果 | | 层级 | 内容 | 结果 |
| --- | --- | --- | | --- | --- | --- |
| 单元 | 周期工具 23 条断言(窗口边界、周一起始、月末、闰年、日均期望、streak 跳过未结束周期) | 全通过 | | 单元 | 周期工具 23 条断言(窗口边界、周一起始、月末、闰年、日均期望、streak 跳过未结束周期) | 全通过 |
| 单元 | 版本比较 10 条断言(`-ota` 后缀、`v` 前缀、缺位补 0、`0.10 > 0.9`、`beta.3 < beta.10`、空串) | 全通过 |
| 接口冒烟 | 自签 JWT 端到端:每天 1 次闭合、每周 3 次 33.3%、超额 +1、撤销归零、未来日期 400、月历 31 天、模板生成 11 项、全选完成、复制重置 | 全通过,测试数据已清理 | | 接口冒烟 | 自签 JWT 端到端:每天 1 次闭合、每周 3 次 33.3%、超额 +1、撤销归零、未来日期 400、月历 31 天、模板生成 11 项、全选完成、复制重置 | 全通过,测试数据已清理 |
| 构建 | `npm run build` | 通过 | | 构建 | `npm run build` | 通过 |
| 迁移 | `prisma migrate deploy` 到目标库 | 成功 | | 迁移 | `prisma migrate deploy` 到目标库 | 成功 |
@@ -323,8 +348,8 @@ ListView()
| 周期口径前后端各实现一份 | 前端需离线即时反馈,不能等接口 | 两端算法同源同注释;口径变更必须同步 `period.js` 与 `date.js`(已写入项目记忆) | | 周期口径前后端各实现一份 | 前端需离线即时反馈,不能等接口 | 两端算法同源同注释;口径变更必须同步 `period.js` 与 `date.js`(已写入项目记忆) |
| 进度实时聚合而非冗余计数 | 换取一致性与配置即时生效,牺牲少量 CPU | 单用户数据量极小(月历 30 天 × 20 项),实测无感;V0.3 可加汇总表 | | 进度实时聚合而非冗余计数 | 换取一致性与配置即时生效,牺牲少量 CPU | 单用户数据量极小(月历 30 天 × 20 项),实测无感;V0.3 可加汇总表 |
| 未来日期禁止打卡 | 防止「提前打完一周」刷进度,符合「日历看明天=计划」语义 | 明天视图只读展示「计划」,明确文案 | | 未来日期禁止打卡 | 防止「提前打完一周」刷进度,符合「日历看明天=计划」语义 | 明天视图只读展示「计划」,明确文案 |
| 特效可能打扰 | 部分用户不喜动效 | 一键开关 + `prefers-reduced-motion` 自动降级 | | 特效恒开可能打扰 | 用户明确要求「特效永远打开」,不再提供开关 | 单次特效 ≤900ms 且 `pointer-events:none`,不影响连续打卡;若后续需要再评估恢复开关 |
| 新增第 5 个 Tab | 底部空间变拥挤 | 图标线性简洁;`TabBar` 原生 safe-area,5 项在 375px 下仍在可点区(≥44px) | | Tab 调整为 4 个(打卡置左并默认) | 首页被取消,报警/新闻并入宏观后宏观有 6 个子 Tab | 子 Tab 可横向滚动;报警角标保留在子 Tab 上;底部 4 项在 375px 下热区充足(≥44px) |
--- ---
@@ -336,3 +361,70 @@ ListView()
4. **打卡项分组与排序拖拽**:现有 `sortOrder` 已预留,仅需交互。 4. **打卡项分组与排序拖拽**:现有 `sortOrder` 已预留,仅需交互。
5. **清单协作/分享**:引入 `CheckListShare`。 5. **清单协作/分享**:引入 `CheckListShare`。
6. **数据导出**:清单完成后导出为图片或文本(便于发群里确认)。 6. **数据导出**:清单完成后导出为图片或文本(便于发群里确认)。
---
## 10. 设计变更记录(2026-10-22)
### 10.1 变更清单
| # | 类型 | 变更 | 影响文件 |
| --- | --- | --- | --- |
| 1 | Bug | OTA 包版本与分支不对应:`dev-0.2` 上仍是 `0.1.0` → OTA 报成 `0.1.0-ota`。统一升到 `0.2.0`,OTA 包变为 **`0.2.0-ota`**,并加 CI 强校验 | `package.json`、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`、`Cargo.lock`、`scripts/ci/check-version.mjs`、`docker-build.yml` |
| 2 | 功能 | 我的页 OTA 组新增**「开发版本」Switch**(默认关),切换判新口径:开=`build sequence`,关=版本号字段比较;开关值持久化在设备端 | `src/api/ota.js`、`src/utils/version.js`、`src/pages/MePage.jsx` |
| 3 | 设计 | 打卡 Tab 移到**最左边并默认选中**;打卡页移除**特效开关**,特效永远打开 | `BottomNav.jsx`、`App.jsx`、`TabIcon.jsx`、`HabitView.jsx`、`RingProgress.jsx`、`globals.css` |
| 4 | 设计 | 取消「首页」Tab,其**报警 / 新闻**并入宏观,成为宏观的第 5、6 个子 Tab | `MacroPage.jsx`、新增 `macro/AlertsView.jsx`、`macro/NewsView.jsx`、删除 `HomePage.jsx` |
### 10.2 OTA 版本号为什么必须跟分支走
`scripts/build-ota.mjs` 里 `manifest.version = package.json.version + '-ota'`,也就是**版本号的唯一来源仍是 `package.json`**。
分支按版本命名(`dev-0.2`),但版本号是手工改的,一旦忘改就会出现「分支 0.2、包版本 0.1」的错位,客户端判新逻辑全部失准。
| 环节 | 取值 | 现状 |
| --- | --- | --- |
| `package.json` `version` | `0.2.0` | 唯一手动管理点 |
| OTA `latest.json.version` | `0.2.0-ota` | 打包脚本自动拼 `-ota` 后缀 |
| `src/version.js` `APP_VERSION` | `0.2.0` | 前端展示与版本比较的基准 |
| OTA `latest.json.min_binary_version` | `0.1.0` | **与前端版本解耦**:前端 bundle 要求的最低原生二进制版本。现有 APK 原生层仍是 0.1.0,跟着抬会导致旧 APK 收不到更新,CI 固定传 `--min-binary 0.1.0` |
| CI 守卫 | `scripts/ci/check-version.mjs` | 从 `GITHUB_REF_NAME` 解析 `dev-<major>.<minor>`,要求 `version` 以该前缀开头,否则**构建直接失败**;`main`/`master` 跳过 |
### 10.3 判新口径:版本号 vs build sequence
「我的 → 前端升级 (OTA)」组内新增 `开发版本` 开关(antd-mobile `Switch`),默认**关闭**:
| 开关 | 判新依据 | 实现路径 | 适用场景 |
| --- | --- | --- | --- |
| **关(默认)** | **版本号字段大小** | `GET /api/ota/check/0` 取 `manifest.version` → `compareVersion(manifest.version, 当前运行版本) > 0` 才算新 | 生产口径。旧分支被重新构建(sequence 变大但版本更小)时不会把设备拉回旧版 |
| **开** | **build sequence** | `tauri-plugin-hotswap` 的 `checkUpdate()`(内部拿本地 sequence 请求 `/api/ota/check/:seq`,服务端只回比它大的) | 开发联调。每次构建 sequence 都变,保证任何一次提交都能推到设备上 |
补充约定:
- **持久化**:`localStorage['iboard_ota_dev_mode'] = '1' | '0'`(默认 `'0'`)。值只存在设备端 WebView 存储里,不进数据库、不上报,换设备/清数据即回默认。
- **当前运行版本**:已应用过 OTA 覆盖包时取覆盖包 `version`(插件 `getVersionInfo()`),否则取内置包 `APP_VERSION`;避免「内置包 0.1.0 + 覆盖包 0.2.0」时永远判定有新版本。
- **切换开关会重置检查结果**(`otaState='idle'`、清空 `otaInfo`),避免上一种口径的结论误导用户。
- **Web 端**:浏览器里拿不到本地 sequence。默认口径按版本号比较;开发口径退化为「服务端版本 ≠ 本地版本即算新」(近似 sequence 语义)。Web 端只提示刷新,不下载不替换,与原设计一致。
**版本比较算法**(`src/utils/version.js`,纯函数可单测):
```js
parseVersionFields('0.2.0-ota') // → [0, 2, 0]
compareVersion('0.2.0-ota','0.1.0') // → 1 (0.2.0 更新)
compareVersion('0.2','0.2.0') // → 0 (缺位补 0)
```
规则:按非数字字符切段 → 过滤空段 → 转数字 → 逐段比较,位数不足补 0。单测 10 条覆盖 `-ota` 后缀、`v` 前缀、多位段(`0.10 > 0.9`)、空串、`beta.3 < beta.10`,全部通过。
### 10.4 交互与信息架构变更
| 项 | 变更前 | 变更后 |
| --- | --- | --- |
| 底部 Tab | 首页 / 宏观 / 监控 / 打卡 / 我(5 个) | **打卡 / 宏观 / 监控 / 我(4 个)**,打卡在最左 |
| 默认落地 | `/` → 首页(默认选中新闻) | `/` → **`/checkin`**;`RedirectIfAuthed` 与 404 兜底同样指向 `/checkin` |
| 报警 / 新闻 | 首页内的 2 个 Tab | 宏观的第 5、6 个子 Tab(`/macro/alerts`、`/macro/news`),报警角标保留 |
| 新闻详情 | `/news/:id`(底部无 Tab 高亮) | **`/macro/news/:id`**(宏观 Tab 保持高亮) |
| 打卡页头部 | `[特效开/关]` + `[+ 新建]` | 只保留 `[+ 新建]`;特效恒开 |
| 特效实现 | `effectsEnabled()` 读 `localStorage.iboard_celebrate` + `prefers-reduced-motion` 降级 | 删除开关与降级判断,`Celebrate` 只要 `fire` 变化就播放 |
### 10.5 界面设计同步
`界面设计.html` 已同步:底部 Tab 改为 4 项且「打卡」高亮在首位、打卡页去掉特效开关按钮、新增「我的 · OTA 开发版本开关」屏、宏观新增「报警 / 新闻」子 Tab 与合并后的 6 Tab 导航。
+22 -9
View File
@@ -117,7 +117,7 @@
| 编号 | 需求描述 | 验收要点 | 优先级 | | 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| F-SY-01 | 底部 TabBar 新增**「打卡」页** 和 **「清单」页** | 图标风格与现有 TabIcon 一致 | P0 | | F-SY-01 | 底部 TabBar 新增**「打卡」页**,并作为**默认首页放在最左**;「清单」不单独占 Tab,作为打卡页内的第三个子视图 | 图标风格与现有 TabIcon 一致;启动即进入 `/checkin` | P0 |
| F-SY-02 | 「打卡」页内集成 打卡/日历 两个功能,日历放在上半截,默认显示一行为周视图,下拉显示为月视图。 | 日历中选择时间与下部打卡任务联动 | P0 | | F-SY-02 | 「打卡」页内集成 打卡/日历 两个功能,日历放在上半截,默认显示一行为周视图,下拉显示为月视图。 | 日历中选择时间与下部打卡任务联动 | P0 |
| F-SY-03 | 所有接口复用现有鉴权(`authRequired`,JWT),数据按 `userId` 隔离 | 未登录跳转 `/login` | P0 | | F-SY-03 | 所有接口复用现有鉴权(`authRequired`,JWT),数据按 `userId` 隔离 | 未登录跳转 `/login` | P0 |
| F-SY-04 | 后端新增 REST 路由(建议 `/api/checkin/*`、`/api/checklist/*`),挂载到 `server/index.js` | 与现有路由风格一致 | P0 | | F-SY-04 | 后端新增 REST 路由(建议 `/api/checkin/*`、`/api/checklist/*`),挂载到 `server/index.js` | 与现有路由风格一致 | P0 |
@@ -132,11 +132,11 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| U-01 | **环形进度组件**:SVG 圆环,背景环 + 进度环,进度变化带 400ms ease-out 动画 | 组件可复用(打卡卡片、清单进度共用) | P0 | | U-01 | **环形进度组件**:SVG 圆环,背景环 + 进度环,进度变化带 400ms ease-out 动画 | 组件可复用(打卡卡片、清单进度共用) | P0 |
| U-02 | **完成特效**:达标瞬间环形闭合 + 光晕/粒子扩散,约 600ms;可重复观看不烦扰 | 不阻塞操作,动画结束自动清理 | P0 | | U-02 | **完成特效**:达标瞬间环形闭合 + 光晕/粒子扩散,约 600ms;可重复观看不烦扰 | 不阻塞操作,动画结束自动清理 | P0 |
| U-03 | **触感反馈**:完成打卡时轻量震动(Web Vibration API / Tauri),可在设置中关闭 | 默认开启,关闭后仅视觉反馈 | P1 | | U-03 | **触感反馈**:完成打卡时轻量震动(Web Vibration API / Tauri) | 固定开启,与特效一同触发 | P1 |
| U-04 | 移动端适配:375~430px 视口,复用 `globals.css` 的 `.card` 体系,安全区与底部 TabBar 不遮挡内容 | 真机无横向滚动、无遮挡 | P0 | | U-04 | 移动端适配:375~430px 视口,复用 `globals.css` 的 `.card` 体系,安全区与底部 TabBar 不遮挡内容 | 真机无横向滚动、无遮挡 | P0 |
| U-05 | 空态 / 加载态 / 错误态三态齐全(首次使用引导"创建第一个打卡项") | 空态有明确 CTA | P0 | | U-05 | 空态 / 加载态 / 错误态三态齐全(首次使用引导"创建第一个打卡项") | 空态有明确 CTA | P0 |
| U-06 | 打卡按钮足够大(点击热区 ≥ 44×44),符合移动端触控规范 | 误触率低 | P1 | | U-06 | 打卡按钮足够大(点击热区 ≥ 44×44),符合移动端触控规范 | 误触率低 | P1 |
| U-07 | 动效**性能降级**:低端设备或 `prefers-reduced-motion` 时关闭粒子特效,仅保留进度动画 | 不卡顿 | P1 | | U-07 | 动效**性能降级**:特效保持轻量(单次 ≤900ms、`pointer-events:none`),不给低端机造成卡顿 | 不卡顿、不阻塞操作 | P1 |
| U-08 | 深浅色:跟随现有浅色主题,卡片/分割线风格与首页、监控页一致 | 视觉统一 | P1 | | U-08 | 深浅色:跟随现有浅色主题,卡片/分割线风格与首页、监控页一致 | 视觉统一 | P1 |
| U-09 | 清单勾选有明确的勾选动画(勾形描边动画) | 反馈即时 | P2 | | U-09 | 清单勾选有明确的勾选动画(勾形描边动画) | 反馈即时 | P2 |
@@ -174,12 +174,23 @@
| E-07 | 清单项全部删除后清单进度为 0/0 | 显示空清单,不显示 100% | P1 | | E-07 | 清单项全部删除后清单进度为 0/0 | 显示空清单,不显示 100% | P1 |
| E-08 | 跨零点打卡(23:59 → 00:00) | 以**提交时刻的本地日历日**归属 | P0 | | E-08 | 跨零点打卡(23:59 → 00:00) | 以**提交时刻的本地日历日**归属 | P0 |
### 3.9 功能需求 — 版本与升级(F-OT,2026-10-22 追加)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| F-OT-01 | **OTA 包版本必须与分支对应**:`dev-0.2` 分支上 OTA 包版本应为 `0.2.0-ota` | `GET /api/ota/check/0` 返回 `version = 0.2.0-ota` | P0 |
| F-OT-02 | 版本号错位在 CI 阶段就拦住:分支名 `dev-x.y` 与 `package.json` 版本主次号不一致时构建失败 | CI 输出明确报错并中断 | P0 |
| F-OT-03 | 「我」页 OTA 组内新增**「开发版本」开关**,默认**关闭** | 关闭=按版本号比较;打开=按 build sequence 比较 | P0 |
| F-OT-04 | 开关值**持久化在设备端**,重启 App 后保持上次选择 | 杀进程重进仍为上次状态 | P0 |
| F-OT-05 | 版本号比较规则:按非数字字符切成数字字段,逐段比较大小(`0.2.0 > 0.1.0`,`0.10 > 0.9`) | 单测覆盖后缀/前缀/缺位/多位段 | P0 |
| F-OT-06 | 切换开关后清空上一次的检查结果,避免误判 | 切换即回到「未检查」状态 | P1 |
--- ---
## 4. 优先级汇总(本期交付范围) ## 4. 优先级汇总(本期交付范围)
- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。 - **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;F-OT-01~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。
- **P1(应该)**:F-CK-09、10、12~14;F-CL-09;F-LS-05~09;F-SY-06~08;U-03、U-06~08;D-06;N-03;E-03、E-06、E-07。 - **P1(应该)**:F-CK-09、10、12~14;F-CL-09;F-LS-05~09;F-SY-06~08;F-OT-06;U-03、U-06~08;D-06;N-03;E-03、E-06、E-07。
- **P2(可选/延后)**:F-CK-15~17;F-CL-10、11;F-LS-10、11;U-09。 - **P2(可选/延后)**:F-CK-15~17;F-CL-10、11;F-LS-10、11;U-09。
--- ---
@@ -200,12 +211,12 @@
| # | 问题 | 建议 | | # | 问题 | 建议 |
| --- | --- | --- | | --- | --- | --- |
| 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | 建议新增 Tab,入口最清晰 | | 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | ✅ **已决策(2026-10-22)**:「打卡」作为**默认首页放在最左**;原来的「首页」取消,报警/新闻并入宏观 |
| 2 | 周起始日是周一还是周日? | 建议周一(国内习惯) | | 2 | 周起始日是周一还是周日? | 建议周一(国内习惯) |
| 3 | 是否允许"明天提前打卡"? | 建议**不允许**(F-CK-11),保持统计口径干净 | | 3 | 是否允许"明天提前打卡"? | 建议**不允许**(F-CK-11),保持统计口径干净 |
| 4 | 删除打卡项时历史是否保留? | 建议默认归档保留,硬删除才清历史 | | 4 | 删除打卡项时历史是否保留? | 建议默认归档保留,硬删除才清历史 |
| 5 | 预置模板具体包含哪些项? | 评审时列出"出差/回老家"各 8~12 条初始项 | | 5 | 预置模板具体包含哪些项? | 评审时列出"出差/回老家"各 8~12 条初始项 |
| 6 | 完成特效是否可全局关闭? | 建议在"我"页提供开关(默认开) | | 6 | 完成特效是否可全局关闭? | ✅ **已决策(2026-10-22)**:不提供关闭入口,**特效永远打开**;页面内不再有特效开关 |
--- ---
@@ -308,8 +319,10 @@ model CheckListTemplate {
| B-6 | **撤销语义** | 撤销删除本周期内"最近一次"打卡(按 date desc、seq desc 取第一条) | | B-6 | **撤销语义** | 撤销删除本周期内"最近一次"打卡(按 date desc、seq desc 取第一条) |
| B-7 | **删除打卡项** | 默认归档(`archived=true`,历史保留);`?hard=1` 才物理删除并清记录 | | B-7 | **删除打卡项** | 默认归档(`archived=true`,历史保留);`?hard=1` 才物理删除并清记录 |
| B-8 | **接口前缀** | 打卡 `/api/checkin/*`、清单 `/api/checklists/*`,全部 `authRequired`,数据按 `userId` 隔离 | | B-8 | **接口前缀** | 打卡 `/api/checkin/*`、清单 `/api/checklists/*`,全部 `authRequired`,数据按 `userId` 隔离 |
| B-9 | **前端入口** | 底部新增第 5 个 Tab「打卡」→ `/checkin`,页内三视图:打卡 / 日历 / 清单 | | B-9 | **前端入口** | 底部 Tab 4 个:**打卡(最左,默认选中)** / 宏观 / 监控 / 我;`/` 重定向到 `/checkin`,页内三视图:打卡 / 日历 / 清单。原「首页」取消,报警/新闻并入宏观 |
| B-10 | **特效开关** | 完成特效 + 触感反馈受 `localStorage iboard_celebrate` 控制(默认开),系统开启"减弱动效"时自动降级 | | B-10 | **特效** | 完成特效 + 触感反馈**恒开**,页面内不提供开关(2026-10-22 变更,原 `localStorage iboard_celebrate` 机制已移除) |
| B-11 | **OTA 版本** | OTA 包 `version = package.json.version + '-ota'`,必须与分支一致(`dev-0.2` → `0.2.0-ota`),CI 有强校验 |
| B-12 | **OTA 判新** | 我的页「开发版本」开关(默认关,持久化 `localStorage.iboard_ota_dev_mode`):关=版本号字段比较,开=build sequence 比较 |
### 接口清单 ### 接口清单
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "iboard", "name": "iboard",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"tauri": "tauri", "tauri": "tauri",
+21 -2
View File
@@ -1,9 +1,10 @@
# iBoard # iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。 用于收集和整理经济数据,但是以策略为主体重新组织。
## 版本管理 (V0.1) ## 版本管理
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。 版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`)。
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`(CI 有 `scripts/ci/check-version.mjs` 强校验,不一致直接失败)。
发版时只改这一处,以下位置必须保持一致(改版本时一并核对): 发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
| 文件 | 字段 | 说明 | | 文件 | 字段 | 说明 |
@@ -11,9 +12,12 @@
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 | | `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) | | `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 | | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
说明: 说明:
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。 - `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
- OTA 升级包的 `version` 字段 = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
所以版本号错位会直接导致线上 OTA 判新失准。
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。 - `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。 - `ota-package/latest.json` 为运行时产物,不纳入版本管理。
@@ -123,6 +127,21 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**. > **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物. > 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 端
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新". Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// CI 专用: 校验分支名与 package.json 版本号一致
//
// 背景:
// OTA 升级包的 version 直接取自 package.json (`<version>-ota`), 而分支按版本命名
// (dev-0.1 / dev-0.2 ...). 两者一旦错位 (例如 dev-0.2 上 package.json 还是 0.1.0),
// 线上 OTA 就会报成上一个版本的号, 客户端判新逻辑全部失准。
//
// 规则:
// - 分支形如 dev-<major>.<minor> → package.json 的 version 必须以 <major>.<minor> 开头
// - 分支形如 dev-<major> → version 必须以 <major>. 开头
// - main / master 及其它分支 → 跳过校验 (生产分支不做约束, 只打印当前版本)
//
// 环境变量:
// GITHUB_REF_NAME - Gitea Actions 提供的分支名 (本地运行可用 BRANCH 覆盖)
//
// 退出码: 0 通过 / 1 不一致
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(__dirname, '..', '..')
const branch = process.env.GITHUB_REF_NAME || process.env.BRANCH || ''
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const version = String(pkg.version || '0.0.0')
const otaVersion = version + '-ota'
console.log('[check-version] 分支: ', branch || '(未知)')
console.log('[check-version] package.json version:', version)
console.log('[check-version] OTA 包版本将是:', otaVersion)
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
if (!m) {
console.log('[check-version] 非 dev-<版本> 分支, 跳过一致性校验')
process.exit(0)
}
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
if (!version.startsWith(want)) {
console.error('')
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`)
console.error('[check-version] 请修改 package.json 的 version (唯一手动管理点),')
console.error('[check-version] 并同步 src-tauri/tauri.conf.json 与 src-tauri/Cargo.toml')
process.exit(1)
}
console.log(`[check-version] 一致 (${branch} ↔ ${version})`)
+1 -1
View File
@@ -1456,7 +1456,7 @@ dependencies = [
[[package]] [[package]]
name = "iboard" name = "iboard"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "iboard" name = "iboard"
version = "0.1.0" version = "0.2.0"
description = "iBoard - 经济数据与策略" description = "iBoard - 经济数据与策略"
authors = ["iBoard Team"] authors = ["iBoard Team"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "iBoard", "productName": "iBoard",
"version": "0.1.0", "version": "0.2.0",
"identifier": "com.iboard.app", "identifier": "com.iboard.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+10 -9
View File
@@ -1,8 +1,6 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import MobileShell from './layouts/MobileShell.jsx' import MobileShell from './layouts/MobileShell.jsx'
import LoginPage from './pages/LoginPage.jsx' import LoginPage from './pages/LoginPage.jsx'
import HomePage from './pages/HomePage.jsx'
import NewsDetail from './pages/news/NewsDetail.jsx'
import MacroPage from './pages/MacroPage.jsx' import MacroPage from './pages/MacroPage.jsx'
import MonitorPage from './pages/MonitorPage.jsx' import MonitorPage from './pages/MonitorPage.jsx'
import ServerDetail from './pages/monitor/ServerDetail.jsx' import ServerDetail from './pages/monitor/ServerDetail.jsx'
@@ -10,11 +8,14 @@ import MePage from './pages/MePage.jsx'
import CheckinPage from './pages/checkin/CheckinPage.jsx' import CheckinPage from './pages/checkin/CheckinPage.jsx'
import { useAuth } from './auth/AuthContext.jsx' import { useAuth } from './auth/AuthContext.jsx'
// 已登录访问 /login 自动回首页 // 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
const HOME_PATH = '/checkin'
// 已登录访问 /login 自动回默认页
function RedirectIfAuthed({ children }) { function RedirectIfAuthed({ children }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) return null if (loading) return null
if (user) return <Navigate to="/" replace /> if (user) return <Navigate to={HOME_PATH} replace />
return children return children
} }
@@ -32,16 +33,16 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} /> <Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
<Route element={<RequireAuth><MobileShell /></RequireAuth>}> <Route element={<RequireAuth><MobileShell /></RequireAuth>}>
<Route path="/" element={<HomePage />} /> {/* 打卡为默认首页 */}
<Route path="/news/:id" element={<NewsDetail />} /> <Route path="/" element={<Navigate to={HOME_PATH} replace />} />
<Route path="/checkin" element={<CheckinPage />} />
{/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */}
<Route path="/macro/*" element={<MacroPage />} /> <Route path="/macro/*" element={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} /> <Route path="/monitor" element={<MonitorPage />} />
<Route path="/monitor/server/:uuid" element={<ServerDetail />} /> <Route path="/monitor/server/:uuid" element={<ServerDetail />} />
{/* V0.2 打卡 / 日历 / 清单 */}
<Route path="/checkin" element={<CheckinPage />} />
<Route path="/me" element={<MePage />} /> <Route path="/me" element={<MePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to={HOME_PATH} replace />} />
</Routes> </Routes>
) )
} }
+99 -17
View File
@@ -5,7 +5,7 @@
// 设计原则: // 设计原则:
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 // - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 // - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
import { getApiBase, getToken } from './client.js' import { getApiBase } from './client.js'
import { APP_VERSION, BUILD_ID } from '../version.js' import { APP_VERSION, BUILD_ID } from '../version.js'
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) // 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
@@ -33,6 +33,47 @@ export function initOta() {
.catch((e) => console.warn('[ota] notifyReady failed:', e)) .catch((e) => console.warn('[ota] notifyReady failed:', e))
} }
// ---------------------------------------------------------------------------
// 判新模式 (V0.2)
// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
// ---------------------------------------------------------------------------
const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
export function getOtaDevMode() {
try {
return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
} catch { /* ignore */ }
return false
}
export function setOtaDevMode(v) {
try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
}
// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
import { compareVersion, parseVersionFields } from '../utils/version.js'
export { compareVersion, parseVersionFields }
// 拉服务端最新 manifest (不分平台)
// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
export async function fetchOtaManifest() {
try {
const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
if (res.status === 204) return null
if (!res.ok) return null
return await res.json()
} catch (e) {
console.warn('[ota] manifest 拉取失败:', e)
return null
}
}
// 检查更新 // 检查更新
// 返回统一结构, 调用方不关心是 Tauri 还是 web: // 返回统一结构, 调用方不关心是 Tauri 还是 web:
// { // {
@@ -40,34 +81,71 @@ export function initOta() {
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', // reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
// info: { version, sequence, notes, mandatory, bundle_size } // info: { version, sequence, notes, mandatory, bundle_size }
// } // }
export async function checkOtaUpdate() { // opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
export async function checkOtaUpdate(opts) {
const devMode = !!(opts && opts.devMode)
if (!inTauri) { if (!inTauri) {
return checkOtaUpdateWeb() return checkOtaUpdateWeb(devMode)
} }
return checkOtaUpdateTauri() return checkOtaUpdateTauri(devMode)
} }
async function checkOtaUpdateTauri() { // 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
// 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数) async function currentRunningVersion() {
const { checkUpdate } = await import('tauri-plugin-hotswap-api') try {
const r = await checkUpdate() const v = await getOtaVersionInfo()
if (!r.available) { if (v?.active && v.version) return v.version
return { available: false, reason: 'no_package', info: r } } catch { /* ignore */ }
return APP_VERSION
}
// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
async function checkByVersion() {
const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
if (!manifest || !manifest.version) {
return { available: false, reason: 'no_package', info: null }
}
if (compareVersion(manifest.version, current) <= 0) {
return { available: false, reason: 'same_version', info: manifest }
} }
return { return {
available: true, available: true,
reason: 'ok', reason: 'ok',
info: { info: {
version: r.version, version: manifest.version,
sequence: r.sequence, sequence: manifest.sequence,
notes: r.notes, notes: manifest.notes || '',
mandatory: r.mandatory, mandatory: !!manifest.mandatory,
bundle_size: r.bundle_size, bundle_size: manifest.bundle_size || 0,
}, },
} }
} }
async function checkOtaUpdateWeb() { async function checkOtaUpdateTauri(devMode) {
// 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
if (devMode) {
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
const r = await checkUpdate()
if (!r.available) {
return { available: false, reason: 'no_package', info: r }
}
return {
available: true,
reason: 'ok',
info: {
version: r.version,
sequence: r.sequence,
notes: r.notes,
mandatory: r.mandatory,
bundle_size: r.bundle_size,
},
}
}
// 生产口径: 版本号判新
return checkByVersion()
}
async function checkOtaUpdateWeb(devMode) {
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新 // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
try { try {
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' }) const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
@@ -76,7 +154,11 @@ async function checkOtaUpdateWeb() {
} }
const data = await res.json() const data = await res.json()
if (!data.available) return { available: false, reason: 'no_package', info: data } if (!data.available) return { available: false, reason: 'no_package', info: data }
if (data.build_id && data.build_id === BUILD_ID) { // Web 端拿不到本地 sequence:
// 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
// 生产口径 → 严格比较版本号大小, 只有更高版本才算新
const cmp = compareVersion(data.version, APP_VERSION)
if (devMode ? cmp === 0 : cmp <= 0) {
return { available: false, reason: 'same_version', info: data } return { available: false, reason: 'same_version', info: data }
} }
return { return {
+6 -7
View File
@@ -4,24 +4,23 @@ import TabIcon from './TabIcon.jsx'
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名 // 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格 // 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
const TABS = [ const TABS = [
{ key: '/', name: 'home', label: '首页' }, { key: '/checkin', name: 'checkin', label: '打卡' },
{ key: '/macro', name: 'macro', label: '宏观' }, { key: '/macro', name: 'macro', label: '宏观' },
{ key: '/monitor', name: 'monitor', label: '监控' }, { key: '/monitor', name: 'monitor', label: '监控' },
{ key: '/checkin', name: 'checkin', label: '打卡' },
{ key: '/me', name: 'me', label: '我' }, { key: '/me', name: 'me', label: '我' },
] ]
// 根据当前路径计算激活的 tab key (精确匹配 '/' , 其他前缀匹配) // 根据当前路径计算激活的 tab key (精确匹配 + 前缀匹配)
function activeKeyFromPath(pathname) { function activeKeyFromPath(pathname) {
for (const t of TABS) { for (const t of TABS) {
if (t.key === '/') { if (pathname === t.key || pathname.startsWith(t.key + '/')) {
if (pathname === '/') return t.key
} else if (pathname === t.key || pathname.startsWith(t.key + '/')) {
return t.key return t.key
} }
} }
return '/' // 未知路径兜底到第一个 Tab (打卡)
return TABS[0].key
} }
// 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区 // 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区
+1 -20
View File
@@ -69,7 +69,7 @@ export default function RingProgress({
// 完成特效 (F-CK-07, U-02/U-03/U-07) // 完成特效 (F-CK-07, U-02/U-03/U-07)
// props: fire 计数器(每次 +1 触发一次), color 主色 // props: fire 计数器(每次 +1 触发一次), color 主色
// 尊重"特效开关": localStorage iboard_celebrate === '0' 时不播放 // V0.2 设计变更: 去掉特效开关, 达标特效**恒开** (没有任何关闭入口)
export function Celebrate({ fire = 0, color = '#1677ff' }) { export function Celebrate({ fire = 0, color = '#1677ff' }) {
const [burst, setBurst] = useState(0) const [burst, setBurst] = useState(0)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
@@ -80,7 +80,6 @@ export function Celebrate({ fire = 0, color = '#1677ff' }) {
if (fire === prev.current) return if (fire === prev.current) return
prev.current = fire prev.current = fire
if (fire <= 0) return if (fire <= 0) return
if (!effectsEnabled()) return
setBurst((n) => n + 1) setBurst((n) => n + 1)
setVisible(true) setVisible(true)
haptic() haptic()
@@ -113,24 +112,6 @@ export function Celebrate({ fire = 0, color = '#1677ff' }) {
) )
} }
// 特效全局开关 (默认开)
export function effectsEnabled() {
try {
if (localStorage.getItem('iboard_celebrate') === '0') return false
} catch { /* ignore */ }
// 系统"减弱动效"偏好时降级
if (typeof window !== 'undefined' && window.matchMedia) {
try {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
} catch { /* ignore */ }
}
return true
}
export function setEffectsEnabled(v) {
try { localStorage.setItem('iboard_celebrate', v ? '1' : '0') } catch { /* ignore */ }
}
// 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持) // 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持)
export function haptic(ms = 18) { export function haptic(ms = 18) {
try { try {
+1 -7
View File
@@ -2,13 +2,7 @@
// props: name 字符串, active 布尔 // props: name 字符串, active 布尔
// 返回对应的 24x24 SVG 节点 // 返回对应的 24x24 SVG 节点
const ICONS = { const ICONS = {
home: (active) => ( // V0.2: 打卡移到最左, 原「首页」已并入宏观, home 图标不再使用
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 11l9-7 9 7" />
<path d="M5 10v10h14V10" />
{active ? <path d="M10 20v-5h4v5" fill="currentColor" stroke="none" /> : null}
</svg>
),
macro: (active) => ( macro: (active) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} /> <rect x="3" y="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
-231
View File
@@ -1,231 +0,0 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
import { api } from '../api/client.js'
import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
// 首页: 报警模块 + 新闻模块
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
// - 新闻: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻 (按时间倒序)
// - 两个模块均无限滚动, 触底自动加载下一页
function fmtDateTime(s) {
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n) => String(n).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n) => String(n).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
export default function HomePage() {
const [active, setActive] = useState('news') // 默认选中新闻
const [alertCount, setAlertCount] = useState(0)
const handleAlertCount = useCallback((n) => setAlertCount(n), [])
const tabs = [
{
key: 'alerts',
label: (
<span className="tab-label">
报警
{alertCount > 0 && <Badge content={alertCount} />}
</span>
),
render: () => <AlertSection onCount={handleAlertCount} />,
},
{ key: 'news', label: '新闻', render: () => <NewsSection /> },
]
return (
<div className="page-with-navbar">
<NavBar back={null} />
<div className="home-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
</div>
)
}
// 报警模块
function AlertSection({ onCount }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const fetchPage = useCallback(async () => {
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
const data = await api(url)
setItems((prev) => prev.concat(data.items || []))
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
if (!data.nextCursor) setDone(true)
} catch (e) {
console.error('alerts load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [done, onCount])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/alerts?limit=10')
setItems(data.items || [])
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('alerts reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [onCount])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
if (!el) return
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) fetchPage()
}, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [items.length, done, fetchPage])
return (
<section className="home-section">
<div className="section-title">报警</div>
{items.length === 0 && !loading ? (
<Empty description="暂无报警" />
) : (
<div className="section-list">
{items.map((a) => (
<Card key={a.id} className="alert-card">
<div className="alert-title">
<span className="alert-name">{a.indicatorName}</span>
<span className="alert-period">{a.period}</span>
</div>
<div className="alert-diff">
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
</div>
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
</Card>
))}
<div ref={sentinelRef} className="scroll-sentinel">
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
</div>
</div>
)}
</section>
)
}
// 新闻模块
function NewsSection() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const navigate = useNavigate()
const fetchPage = useCallback(async () => {
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
const data = await api(url)
setItems((prev) => prev.concat(data.items || []))
cursorRef.current = data.nextCursor
if (!data.nextCursor) setDone(true)
} catch (e) {
console.error('news load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [done])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/news?limit=10')
setItems(data.items || [])
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('news reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
if (!el) return
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) fetchPage()
}, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [items.length, done, fetchPage])
return (
<section className="home-section">
<div className="section-title">新闻</div>
{items.length === 0 && !loading ? (
<Empty description="暂无新闻" />
) : (
<div className="section-list">
{items.map((n) => (
<Card key={n.id} className="news-card" onClick={() => navigate('/news/' + n.id)}>
<div className="news-title">{n.title}</div>
{n.summary && <div className="news-summary">{n.summary}</div>}
<div className="news-meta">
<span className="news-source">{n.source || '未知来源'}</span>
<span className="news-time">{fmtDate(n.publishedAt)}</span>
<span className="news-rank">#{n.impactRank}</span>
</div>
</Card>
))}
<div ref={sentinelRef} className="scroll-sentinel">
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
</div>
</div>
)}
</section>
)
}
+25 -9
View File
@@ -1,25 +1,32 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom' import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
import { NavBar } from 'antd-mobile' import { Badge, NavBar } from 'antd-mobile'
import SwipeTabs from '../components/SwipeTabs.jsx' import SwipeTabs from '../components/SwipeTabs.jsx'
import DataView from './macro/DataView.jsx' import DataView from './macro/DataView.jsx'
import StrategyView from './macro/StrategyView.jsx' import StrategyView from './macro/StrategyView.jsx'
import ArticlesView from './macro/ArticlesView.jsx' import ArticlesView from './macro/ArticlesView.jsx'
import ArticleDetail from './macro/ArticleDetail.jsx' import ArticleDetail from './macro/ArticleDetail.jsx'
import OilPage from './macro/OilPage.jsx' import OilPage from './macro/OilPage.jsx'
import AlertsView from './macro/AlertsView.jsx'
import NewsView from './macro/NewsView.jsx'
import NewsDetail from './news/NewsDetail.jsx'
// 宏观: 顶栏 4 tab (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价 // 宏观: 顶栏 6 tab, 默认 油价
// /macro → 显示 oil tab (本组件 activeFromPath 决定) // /macro → 显示 oil tab (本组件 activeFromPath 决定)
// /macro/oil → 油价 (新增) // /macro/oil → 油价
// /macro/data → 经济数据 // /macro/data → 经济数据
// /macro/strategy → 策略研究 // /macro/strategy → 策略研究
// /macro/articles → 经济文章 // /macro/articles → 经济文章
// /macro/articles/:slug → 文章详情 // /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来)
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来)
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
const TABS = [ const TABS = [
{ key: 'oil', label: '油价', path: '/macro/oil' }, { key: 'oil', label: '油价', path: '/macro/oil' },
{ key: 'data', label: '经济数据', path: '/macro/data' }, { key: 'data', label: '经济数据', path: '/macro/data' },
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' }, { key: 'strategy', label: '策略研究', path: '/macro/strategy' },
{ key: 'articles', label: '经济文章', path: '/macro/articles' }, { key: 'articles', label: '经济文章', path: '/macro/articles' },
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
{ key: 'news', label: '新闻', path: '/macro/news' },
] ]
function activeFromPath(pathname) { function activeFromPath(pathname) {
@@ -27,7 +34,9 @@ function activeFromPath(pathname) {
if (pathname.startsWith('/macro/articles')) return 'articles' if (pathname.startsWith('/macro/articles')) return 'articles'
if (pathname.startsWith('/macro/strategy')) return 'strategy' if (pathname.startsWith('/macro/strategy')) return 'strategy'
if (pathname.startsWith('/macro/data')) return 'data' if (pathname.startsWith('/macro/data')) return 'data'
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价) if (pathname.startsWith('/macro/alerts')) return 'alerts'
if (pathname.startsWith('/macro/news')) return 'news'
// 父路由 /macro 走 Tab 列表第一个 (油价)
return 'oil' return 'oil'
} }
@@ -35,15 +44,21 @@ export default function MacroPage() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const active = activeFromPath(location.pathname) const active = activeFromPath(location.pathname)
// 报警角标: 由 AlertsView 回传总数
const [alertCount, setAlertCount] = useState(0)
const tabs = TABS.map((t) => ({ const tabs = TABS.map((t) => ({
key: t.key, key: t.key,
label: t.label, label: t.key === 'alerts'
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
: t.label,
render: () => { render: () => {
if (t.key === 'oil') return <OilPage /> if (t.key === 'oil') return <OilPage />
if (t.key === 'data') return <DataView /> if (t.key === 'data') return <DataView />
if (t.key === 'strategy') return <StrategyView /> if (t.key === 'strategy') return <StrategyView />
if (t.key === 'articles') return <ArticlesView /> if (t.key === 'articles') return <ArticlesView />
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
if (t.key === 'news') return <NewsView />
return null return null
}, },
})) }))
@@ -53,13 +68,14 @@ export default function MacroPage() {
if (next) navigate(next.path) if (next) navigate(next.path)
} }
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏 // 详情页单独走 React Router 渲染, 不参与滑屏
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) { if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
return ( return (
<Routes> <Routes>
<Route path="data/:name" element={<DataView />} /> <Route path="data/:name" element={<DataView />} />
<Route path="strategy/:name" element={<StrategyView />} /> <Route path="strategy/:name" element={<StrategyView />} />
<Route path="articles/:slug" element={<ArticleDetail />} /> <Route path="articles/:slug" element={<ArticleDetail />} />
<Route path="news/:id" element={<NewsDetail />} />
</Routes> </Routes>
) )
} }
+30 -2
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Avatar, Button, List, NavBar, ProgressBar, Toast } from 'antd-mobile' import { Avatar, Button, List, NavBar, ProgressBar, Switch, Toast } from 'antd-mobile'
import { useAuth } from '../auth/AuthContext.jsx' import { useAuth } from '../auth/AuthContext.jsx'
import { APP_VERSION, BUILD_ID } from '../version.js' import { APP_VERSION, BUILD_ID } from '../version.js'
import { import {
applyOtaUpdate, applyOtaUpdate,
checkOtaUpdate, checkOtaUpdate,
getCurrentVersionInfo, getCurrentVersionInfo,
getOtaDevMode,
getOtaVersionInfo, getOtaVersionInfo,
reloadApp, reloadApp,
rollbackOta, rollbackOta,
setOtaDevMode,
} from '../api/ota.js' } from '../api/ota.js'
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 // "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
@@ -29,6 +31,8 @@ export default function MePage() {
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
const [progress, setProgress] = useState({ received: 0, total: 0 }) const [progress, setProgress] = useState({ received: 0, total: 0 })
const [error, setError] = useState('') const [error, setError] = useState('')
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
const [devMode, setDevMode] = useState(() => getOtaDevMode())
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓) // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
useEffect(() => { useEffect(() => {
@@ -43,7 +47,7 @@ export default function MePage() {
setError('') setError('')
setOtaState('checking') setOtaState('checking')
try { try {
const r = await checkOtaUpdate() const r = await checkOtaUpdate({ devMode })
if (r.available) { if (r.available) {
setOtaInfo(r.info) setOtaInfo(r.info)
setOtaState('available') setOtaState('available')
@@ -130,6 +134,30 @@ export default function MePage() {
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}> <List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
运行资源 运行资源
</List.Item> </List.Item>
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
<List.Item
extra={
<Switch
checked={devMode}
onChange={(v) => {
setDevMode(v)
setOtaDevMode(v)
// 切换口径后旧检查结果失效, 重置避免误导
setOtaState('idle')
setOtaInfo(null)
setError('')
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
}}
/>
}
description={
devMode
? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
: '按版本号判新:仅当服务端版本号更高时才提示更新'
}
>
开发版本
</List.Item>
<List.Item> <List.Item>
<div className="me-ota-actions"> <div className="me-ota-actions">
<Button <Button
+1 -9
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
} from 'antd-mobile' } from 'antd-mobile'
import RingProgress, { Celebrate, effectsEnabled, setEffectsEnabled } from '../../components/RingProgress.jsx' import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js' import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { toDateStr, dayLabel } from './date.js' import { toDateStr, dayLabel } from './date.js'
@@ -25,7 +25,6 @@ export default function HabitView({ date, onDateChange }) {
const [bursts, setBursts] = useState({}) // { habitId: 触发计数 } const [bursts, setBursts] = useState({}) // { habitId: 触发计数 }
const [form, setForm] = useState(EMPTY_FORM) const [form, setForm] = useState(EMPTY_FORM)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [effects, setEffects] = useState(effectsEnabled())
const load = useCallback((silent) => { const load = useCallback((silent) => {
if (!silent) setErr('') if (!silent) setErr('')
@@ -145,13 +144,6 @@ export default function HabitView({ date, onDateChange }) {
</div> </div>
</div> </div>
<div className="habit-head-right"> <div className="habit-head-right">
<Button size="mini" fill="outline" color="primary" onClick={() => setEffects((v) => {
const next = !v
setEffectsEnabled(next)
return next
})}>
{effects ? '特效开' : '特效关'}
</Button>
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}> <Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
+ 新建 + 新建
</Button> </Button>
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Card, DotLoading, Empty } from 'antd-mobile'
import { api } from '../../api/client.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { fmtDateTime } from '../../utils/format.js'
// 报警 (V0.2 从「首页」并入「宏观」Tab)
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序
// - 无限滚动: 触底自动加载下一页
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标)
export default function AlertsView({ onCount }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const fetchPage = useCallback(async () => {
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
const data = await api(url)
setItems((prev) => prev.concat(data.items || []))
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
if (!data.nextCursor) setDone(true)
} catch (e) {
console.error('alerts load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [done, onCount])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/alerts?limit=10')
setItems(data.items || [])
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('alerts reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [onCount])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
if (!el) return
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) fetchPage()
}, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [items.length, done, fetchPage])
if (items.length === 0 && !loading) {
return (
<section className="home-section">
<Empty description="暂无报警" />
</section>
)
}
return (
<section className="home-section">
<div className="section-list">
{items.map((a) => (
<Card key={a.id} className="alert-card">
<div className="alert-title">
<span className="alert-name">{a.indicatorName}</span>
<span className="alert-period">{a.period}</span>
</div>
<div className="alert-diff">
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
</div>
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
</Card>
))}
<div ref={sentinelRef} className="scroll-sentinel">
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
</div>
</div>
</section>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, DotLoading, Empty } from 'antd-mobile'
import { api } from '../../api/client.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { fmtDate } from '../../utils/format.js'
// 新闻 (V0.2 从「首页」并入「宏观」Tab)
// - 数据: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻, 按时间倒序
// - 无限滚动: 触底自动加载下一页
// - 详情: /macro/news/:id (挂在宏观 Tab 下, 保持底部 Tab 高亮)
export default function NewsView() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const navigate = useNavigate()
const fetchPage = useCallback(async () => {
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
const data = await api(url)
setItems((prev) => prev.concat(data.items || []))
cursorRef.current = data.nextCursor
if (!data.nextCursor) setDone(true)
} catch (e) {
console.error('news load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [done])
useEffect(() => { fetchPage() }, [])
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/news?limit=10')
setItems(data.items || [])
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('news reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [])
useRefreshOnVisible(reload)
useEffect(() => {
if (done || items.length === 0) return
const el = sentinelRef.current
if (!el) return
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) fetchPage()
}, { rootMargin: '200px' })
io.observe(el)
return () => io.disconnect()
}, [items.length, done, fetchPage])
if (items.length === 0 && !loading) {
return (
<section className="home-section">
<Empty description="暂无新闻" />
</section>
)
}
return (
<section className="home-section">
<div className="section-list">
{items.map((n) => (
<Card key={n.id} className="news-card" onClick={() => navigate('/macro/news/' + n.id)}>
<div className="news-title">{n.title}</div>
{n.summary && <div className="news-summary">{n.summary}</div>}
<div className="news-meta">
<span className="news-source">{n.source || '未知来源'}</span>
<span className="news-time">{fmtDate(n.publishedAt)}</span>
<span className="news-rank">#{n.impactRank}</span>
</div>
</Card>
))}
<div ref={sentinelRef} className="scroll-sentinel">
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
</div>
</div>
</section>
)
}
+3 -6
View File
@@ -210,12 +210,9 @@ input, textarea, select { font: inherit; }
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; } .me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
.me-logout { color: var(--color-danger); } .me-logout { color: var(--color-danger); }
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; } .me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
/* 首页模块: 报警 + 新闻 */ /* 资讯模块: 报警 + 新闻 (V0.2 起从「首页」并入「宏观」Tab) */
.home-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } .tab-label { display: inline-flex; align-items: center; gap: 4px; }
.home-content .adm-tabs-content { --content-padding: 0; } .home-section { padding: 12px 12px 16px; }
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
.home-section { padding: 0 12px 16px; }
.section-title { font-size: 16px; font-weight: 700; padding: 14px 4px 8px; color: var(--color-text); }
.section-list { display: flex; flex-direction: column; gap: 8px; } .section-list { display: flex; flex-direction: column; gap: 8px; }
.alert-card.adm-card { border-radius: 10px; margin: 0; } .alert-card.adm-card { border-radius: 10px; margin: 0; }
.alert-card .adm-card-body { padding: 10px 12px; } .alert-card .adm-card-body { padding: 10px 12px; }
+19
View File
@@ -0,0 +1,19 @@
// 通用格式化工具
export function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n) => String(n).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
}
export function fmtDateTime(s) {
if (!s) return ''
const d = new Date(s)
if (isNaN(d.getTime())) return s
const pad = (n) => String(n).padStart(2, '0')
return (
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
)
}
+27
View File
@@ -0,0 +1,27 @@
// 版本号比较 (纯函数, 无浏览器依赖, 方便单测)
//
// 口径: 把版本号按"非数字"切成数字字段, 逐段比较数值大小, 缺位补 0
// "0.2.0-ota" → [0, 2, 0]
// "v1.10.3" → [1, 10, 3]
// "0.2" 与 "0.2.0" 视为相等
export function parseVersionFields(v) {
return String(v || '')
.trim()
.split(/[^0-9]+/)
.filter((s) => s !== '')
.map((s) => Number(s))
}
// a > b 返回 1, a < b 返回 -1, 相等返回 0
export function compareVersion(a, b) {
const fa = parseVersionFields(a)
const fb = parseVersionFields(b)
const n = Math.max(fa.length, fb.length)
for (let i = 0; i < n; i++) {
const x = fa[i] || 0
const y = fb[i] || 0
if (x !== y) return x > y ? 1 : -1
}
return 0
}