diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 1f5d06c..818dee5 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -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,13 @@ jobs: with: node-version: 22 + - name: Check version matches branch + # 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, + # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + run: node scripts/ci/check-version.mjs + - name: Install deps run: npm ci --no-audit --no-fund @@ -47,7 +59,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 =====" diff --git a/.gitea/workflows/ota-build.yml b/.gitea/workflows/ota-build.yml index d293808..40538a0 100644 --- a/.gitea/workflows/ota-build.yml +++ b/.gitea/workflows/ota-build.yml @@ -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 diff --git a/docs/V0.2/界面设计.html b/docs/V0.2/界面设计.html index 0ad34c2..3ca0a4e 100644 --- a/docs/V0.2/界面设计.html +++ b/docs/V0.2/界面设计.html @@ -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)} @@ -206,8 +243,8 @@

iBoard V0.2 · 界面设计

打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)

- 版本 V0.2分支 dev-0.2antd-mobile 风格 - 底部 5 Tab(新增「打卡」)时区 Asia/Shanghai文档日期 2026-10-22 + 版本 V0.2(0.2.0)分支 dev-0.2antd-mobile 风格 + 底部 4 Tab(打卡默认)时区 Asia/Shanghai文档日期 2026-10-22
@@ -275,7 +312,6 @@
已完成 2 / 4
-
特效开
+ 新建
@@ -369,14 +405,12 @@
- 首页
+ 打卡
宏观
监控
- 打卡
-
我
@@ -472,11 +506,14 @@
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
@@ -533,11 +570,14 @@
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
@@ -556,7 +596,7 @@
10月22日 周四
已完成 2 / 4
-
特效开
+ 新建
+
+ 新建
@@ -595,11 +635,14 @@
取消
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
@@ -615,7 +658,7 @@
10月22日 周四
已完成 3 / 4
-
特效开
+ 新建
+
+ 新建
@@ -664,16 +707,19 @@
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
2.5 达标瞬间 · 完成特效 -

环形闭合 + 对勾缩放入场(0.32s);同时 14 颗粒子沿圆周扩散(26~58px,交替主色与金色)+ 主色光环脉冲(0.7s),总时长 ≤900ms,pointer-events:none 不阻塞后续操作;并触发 18ms 轻震动。可在头部一键关闭(持久化开关),系统「减弱动效」时自动降级。

+

环形闭合 + 对勾缩放入场(0.32s);同时 14 颗粒子沿圆周扩散(26~58px,交替主色与金色)+ 主色光环脉冲(0.7s),总时长 ≤900ms,pointer-events:none 不阻塞后续操作;并触发 18ms 轻震动。特效恒开(V0.2 设计变更:头部不再有开关)。

@@ -727,11 +773,14 @@
吃药每天 1 次 · 当日 0/1待办
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
@@ -771,11 +820,14 @@
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
@@ -791,7 +843,7 @@
10月22日 周四
已完成 0 / 0
-
特效关
+ 新建
+
+ 新建
○
@@ -800,16 +852,157 @@
-
首页
-
宏观
-
监控
-
打卡
-
我
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
2.8 空态 -

三个视图均具备加载中 / 空态 / 错误(带重试)三态。空态给出明确引导按钮;头部「特效开 / 特效关」为全局动效开关,关闭后仅保留环形与对勾,不播放粒子与震动。

+

三个视图均具备加载中 / 空态 / 错误(带重试)三态。空态给出明确引导按钮。V0.2 设计变更:打卡页头部不再有「特效开 / 特效关」开关,完成特效与触感反馈恒开,没有任何关闭入口。

