Compare commits
2
Commits
e1e0880ce6
...
5f41cb05b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f41cb05b7 | ||
|
|
4a04720a5e |
@@ -30,6 +30,11 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
# 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。
|
||||
# tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。
|
||||
- name: Check version consistency
|
||||
run: node scripts/ci/check-version.mjs
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: https://gitea.com/actions/setup-java@v4
|
||||
with:
|
||||
|
||||
@@ -21,6 +21,11 @@ jobs:
|
||||
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
||||
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
||||
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:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
@@ -29,6 +34,15 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check version consistency & branch
|
||||
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
||||
# (tauri.conf.json 的 version 就是 APK 的 versionName)
|
||||
# 2) 防止 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
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
@@ -47,7 +61,7 @@ jobs:
|
||||
OTA_NOTES: ${{ github.sha }}
|
||||
run: |
|
||||
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 ====="
|
||||
cat ota-package/latest.json
|
||||
echo "===== bundle sha256 ====="
|
||||
|
||||
@@ -21,10 +21,10 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
min_binary:
|
||||
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
|
||||
description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
default: '0.1.0'
|
||||
|
||||
jobs:
|
||||
build-ota:
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
OUT="ota-package"
|
||||
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 "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
|
||||
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
||||
|
||||
+241
-48
@@ -197,6 +197,43 @@
|
||||
.legend span{display:flex;align-items:center;gap:6px}
|
||||
.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}
|
||||
|
||||
/* ---------- 宏观子 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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -206,8 +243,8 @@
|
||||
<h1>iBoard V0.2 · 界面设计</h1>
|
||||
<p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p>
|
||||
<div class="meta">
|
||||
<span>版本 V0.2</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span>
|
||||
<span>底部 5 Tab(新增「打卡」)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span>
|
||||
<span>版本 V0.2(0.2.0)</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span>
|
||||
<span>底部 4 Tab(打卡默认)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,7 +312,6 @@
|
||||
<div class="habit-summary">已完成 <strong>2</strong> / 4</div>
|
||||
</div>
|
||||
<div class="head-right">
|
||||
<div class="mini-btn">特效开</div>
|
||||
<div class="mini-btn solid">+ 新建</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,14 +405,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,11 +506,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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">
|
||||
@@ -533,11 +570,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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">
|
||||
@@ -556,7 +596,7 @@
|
||||
<div class="habit-date">10月22日 周四</div>
|
||||
<div class="habit-summary">已完成 <strong>2</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 class="habit-list">
|
||||
<div class="habit-card">
|
||||
@@ -595,11 +635,14 @@
|
||||
<div class="btn-ghost">取消</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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">
|
||||
@@ -615,7 +658,7 @@
|
||||
<div class="body">
|
||||
<div class="habit-head">
|
||||
<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 class="habit-list">
|
||||
<div class="habit-card done" style="overflow:visible">
|
||||
@@ -664,16 +707,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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.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>
|
||||
|
||||
@@ -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>
|
||||
<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"><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="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>
|
||||
<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">
|
||||
@@ -771,11 +820,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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">
|
||||
@@ -791,7 +843,7 @@
|
||||
<div class="body">
|
||||
<div class="habit-head">
|
||||
<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 class="empty">
|
||||
<div class="ico">○</div>
|
||||
@@ -800,16 +852,157 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"><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="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>
|
||||
<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.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>
|
||||
|
||||
@@ -875,9 +1068,9 @@
|
||||
<h2>5 适配与可达性</h2>
|
||||
<table>
|
||||
<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>系统开启「减弱动效」或用户关闭特效开关 → 不播放粒子 / 光环 / 震动,只保留环形与对勾</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>V0.2 沿用现有浅色主题,暂不适配</td></tr>
|
||||
</table>
|
||||
|
||||
+122
-16
@@ -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`、`界面设计.html` |
|
||||
| 技术栈 | Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma/MariaDB |
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
### 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 演进)。
|
||||
|
||||
### 1.2 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────── 前端 (React + antd-mobile, 打包进 Tauri / OTA) ────────────────────────┐
|
||||
│ App.jsx 路由 │
|
||||
│ App.jsx 路由 (默认 / → /checkin) │
|
||||
│ └ /checkin ──► CheckinPage ── SwipeTabs ──┬── HabitView (打卡) │
|
||||
│ ├── CalendarView (日历) │
|
||||
│ └── ListView (清单) │
|
||||
│ 组件: RingProgress(环形) / Celebrate(特效) / BottomNav / TabIcon │
|
||||
│ └ /macro/* ──► MacroPage ── 油价/经济数据/策略研究/经济文章/报警/新闻 │
|
||||
│ 组件: RingProgress(环形) / Celebrate(特效, 恒开) / BottomNav / TabIcon │
|
||||
│ 工具: src/pages/checkin/date.js (本地日期, 与后端同口径) apiClient: src/api/checkin.js │
|
||||
└──────────────────────────────────┬─────────────────────────────────────────────────────────────┘
|
||||
│ HTTPS + JWT (Authorization: Bearer)
|
||||
@@ -52,7 +64,7 @@
|
||||
| 层 | 位置 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| 页面 | `src/pages/checkin/*.jsx` | 视图编排、本地状态、乐观更新与回滚 |
|
||||
| 组件 | `src/components/RingProgress.jsx` | 纯展示:SVG 环形 + 完成特效 + 特效开关 |
|
||||
| 组件 | `src/components/RingProgress.jsx` | 纯展示:SVG 环形 + 完成特效(恒开,无开关) |
|
||||
| 日期工具 | `src/pages/checkin/date.js` | 与后端同口径的本地日期运算(**改一处需同步另一处**) |
|
||||
| API 封装 | `src/api/checkin.js` | 统一走 `api()`(自带 token / 错误解析) |
|
||||
| 路由 | `server/routes/checkin.js`、`checklists.js` | 参数校验、鉴权、组装展示态 |
|
||||
@@ -72,16 +84,24 @@ server/
|
||||
index.js # 挂载两个路由
|
||||
src/
|
||||
api/checkin.js # checkinApi / checklistApi
|
||||
api/ota.js # OTA 封装 + 判新模式(version/sequence)
|
||||
utils/version.js # 版本号字段比较(纯函数, 可单测)
|
||||
utils/format.js # 日期格式化
|
||||
components/RingProgress.jsx # 环形进度 + Celebrate 特效
|
||||
components/BottomNav.jsx # 新增「打卡」Tab
|
||||
components/TabIcon.jsx # 新增 checkin 图标
|
||||
components/BottomNav.jsx # Tab: 打卡(默认)/宏观/监控/我
|
||||
components/TabIcon.jsx # 图标集(移除 home)
|
||||
pages/checkin/CheckinPage.jsx # 容器:三视图切换
|
||||
pages/checkin/HabitView.jsx # 打卡视图
|
||||
pages/checkin/CalendarView.jsx # 日历视图
|
||||
pages/checkin/ListView.jsx # 清单视图
|
||||
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 # 打卡相关样式段
|
||||
scripts/ci/check-version.mjs # CI: 校验分支名 ↔ package.json 版本
|
||||
docs/V0.2/ # 需求 / 设计 / 数据库设计 / 界面设计
|
||||
```
|
||||
|
||||
@@ -126,14 +146,19 @@ docs/V0.2/ # 需求 / 设计 / 数据
|
||||
### 3.1 路由与容器
|
||||
|
||||
```
|
||||
底部 Tab (BottomNav) 打卡(默认) ─ 宏观 ─ 监控 ─ 我
|
||||
/ → <Navigate to="/checkin" replace />
|
||||
/checkin → CheckinPage
|
||||
├─ NavBar「打卡」
|
||||
└─ SwipeTabs(左右滑动切换,与既有模块交互一致)
|
||||
├─ habit → HabitView (受 date 影响)
|
||||
├─ calendar → CalendarView (持有选中 date,可回写容器)
|
||||
└─ list → ListView (独立)
|
||||
/macro/* → MacroPage (油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻)
|
||||
```
|
||||
|
||||
`activeKeyFromPath()` 对未匹配路径兜底返回**第一个 Tab(`/checkin`)**,与默认路由保持一致。
|
||||
|
||||
`date` 状态提升到 `CheckinPage`:日历里点某天 → 切回打卡视图时即为该日,实现「日历与打卡联动」。
|
||||
|
||||
### 3.2 组件树与关键状态
|
||||
@@ -144,8 +169,7 @@ CheckinPage
|
||||
HabitView(date)
|
||||
├── data: { today, date, items[], summary:{total,done} }
|
||||
├── bursts: { habitId: n } // 特效触发计数(+1 即播放一次)
|
||||
├── form / showForm // 新建/编辑弹窗
|
||||
└── effects: boolean // 特效开关(持久化 localStorage)
|
||||
└── form / showForm // 新建/编辑弹窗
|
||||
CalendarView(date, onDateChange)
|
||||
├── month, cal(月历), detail(当日), bursts
|
||||
└── quickValue: -1|0|1|null // 昨天/今天/明天 分段控件
|
||||
@@ -167,15 +191,15 @@ ListView()
|
||||
| 动画 | `transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1)`(400ms 级,U-01) |
|
||||
| 中心内容 | 未达标 → `done / target` 数字;**达标 → 对勾 SVG**(`ring-check-in` 缩放入场);超额 → 右上角 `+n` 徽标 |
|
||||
| 交互 | 整个环可点 = 打卡(`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 交互清单
|
||||
|
||||
| 视图 | 主要交互 |
|
||||
| --- | --- |
|
||||
| 打卡 | 点环/点按钮打卡(乐观更新,失败回滚 + Toast)、撤销、编辑、归档、新建弹窗(名称/周期/目标次数/颜色/备注)、特效开关 |
|
||||
| 打卡 | 点环/点按钮打卡(乐观更新,失败回滚 + Toast)、撤销、编辑、归档、新建弹窗(名称/周期/目标次数/颜色/备注) |
|
||||
| 日历 | 月份左右切换、回到今天、「昨天/今天/明天」分段切换、点日期选中、当日详情内打卡(未来日期只读显示「计划」)、本月完成率 |
|
||||
| 清单 | 进行中/已归档切换、展开/收起、逐条勾选、添加检查项、全选/全不选、复制、存为模板、归档/恢复、删除、从模板新建 |
|
||||
|
||||
@@ -298,6 +322,7 @@ ListView()
|
||||
| 层级 | 内容 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 单元 | 周期工具 23 条断言(窗口边界、周一起始、月末、闰年、日均期望、streak 跳过未结束周期) | 全通过 |
|
||||
| 单元 | 版本比较 10 条断言(`-ota` 后缀、`v` 前缀、缺位补 0、`0.10 > 0.9`、`beta.3 < beta.10`、空串) | 全通过 |
|
||||
| 接口冒烟 | 自签 JWT 端到端:每天 1 次闭合、每周 3 次 33.3%、超额 +1、撤销归零、未来日期 400、月历 31 天、模板生成 11 项、全选完成、复制重置 | 全通过,测试数据已清理 |
|
||||
| 构建 | `npm run build` | 通过 |
|
||||
| 迁移 | `prisma migrate deploy` 到目标库 | 成功 |
|
||||
@@ -323,8 +348,8 @@ ListView()
|
||||
| 周期口径前后端各实现一份 | 前端需离线即时反馈,不能等接口 | 两端算法同源同注释;口径变更必须同步 `period.js` 与 `date.js`(已写入项目记忆) |
|
||||
| 进度实时聚合而非冗余计数 | 换取一致性与配置即时生效,牺牲少量 CPU | 单用户数据量极小(月历 30 天 × 20 项),实测无感;V0.3 可加汇总表 |
|
||||
| 未来日期禁止打卡 | 防止「提前打完一周」刷进度,符合「日历看明天=计划」语义 | 明天视图只读展示「计划」,明确文案 |
|
||||
| 特效可能打扰 | 部分用户不喜动效 | 一键开关 + `prefers-reduced-motion` 自动降级 |
|
||||
| 新增第 5 个 Tab | 底部空间变拥挤 | 图标线性简洁;`TabBar` 原生 safe-area,5 项在 375px 下仍在可点区(≥44px) |
|
||||
| 特效恒开可能打扰 | 用户明确要求「特效永远打开」,不再提供开关 | 单次特效 ≤900ms 且 `pointer-events:none`,不影响连续打卡;若后续需要再评估恢复开关 |
|
||||
| Tab 调整为 4 个(打卡置左并默认) | 首页被取消,报警/新闻并入宏观后宏观有 6 个子 Tab | 子 Tab 可横向滚动;报警角标保留在子 Tab 上;底部 4 项在 375px 下热区充足(≥44px) |
|
||||
|
||||
---
|
||||
|
||||
@@ -336,3 +361,84 @@ ListView()
|
||||
4. **打卡项分组与排序拖拽**:现有 `sortOrder` 已预留,仅需交互。
|
||||
5. **清单协作/分享**:引入 `CheckListShare`。
|
||||
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 版本号为什么必须跟分支走
|
||||
|
||||
`scripts/build-ota.mjs` 里 `manifest.version = package.json.version + '-ota'`,也就是**版本号的唯一来源仍是 `package.json`**。
|
||||
分支按版本命名(`dev-0.2`),但版本号是手工改的,一旦忘改就会出现「分支 0.2、包版本 0.1」的错位,客户端判新逻辑全部失准。
|
||||
|
||||
**同一版本号的两种产物写法**(2026-10-22 补充确认):
|
||||
|
||||
```
|
||||
分支 dev-0.2 → 0.2.0
|
||||
├─ APK / 桌面安装包 0.2.0 裸版本号,无后缀
|
||||
├─ 前端运行时 0.2.0 裸版本号,无后缀
|
||||
└─ OTA 热更包 0.2.0-ota 多一个 -ota 标记
|
||||
```
|
||||
|
||||
即 APK 与 OTA 的版本主体完全一致,唯一差别是 OTA 多 `-ota` 后缀;`package.json` 只写裸版本号,后缀由打包脚本追加。
|
||||
|
||||
| 环节 | 取值 | 现状 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` `version` | `0.2.0` | 唯一手动管理点,**不得带 `-ota`** |
|
||||
| `src-tauri/tauri.conf.json` `version` | `0.2.0` | 决定 **APK `versionName`**(经 `tauri android init` 写入 `tauri.properties`)与桌面包版本 |
|
||||
| `src-tauri/Cargo.toml` / `Cargo.lock` | `0.2.0` | Rust 包版本,与上两者保持一致 |
|
||||
| OTA `latest.json.version` | `0.2.0-ota` | 打包脚本自动拼 `-ota` 后缀 |
|
||||
| `src/version.js` `APP_VERSION` | `0.2.0` | 前端展示与版本比较的基准 |
|
||||
| APK `versionCode` | `2000` | `major*1000000 + minor*1000 + patch`,必须单调递增才能覆盖安装(`0.1.0` = 1000) |
|
||||
| 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` | ① 四处版本必须完全相等且不含 `-ota`(`main`/`master` 也查);② 从 `GITHUB_REF_NAME` 解析 `dev-<major>.<minor>`,要求 `version` 以该前缀开头;任一不满足**构建直接失败**。`docker-build.yml` 与 `android-build.yml` 都在构建前执行 |
|
||||
|
||||
### 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 导航。
|
||||
|
||||
+23
-9
@@ -117,7 +117,7 @@
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-SY-01 | 底部 TabBar 新增**「打卡」页** 和 **「清单」页** | 图标风格与现有 TabIcon 一致 | P0 |
|
||||
| F-SY-01 | 底部 TabBar 新增**「打卡」页**,并作为**默认首页放在最左**;「清单」不单独占 Tab,作为打卡页内的第三个子视图 | 图标风格与现有 TabIcon 一致;启动即进入 `/checkin` | P0 |
|
||||
| F-SY-02 | 「打卡」页内集成 打卡/日历 两个功能,日历放在上半截,默认显示一行为周视图,下拉显示为月视图。 | 日历中选择时间与下部打卡任务联动 | P0 |
|
||||
| F-SY-03 | 所有接口复用现有鉴权(`authRequired`,JWT),数据按 `userId` 隔离 | 未登录跳转 `/login` | 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-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-05 | 空态 / 加载态 / 错误态三态齐全(首次使用引导"创建第一个打卡项") | 空态有明确 CTA | P0 |
|
||||
| U-06 | 打卡按钮足够大(点击热区 ≥ 44×44),符合移动端触控规范 | 误触率低 | P1 |
|
||||
| U-07 | 动效**性能降级**:低端设备或 `prefers-reduced-motion` 时关闭粒子特效,仅保留进度动画 | 不卡顿 | P1 |
|
||||
| U-07 | 动效**性能降级**:特效保持轻量(单次 ≤900ms、`pointer-events:none`),不给低端机造成卡顿 | 不卡顿、不阻塞操作 | P1 |
|
||||
| U-08 | 深浅色:跟随现有浅色主题,卡片/分割线风格与首页、监控页一致 | 视觉统一 | P1 |
|
||||
| U-09 | 清单勾选有明确的勾选动画(勾形描边动画) | 反馈即时 | P2 |
|
||||
|
||||
@@ -174,12 +174,24 @@
|
||||
| E-07 | 清单项全部删除后清单进度为 0/0 | 显示空清单,不显示 100% | P1 |
|
||||
| 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-01a | **APK 版本与 OTA 版本同源**:APK(含桌面包)版本为裸版本号 `0.2.0`,无后缀;OTA 版本为同一版本号加 `-ota`。两者都跟代码分支保持一致 | APK 系统信息里 versionName=`0.2.0`(versionCode=2000),OTA manifest=`0.2.0-ota` | P0 |
|
||||
| F-OT-02 | 版本号错位在 CI 阶段就拦住:①`package.json`/`tauri.conf.json`/`Cargo.toml`/`Cargo.lock` 四处必须完全一致且不含 `-ota`;②分支名 `dev-x.y` 与版本主次号不一致时构建失败 | 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. 优先级汇总(本期交付范围)
|
||||
|
||||
- **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。
|
||||
- **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。
|
||||
- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;F-OT-01、F-OT-01a、F-OT-02~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;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。
|
||||
|
||||
---
|
||||
@@ -200,12 +212,12 @@
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | 建议新增 Tab,入口最清晰 |
|
||||
| 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | ✅ **已决策(2026-10-22)**:「打卡」作为**默认首页放在最左**;原来的「首页」取消,报警/新闻并入宏观 |
|
||||
| 2 | 周起始日是周一还是周日? | 建议周一(国内习惯) |
|
||||
| 3 | 是否允许"明天提前打卡"? | 建议**不允许**(F-CK-11),保持统计口径干净 |
|
||||
| 4 | 删除打卡项时历史是否保留? | 建议默认归档保留,硬删除才清历史 |
|
||||
| 5 | 预置模板具体包含哪些项? | 评审时列出"出差/回老家"各 8~12 条初始项 |
|
||||
| 6 | 完成特效是否可全局关闭? | 建议在"我"页提供开关(默认开) |
|
||||
| 6 | 完成特效是否可全局关闭? | ✅ **已决策(2026-10-22)**:不提供关闭入口,**特效永远打开**;页面内不再有特效开关 |
|
||||
|
||||
---
|
||||
|
||||
@@ -308,8 +320,10 @@ model CheckListTemplate {
|
||||
| B-6 | **撤销语义** | 撤销删除本周期内"最近一次"打卡(按 date desc、seq desc 取第一条) |
|
||||
| B-7 | **删除打卡项** | 默认归档(`archived=true`,历史保留);`?hard=1` 才物理删除并清记录 |
|
||||
| B-8 | **接口前缀** | 打卡 `/api/checkin/*`、清单 `/api/checklists/*`,全部 `authRequired`,数据按 `userId` 隔离 |
|
||||
| B-9 | **前端入口** | 底部新增第 5 个 Tab「打卡」→ `/checkin`,页内三视图:打卡 / 日历 / 清单 |
|
||||
| B-10 | **特效开关** | 完成特效 + 触感反馈受 `localStorage iboard_celebrate` 控制(默认开),系统开启"减弱动效"时自动降级 |
|
||||
| B-9 | **前端入口** | 底部 Tab 4 个:**打卡(最左,默认选中)** / 宏观 / 监控 / 我;`/` 重定向到 `/checkin`,页内三视图:打卡 / 日历 / 清单。原「首页」取消,报警/新闻并入宏观 |
|
||||
| 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "iboard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
## 版本管理 (V0.1)
|
||||
## 版本管理
|
||||
|
||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
|
||||
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
|
||||
### 一句话规则
|
||||
|
||||
**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。**
|
||||
|
||||
```
|
||||
分支 dev-0.2 → 版本 0.2.0
|
||||
├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀)
|
||||
├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀)
|
||||
└─ OTA 热更包 version = 0.2.0-ota (多一个 -ota)
|
||||
```
|
||||
|
||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`),
|
||||
该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。
|
||||
|
||||
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。
|
||||
|
||||
### 改版本号时要同步的位置
|
||||
|
||||
| 文件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 |
|
||||
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
||||
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
|
||||
|
||||
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
|
||||
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
|
||||
|
||||
### APK 版本号是怎么来的
|
||||
|
||||
`tauri android init` 会按 `src-tauri/tauri.conf.json` 的 `version` 生成
|
||||
`src-tauri/gen/android/app/tauri.properties`(该目录被 gitignore,CI 每次 `android init` 重新生成):
|
||||
|
||||
```
|
||||
tauri.android.versionName = 0.2.0 # 人类可见版本,与 tauri.conf.json 一致
|
||||
tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000 + minor*1000 + patch
|
||||
```
|
||||
|
||||
注意:
|
||||
- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
|
||||
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
|
||||
删掉该目录重新 `npx tauri android init` 即可。
|
||||
|
||||
### 其它
|
||||
|
||||
说明:
|
||||
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
||||
- OTA 升级包的 `version` = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
|
||||
所以版本号错位会直接导致线上 OTA 判新失准。
|
||||
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
|
||||
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
|
||||
否则装了 0.1.0 APK 的设备会直接收不到热更。
|
||||
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
|
||||
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
|
||||
|
||||
@@ -123,6 +163,21 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
||||
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
|
||||
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
|
||||
|
||||
### 客户端判新口径(V0.2 新增)
|
||||
|
||||
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
|
||||
|
||||
| 开关 | 判新依据 | 适用 |
|
||||
| --- | --- | --- |
|
||||
| 关(默认) | **版本号**逐字段比大小(`0.2.0-ota` > `0.1.0` 才算新) | 生产。旧分支被重建(sequence 变大但版本号更小)时不会把设备拉回旧版 |
|
||||
| 开 | **build sequence**(服务端 sequence 比本地大就算新) | 开发联调。每次构建都能推到设备 |
|
||||
|
||||
- 开关值持久化在设备端 `localStorage.iboard_ota_dev_mode`(`'1'`/`'0'`),默认 `'0'`。
|
||||
- 版本比较在 `src/utils/version.js`(纯函数,`src/api/ota.js` 统一封装两种模式)。
|
||||
- **`min_binary_version` 与应用版本是两件事**:前者是前端 bundle 要求的最低原生二进制版本,
|
||||
当前 APK 原生层仍是 `0.1.0`,所以 CI 里固定传 `--min-binary 0.1.0`(见 `docker-build.yml` 的
|
||||
`OTA_MIN_BINARY`)。只改前端版本号时**不要**跟着抬这个值,否则旧 APK 会收不到更新。
|
||||
|
||||
### Web 端
|
||||
|
||||
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// CI 专用: 校验版本号四处一致 + 与分支名一致
|
||||
//
|
||||
// 背景:
|
||||
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生:
|
||||
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀)
|
||||
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记)
|
||||
// - 前端运行时 APP_VERSION = <version>
|
||||
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。
|
||||
//
|
||||
// 校验分两段:
|
||||
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock
|
||||
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加)
|
||||
// B. 分支对应 (仅 dev-<版本> 分支):
|
||||
// dev-<major>.<minor> → 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 || ''
|
||||
|
||||
function readJson(rel) {
|
||||
return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'))
|
||||
}
|
||||
|
||||
// Cargo.toml 的 [package].version 位于文件开头, 取第一个行首 version = "x"
|
||||
function readCargoTomlVersion() {
|
||||
const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.toml'), 'utf8')
|
||||
const m = /^version\s*=\s*"([^"]+)"/m.exec(text)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// Cargo.lock 里挑出 name = "iboard" 那个包的 version
|
||||
function readCargoLockVersion() {
|
||||
const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.lock'), 'utf8')
|
||||
const blocks = text.split(/^\[\[package\]\]$/m)
|
||||
for (const b of blocks) {
|
||||
if (/^name\s*=\s*"iboard"$/m.test(b)) {
|
||||
const m = /^version\s*=\s*"([^"]+)"/m.exec(b)
|
||||
if (m) return m[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ file: 'package.json', version: String(readJson('package.json').version || '') },
|
||||
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') },
|
||||
{ file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() },
|
||||
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
|
||||
]
|
||||
|
||||
const version = sources[0].version
|
||||
const otaVersion = version + '-ota'
|
||||
|
||||
console.log('[check-version] 分支: ', branch || '(未知)')
|
||||
for (const s of sources) {
|
||||
console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
|
||||
}
|
||||
console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion)
|
||||
|
||||
let failed = false
|
||||
|
||||
// ---- A. 四处一致性 + 裸版本号 ----
|
||||
if (/-ota$/i.test(version)) {
|
||||
console.error('')
|
||||
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`)
|
||||
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
|
||||
failed = true
|
||||
}
|
||||
|
||||
const mismatched = sources.filter((s) => s.version !== version)
|
||||
if (mismatched.length) {
|
||||
console.error('')
|
||||
console.error('============================================================')
|
||||
console.error('[check-version] 版本号不一致, 构建中止')
|
||||
console.error('============================================================')
|
||||
for (const s of mismatched) {
|
||||
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);')
|
||||
console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。')
|
||||
console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。')
|
||||
failed = true
|
||||
}
|
||||
|
||||
// ---- B. 分支对应 ----
|
||||
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
|
||||
if (!m) {
|
||||
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
|
||||
} else {
|
||||
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
|
||||
if (!version.startsWith(want)) {
|
||||
console.error('')
|
||||
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`)
|
||||
console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock')
|
||||
failed = true
|
||||
} else {
|
||||
console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
console.log('[check-version] 全部通过')
|
||||
Generated
+1
-1
@@ -1456,7 +1456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iboard"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iboard"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "iBoard - 经济数据与策略"
|
||||
authors = ["iBoard Team"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "iBoard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.iboard.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+10
-9
@@ -1,8 +1,6 @@
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import MobileShell from './layouts/MobileShell.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 MonitorPage from './pages/MonitorPage.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 { useAuth } from './auth/AuthContext.jsx'
|
||||
|
||||
// 已登录访问 /login 自动回首页
|
||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||
const HOME_PATH = '/checkin'
|
||||
|
||||
// 已登录访问 /login 自动回默认页
|
||||
function RedirectIfAuthed({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (user) return <Navigate to="/" replace />
|
||||
if (user) return <Navigate to={HOME_PATH} replace />
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -32,16 +33,16 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||
<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="/monitor" element={<MonitorPage />} />
|
||||
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
||||
{/* V0.2 打卡 / 日历 / 清单 */}
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to={HOME_PATH} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
+99
-17
@@ -5,7 +5,7 @@
|
||||
// 设计原则:
|
||||
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
|
||||
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
|
||||
import { getApiBase, getToken } from './client.js'
|
||||
import { getApiBase } from './client.js'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
|
||||
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
|
||||
@@ -33,6 +33,47 @@ export function initOta() {
|
||||
.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:
|
||||
// {
|
||||
@@ -40,34 +81,71 @@ export function initOta() {
|
||||
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
|
||||
// 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) {
|
||||
return checkOtaUpdateWeb()
|
||||
return checkOtaUpdateWeb(devMode)
|
||||
}
|
||||
return checkOtaUpdateTauri()
|
||||
return checkOtaUpdateTauri(devMode)
|
||||
}
|
||||
|
||||
async function checkOtaUpdateTauri() {
|
||||
// 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数)
|
||||
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
|
||||
const r = await checkUpdate()
|
||||
if (!r.available) {
|
||||
return { available: false, reason: 'no_package', info: r }
|
||||
// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
|
||||
async function currentRunningVersion() {
|
||||
try {
|
||||
const v = await getOtaVersionInfo()
|
||||
if (v?.active && v.version) return v.version
|
||||
} catch { /* ignore */ }
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
|
||||
async function checkByVersion() {
|
||||
const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
|
||||
if (!manifest || !manifest.version) {
|
||||
return { available: false, reason: 'no_package', info: null }
|
||||
}
|
||||
if (compareVersion(manifest.version, current) <= 0) {
|
||||
return { available: false, reason: 'same_version', info: manifest }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'ok',
|
||||
info: {
|
||||
version: r.version,
|
||||
sequence: r.sequence,
|
||||
notes: r.notes,
|
||||
mandatory: r.mandatory,
|
||||
bundle_size: r.bundle_size,
|
||||
version: manifest.version,
|
||||
sequence: manifest.sequence,
|
||||
notes: manifest.notes || '',
|
||||
mandatory: !!manifest.mandatory,
|
||||
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 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
|
||||
@@ -76,7 +154,11 @@ async function checkOtaUpdateWeb() {
|
||||
}
|
||||
const data = await res.json()
|
||||
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 {
|
||||
|
||||
@@ -4,24 +4,23 @@ import TabIcon from './TabIcon.jsx'
|
||||
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||
const TABS = [
|
||||
{ key: '/', name: 'home', label: '首页' },
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
]
|
||||
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 '/' , 其他前缀匹配)
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 + 前缀匹配)
|
||||
function activeKeyFromPath(pathname) {
|
||||
for (const t of TABS) {
|
||||
if (t.key === '/') {
|
||||
if (pathname === '/') return t.key
|
||||
} else if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
return t.key
|
||||
}
|
||||
}
|
||||
return '/'
|
||||
// 未知路径兜底到第一个 Tab (打卡)
|
||||
return TABS[0].key
|
||||
}
|
||||
|
||||
// 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function RingProgress({
|
||||
|
||||
// 完成特效 (F-CK-07, U-02/U-03/U-07)
|
||||
// props: fire 计数器(每次 +1 触发一次), color 主色
|
||||
// 尊重"特效开关": localStorage iboard_celebrate === '0' 时不播放
|
||||
// V0.2 设计变更: 去掉特效开关, 达标特效**恒开** (没有任何关闭入口)
|
||||
export function Celebrate({ fire = 0, color = '#1677ff' }) {
|
||||
const [burst, setBurst] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -80,7 +80,6 @@ export function Celebrate({ fire = 0, color = '#1677ff' }) {
|
||||
if (fire === prev.current) return
|
||||
prev.current = fire
|
||||
if (fire <= 0) return
|
||||
if (!effectsEnabled()) return
|
||||
setBurst((n) => n + 1)
|
||||
setVisible(true)
|
||||
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 支持)
|
||||
export function haptic(ms = 18) {
|
||||
try {
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
// props: name 字符串, active 布尔
|
||||
// 返回对应的 24x24 SVG 节点
|
||||
const ICONS = {
|
||||
home: (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">
|
||||
<path d="M3 11l9-7 9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
{active ? <path d="M10 20v-5h4v5" fill="currentColor" stroke="none" /> : null}
|
||||
</svg>
|
||||
),
|
||||
// V0.2: 打卡移到最左, 原「首页」已并入宏观, home 图标不再使用
|
||||
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">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
|
||||
@@ -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
@@ -1,25 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||
import { Badge, NavBar } from 'antd-mobile'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
import ArticlesView from './macro/ArticlesView.jsx'
|
||||
import ArticleDetail from './macro/ArticleDetail.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 → 油价 (新增)
|
||||
// /macro/oil → 油价
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /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 = [
|
||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||
]
|
||||
|
||||
function activeFromPath(pathname) {
|
||||
@@ -27,7 +34,9 @@ function activeFromPath(pathname) {
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -35,15 +44,21 @@ export default function MacroPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const active = activeFromPath(location.pathname)
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
|
||||
const tabs = TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
label: t.key === 'alerts'
|
||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
||||
: t.label,
|
||||
render: () => {
|
||||
if (t.key === 'oil') return <OilPage />
|
||||
if (t.key === 'data') return <DataView />
|
||||
if (t.key === 'strategy') return <StrategyView />
|
||||
if (t.key === 'articles') return <ArticlesView />
|
||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
||||
if (t.key === 'news') return <NewsView />
|
||||
return null
|
||||
},
|
||||
}))
|
||||
@@ -53,13 +68,14 @@ export default function MacroPage() {
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) {
|
||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="data/:name" element={<DataView />} />
|
||||
<Route path="strategy/:name" element={<StrategyView />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="news/:id" element={<NewsDetail />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
+30
-2
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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 { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
import {
|
||||
applyOtaUpdate,
|
||||
checkOtaUpdate,
|
||||
getCurrentVersionInfo,
|
||||
getOtaDevMode,
|
||||
getOtaVersionInfo,
|
||||
reloadApp,
|
||||
rollbackOta,
|
||||
setOtaDevMode,
|
||||
} from '../api/ota.js'
|
||||
|
||||
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
|
||||
@@ -29,6 +31,8 @@ export default function MePage() {
|
||||
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
|
||||
const [progress, setProgress] = useState({ received: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
||||
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
||||
|
||||
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
|
||||
useEffect(() => {
|
||||
@@ -43,7 +47,7 @@ export default function MePage() {
|
||||
setError('')
|
||||
setOtaState('checking')
|
||||
try {
|
||||
const r = await checkOtaUpdate()
|
||||
const r = await checkOtaUpdate({ devMode })
|
||||
if (r.available) {
|
||||
setOtaInfo(r.info)
|
||||
setOtaState('available')
|
||||
@@ -130,6 +134,30 @@ export default function MePage() {
|
||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||
运行资源
|
||||
</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>
|
||||
<div className="me-ota-actions">
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
||||
} 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 useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { toDateStr, dayLabel } from './date.js'
|
||||
@@ -25,7 +25,6 @@ export default function HabitView({ date, onDateChange }) {
|
||||
const [bursts, setBursts] = useState({}) // { habitId: 触发计数 }
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [effects, setEffects] = useState(effectsEnabled())
|
||||
|
||||
const load = useCallback((silent) => {
|
||||
if (!silent) setErr('')
|
||||
@@ -145,13 +144,6 @@ export default function HabitView({ date, onDateChange }) {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -210,12 +210,9 @@ input, textarea, select { font: inherit; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
||||
/* 首页模块: 报警 + 新闻 */
|
||||
.home-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.home-content .adm-tabs-content { --content-padding: 0; }
|
||||
.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); }
|
||||
/* 资讯模块: 报警 + 新闻 (V0.2 起从「首页」并入「宏观」Tab) */
|
||||
.tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 12px 12px 16px; }
|
||||
.section-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.alert-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.alert-card .adm-card-body { padding: 10px 12px; }
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user