+
+ + + +
+
+ +
+
+
油价
经济数据
策略研究
经济文章
+
报警3
新闻
+
+
+
+
央行开展 5000 亿元 MLF 操作,中标利率持平
+
本月 MLF 净投放 2000 亿元,市场流动性保持合理充裕…
+
妙想财经2026-10-22#1
+
+
+
9 月社零同比增长 3.2%,服务消费延续回暖
+
餐饮收入增速回升至 5.1%,可选消费分化明显…
+
妙想财经2026-10-21#2
+
+
+
美联储会议纪要偏鹰,美元指数小幅走强
+
点阵图显示年内或仍有一次降息空间,但分歧加大…
+
妙想财经2026-10-21#3
+
+
+
OPEC+ 维持减产节奏,国际油价日内涨逾 1%
+
布伦特原油重回 78 美元上方,化工链成本端承压…
+
妙想财经2026-10-20#4
+
+
+
+
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
+
+
+
+ 2.9 宏观 · 原「首页」报警/新闻并入设计变更 +

「首页」Tab 取消,报警与新闻成为宏观的第 5、6 个子 Tab(/macro/alerts、/macro/news),共 6 个子 Tab 可横向滚动,当前项底部有 3px 主色指示条;报警数量以红色角标挂在子 Tab 上。新闻详情改为 /macro/news/:id,底部「宏观」保持高亮。

+
+
+ + +
+
+ +
+
+
C
+
Cheney
+
qian.huang@sunyard.com
+
+
+
前端升级 (OTA)
+
当前版本v0.2.0 · 20261022.1530
+
运行资源内置
+
+
+
开发版本
+
按版本号判新:仅当服务端版本号更高时才提示更新
+
+
+
+
+
检查更新
+
立即升级
+
+
+
+
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
+
+
+
+ 2.10 我的 · OTA「开发版本」关闭(默认)新功能 +

开关默认关闭 = 按版本号判新:取服务端 latest.json 的 version(如 0.2.0-ota),与当前运行版本按数字字段逐段比较,更高才提示更新。用于生产,避免旧分支被重建(sequence 变大但版本更小)把设备拉回旧版。开关值保存在设备端 localStorage.iboard_ota_dev_mode。

+
+
+ + +
+
+ +
+
+
C
+
Cheney
+
qian.huang@sunyard.com
+
+
+
前端升级 (OTA)
+
当前版本v0.2.0 · 20261022.1530
+
运行资源内置
+
+
+
开发版本
+
按 build sequence 判新:每次构建都算新版本,便于开发联调
+
+
+
+
新版本v0.2.0-ota (288 KB)
+
+
立即升级
+
回滚
+
+
+
+
+
+ 打卡
+
+ 宏观
+
+ 监控
+
+ 我
+
+
+
+ 2.11 我的 · OTA「开发版本」打开 +

开关打开 = 按 build sequence 判新:走 hotswap 插件的 checkUpdate(),只要服务端 sequence 比本地大就算新版本——每次 CI 构建 sequence 都会变(默认取时间戳),所以任何一次提交都能推到设备上,适合开发联调。切换开关会清空上一次的检查结果,避免上一种口径的结论误导。

@@ -875,9 +1068,9 @@

5 适配与可达性

- + - +
项约定
视口设计稿 420×820;375~430px 内元素不换行、不裁切;底部 TabBar 5 项每项 ≥ 40px 可点宽度
视口设计稿 420×820;375~430px 内元素不换行、不裁切;底部 TabBar 4 项每项 ≥ 44px 可点宽度
安全区TabBar 使用 antd-mobile safeArea,页面底部留出 tabbar-h + safe-bottom
减弱动效系统开启「减弱动效」或用户关闭特效开关 → 不播放粒子 / 光环 / 震动,只保留环形与对勾
动效特效恒开(V0.2 设计变更,无开关):单次 ≤900ms、pointer-events:none,不阻塞连续打卡;不再响应 prefers-reduced-motion
大字号卡片采用 grid 自适应列宽,文字超长换行不截断;名称限制 20 字、检查项 40 字
深色模式V0.2 沿用现有浅色主题,暂不适配
diff --git a/docs/V0.2/设计文档.md b/docs/V0.2/设计文档.md index 3ea15dd..7ae10d0 100644 --- a/docs/V0.2/设计文档.md +++ b/docs/V0.2/设计文档.md @@ -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) 打卡(默认) ─ 宏观 ─ 监控 ─ 我 + / → /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,70 @@ 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 OTA 版本号为什么必须跟分支走 + +`scripts/build-ota.mjs` 里 `manifest.version = package.json.version + '-ota'`,也就是**版本号的唯一来源仍是 `package.json`**。 +分支按版本命名(`dev-0.2`),但版本号是手工改的,一旦忘改就会出现「分支 0.2、包版本 0.1」的错位,客户端判新逻辑全部失准。 + +| 环节 | 取值 | 现状 | +| --- | --- | --- | +| `package.json` `version` | `0.2.0` | 唯一手动管理点 | +| OTA `latest.json.version` | `0.2.0-ota` | 打包脚本自动拼 `-ota` 后缀 | +| `src/version.js` `APP_VERSION` | `0.2.0` | 前端展示与版本比较的基准 | +| OTA `latest.json.min_binary_version` | `0.1.0` | **与前端版本解耦**:前端 bundle 要求的最低原生二进制版本。现有 APK 原生层仍是 0.1.0,跟着抬会导致旧 APK 收不到更新,CI 固定传 `--min-binary 0.1.0` | +| CI 守卫 | `scripts/ci/check-version.mjs` | 从 `GITHUB_REF_NAME` 解析 `dev-.`,要求 `version` 以该前缀开头,否则**构建直接失败**;`main`/`master` 跳过 | + +### 10.3 判新口径:版本号 vs build sequence + +「我的 → 前端升级 (OTA)」组内新增 `开发版本` 开关(antd-mobile `Switch`),默认**关闭**: + +| 开关 | 判新依据 | 实现路径 | 适用场景 | +| --- | --- | --- | --- | +| **关(默认)** | **版本号字段大小** | `GET /api/ota/check/0` 取 `manifest.version` → `compareVersion(manifest.version, 当前运行版本) > 0` 才算新 | 生产口径。旧分支被重新构建(sequence 变大但版本更小)时不会把设备拉回旧版 | +| **开** | **build sequence** | `tauri-plugin-hotswap` 的 `checkUpdate()`(内部拿本地 sequence 请求 `/api/ota/check/:seq`,服务端只回比它大的) | 开发联调。每次构建 sequence 都变,保证任何一次提交都能推到设备上 | + +补充约定: + +- **持久化**:`localStorage['iboard_ota_dev_mode'] = '1' | '0'`(默认 `'0'`)。值只存在设备端 WebView 存储里,不进数据库、不上报,换设备/清数据即回默认。 +- **当前运行版本**:已应用过 OTA 覆盖包时取覆盖包 `version`(插件 `getVersionInfo()`),否则取内置包 `APP_VERSION`;避免「内置包 0.1.0 + 覆盖包 0.2.0」时永远判定有新版本。 +- **切换开关会重置检查结果**(`otaState='idle'`、清空 `otaInfo`),避免上一种口径的结论误导用户。 +- **Web 端**:浏览器里拿不到本地 sequence。默认口径按版本号比较;开发口径退化为「服务端版本 ≠ 本地版本即算新」(近似 sequence 语义)。Web 端只提示刷新,不下载不替换,与原设计一致。 + +**版本比较算法**(`src/utils/version.js`,纯函数可单测): + +```js +parseVersionFields('0.2.0-ota') // → [0, 2, 0] +compareVersion('0.2.0-ota','0.1.0') // → 1 (0.2.0 更新) +compareVersion('0.2','0.2.0') // → 0 (缺位补 0) +``` + +规则:按非数字字符切段 → 过滤空段 → 转数字 → 逐段比较,位数不足补 0。单测 10 条覆盖 `-ota` 后缀、`v` 前缀、多位段(`0.10 > 0.9`)、空串、`beta.3 < beta.10`,全部通过。 + +### 10.4 交互与信息架构变更 + +| 项 | 变更前 | 变更后 | +| --- | --- | --- | +| 底部 Tab | 首页 / 宏观 / 监控 / 打卡 / 我(5 个) | **打卡 / 宏观 / 监控 / 我(4 个)**,打卡在最左 | +| 默认落地 | `/` → 首页(默认选中新闻) | `/` → **`/checkin`**;`RedirectIfAuthed` 与 404 兜底同样指向 `/checkin` | +| 报警 / 新闻 | 首页内的 2 个 Tab | 宏观的第 5、6 个子 Tab(`/macro/alerts`、`/macro/news`),报警角标保留 | +| 新闻详情 | `/news/:id`(底部无 Tab 高亮) | **`/macro/news/:id`**(宏观 Tab 保持高亮) | +| 打卡页头部 | `[特效开/关]` + `[+ 新建]` | 只保留 `[+ 新建]`;特效恒开 | +| 特效实现 | `effectsEnabled()` 读 `localStorage.iboard_celebrate` + `prefers-reduced-motion` 降级 | 删除开关与降级判断,`Celebrate` 只要 `fire` 变化就播放 | + +### 10.5 界面设计同步 + +`界面设计.html` 已同步:底部 Tab 改为 4 项且「打卡」高亮在首位、打卡页去掉特效开关按钮、新增「我的 · OTA 开发版本开关」屏、宏观新增「报警 / 新闻」子 Tab 与合并后的 6 Tab 导航。 diff --git a/docs/V0.2/需求文档.md b/docs/V0.2/需求文档.md index a5fdba7..b8b8421 100644 --- a/docs/V0.2/需求文档.md +++ b/docs/V0.2/需求文档.md @@ -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,23 @@ | 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-02 | 版本号错位在 CI 阶段就拦住:分支名 `dev-x.y` 与 `package.json` 版本主次号不一致时构建失败 | CI 输出明确报错并中断 | P0 | +| F-OT-03 | 「我」页 OTA 组内新增**「开发版本」开关**,默认**关闭** | 关闭=按版本号比较;打开=按 build sequence 比较 | P0 | +| F-OT-04 | 开关值**持久化在设备端**,重启 App 后保持上次选择 | 杀进程重进仍为上次状态 | P0 | +| F-OT-05 | 版本号比较规则:按非数字字符切成数字字段,逐段比较大小(`0.2.0 > 0.1.0`,`0.10 > 0.9`) | 单测覆盖后缀/前缀/缺位/多位段 | P0 | +| F-OT-06 | 切换开关后清空上一次的检查结果,避免误判 | 切换即回到「未检查」状态 | P1 | + --- ## 4. 优先级汇总(本期交付范围) -- **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~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 +211,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 +319,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 比较 | ### 接口清单 diff --git a/package.json b/package.json index b789ba1..b690597 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iboard", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "tauri": "tauri", diff --git a/readme b/readme index 121ee57..2d46c26 100644 --- a/readme +++ b/readme @@ -1,9 +1,10 @@ # iBoard 用于收集和整理经济数据,但是以策略为主体重新组织。 -## 版本管理 (V0.1) +## 版本管理 -版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。 +版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`)。 +版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`(CI 有 `scripts/ci/check-version.mjs` 强校验,不一致直接失败)。 发版时只改这一处,以下位置必须保持一致(改版本时一并核对): | 文件 | 字段 | 说明 | @@ -11,9 +12,12 @@ | `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 | | `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) | | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 | +| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 | 说明: - `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。 +- OTA 升级包的 `version` 字段 = `-ota`(见 `scripts/build-ota.mjs`), + 所以版本号错位会直接导致线上 OTA 判新失准。 - `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。 - `ota-package/latest.json` 为运行时产物,不纳入版本管理。 @@ -123,6 +127,21 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json > **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**. > 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, 仅显示"有新版本请刷新". diff --git a/scripts/ci/check-version.mjs b/scripts/ci/check-version.mjs new file mode 100644 index 0000000..af76352 --- /dev/null +++ b/scripts/ci/check-version.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// CI 专用: 校验分支名与 package.json 版本号一致 +// +// 背景: +// OTA 升级包的 version 直接取自 package.json (`-ota`), 而分支按版本命名 +// (dev-0.1 / dev-0.2 ...). 两者一旦错位 (例如 dev-0.2 上 package.json 还是 0.1.0), +// 线上 OTA 就会报成上一个版本的号, 客户端判新逻辑全部失准。 +// +// 规则: +// - 分支形如 dev-. → package.json 的 version 必须以 . 开头 +// - 分支形如 dev- → version 必须以 . 开头 +// - main / master 及其它分支 → 跳过校验 (生产分支不做约束, 只打印当前版本) +// +// 环境变量: +// GITHUB_REF_NAME - Gitea Actions 提供的分支名 (本地运行可用 BRANCH 覆盖) +// +// 退出码: 0 通过 / 1 不一致 +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..', '..') + +const branch = process.env.GITHUB_REF_NAME || process.env.BRANCH || '' +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const version = String(pkg.version || '0.0.0') +const otaVersion = version + '-ota' + +console.log('[check-version] 分支: ', branch || '(未知)') +console.log('[check-version] package.json version:', version) +console.log('[check-version] OTA 包版本将是:', otaVersion) + +const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch) +if (!m) { + console.log('[check-version] 非 dev-<版本> 分支, 跳过一致性校验') + process.exit(0) +} + +const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}` +if (!version.startsWith(want)) { + console.error('') + console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`) + console.error('[check-version] 请修改 package.json 的 version (唯一手动管理点),') + console.error('[check-version] 并同步 src-tauri/tauri.conf.json 与 src-tauri/Cargo.toml') + process.exit(1) +} + +console.log(`[check-version] 一致 (${branch} ↔ ${version})`) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7f1a392..536cfbb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1456,7 +1456,7 @@ dependencies = [ [[package]] name = "iboard" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71f3739..f19b1ee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iboard" -version = "0.1.0" +version = "0.2.0" description = "iBoard - 经济数据与策略" authors = ["iBoard Team"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cc20d44..7b82045 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index dc30220..c99ffbb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 + if (user) return return children } @@ -32,16 +33,16 @@ export default function App() { } /> }> - } /> - } /> + {/* 打卡为默认首页 */} + } /> + } /> + {/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */} } /> } /> } /> - {/* V0.2 打卡 / 日历 / 清单 */} - } /> } /> - } /> + } /> ) } \ No newline at end of file diff --git a/src/api/ota.js b/src/api/ota.js index a6d2601..ec2214a 100644 --- a/src/api/ota.js +++ b/src/api/ota.js @@ -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 { diff --git a/src/components/BottomNav.jsx b/src/components/BottomNav.jsx index f88b261..0479d0e 100644 --- a/src/components/BottomNav.jsx +++ b/src/components/BottomNav.jsx @@ -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, 内置安全区 diff --git a/src/components/RingProgress.jsx b/src/components/RingProgress.jsx index be2ff6e..db10872 100644 --- a/src/components/RingProgress.jsx +++ b/src/components/RingProgress.jsx @@ -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 { diff --git a/src/components/TabIcon.jsx b/src/components/TabIcon.jsx index 736c86c..65e4833 100644 --- a/src/components/TabIcon.jsx +++ b/src/components/TabIcon.jsx @@ -2,13 +2,7 @@ // props: name 字符串, active 布尔 // 返回对应的 24x24 SVG 节点 const ICONS = { - home: (active) => ( - - - - {active ? : null} - - ), + // V0.2: 打卡移到最左, 原「首页」已并入宏观, home 图标不再使用 macro: (active) => ( diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx deleted file mode 100644 index 387e587..0000000 --- a/src/pages/HomePage.jsx +++ /dev/null @@ -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: ( - - 报警 - {alertCount > 0 && } - - ), - render: () => , - }, - { key: 'news', label: '新闻', render: () => }, - ] - - return ( -
- -
- -
-
- ) -} - -// 报警模块 -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 ( -
-
报警
- {items.length === 0 && !loading ? ( - - ) : ( -
- {items.map((a) => ( - -
- {a.indicatorName} - {a.period} -
-
- {a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB} - 差异 {(a.diffRatio * 100).toFixed(1)}% -
-
{fmtDateTime(a.createdAt)}
-
- ))} -
- {loading ? : (done ? '— 已加载全部 —' : '')} -
-
- )} -
- ) -} - -// 新闻模块 -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 ( -
-
新闻
- {items.length === 0 && !loading ? ( - - ) : ( -
- {items.map((n) => ( - navigate('/news/' + n.id)}> -
{n.title}
- {n.summary &&
{n.summary}
} -
- {n.source || '未知来源'} - {fmtDate(n.publishedAt)} - #{n.impactRank} -
-
- ))} -
- {loading ? : (done ? '— 已加载全部 —' : '')} -
-
- )} -
- ) -} diff --git a/src/pages/MacroPage.jsx b/src/pages/MacroPage.jsx index 7bdc27b..ce04898 100644 --- a/src/pages/MacroPage.jsx +++ b/src/pages/MacroPage.jsx @@ -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' + ? 报警{alertCount > 0 && } + : t.label, render: () => { if (t.key === 'oil') return if (t.key === 'data') return if (t.key === 'strategy') return if (t.key === 'articles') return + if (t.key === 'alerts') return + if (t.key === 'news') return 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 ( } /> } /> } /> + } /> ) } diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx index b393c50..4ac9651 100644 --- a/src/pages/MePage.jsx +++ b/src/pages/MePage.jsx @@ -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() { 运行资源 + {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */} + { + setDevMode(v) + setOtaDevMode(v) + // 切换口径后旧检查结果失效, 重置避免误导 + setOtaState('idle') + setOtaInfo(null) + setError('') + Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' }) + }} + /> + } + description={ + devMode + ? '按 build sequence 判新:每次构建都算新版本,便于开发联调' + : '按版本号判新:仅当服务端版本号更高时才提示更新' + } + > + 开发版本 +
) -} +} diff --git a/src/pages/checkin/HabitView.jsx b/src/pages/checkin/HabitView.jsx index 63998cc..cc9ca08 100644 --- a/src/pages/checkin/HabitView.jsx +++ b/src/pages/checkin/HabitView.jsx @@ -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 }) {
- diff --git a/src/pages/macro/AlertsView.jsx b/src/pages/macro/AlertsView.jsx new file mode 100644 index 0000000..12bca59 --- /dev/null +++ b/src/pages/macro/AlertsView.jsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+
+ {items.map((a) => ( + +
+ {a.indicatorName} + {a.period} +
+
+ {a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB} + 差异 {(a.diffRatio * 100).toFixed(1)}% +
+
{fmtDateTime(a.createdAt)}
+
+ ))} +
+ {loading ? : (done ? '— 已加载全部 —' : '')} +
+
+
+ ) +} diff --git a/src/pages/macro/NewsView.jsx b/src/pages/macro/NewsView.jsx new file mode 100644 index 0000000..e883f72 --- /dev/null +++ b/src/pages/macro/NewsView.jsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+
+ {items.map((n) => ( + navigate('/macro/news/' + n.id)}> +
{n.title}
+ {n.summary &&
{n.summary}
} +
+ {n.source || '未知来源'} + {fmtDate(n.publishedAt)} + #{n.impactRank} +
+
+ ))} +
+ {loading ? : (done ? '— 已加载全部 —' : '')} +
+
+
+ ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 4c407ee..24f269c 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; } diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..9f92724 --- /dev/null +++ b/src/utils/format.js @@ -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()) + ) +} diff --git a/src/utils/version.js b/src/utils/version.js new file mode 100644 index 0000000..adf69fc --- /dev/null +++ b/src/utils/version.js @@ -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 +}