Merge remote-tracking branch 'origin/main' into feat/v0.3.0-toast-confirm-feedback

This commit is contained in:
徐翔宇 2026-05-20 16:34:53 +08:00
commit 278199911f
3 changed files with 372 additions and 0 deletions

View File

@ -68,7 +68,19 @@ actor ETFClient {
static func isETFCode(_ code: String) -> Bool { static func isETFCode(_ code: String) -> Bool {
guard code.count == 6, let n = Int(code) else { return false } guard code.count == 6, let n = Int(code) else { return false }
let prefix = n / 1000 let prefix = n / 1000
// P0 fix (2026-05-19 review): the prior `150...199` range was
// too wide it caught LOF codes that aren't ETFs:
// 161xxx LOF / 162xxx LOF / 163xxx LOF
// 165xxx LOF / 166xxx LOF / 167xxx LOF
// 168xxx, 169xxx LOF
// LOFs trade by 1-day NAV pricing, not by ETF intraday quote
// ticks. Routing them through ETFClient would surface stale
// or wrong-shaped data on the holding row.
// Explicit-deny the LOF prefixes; keep everything else in the
// legacy range routing for backwards compat.
switch prefix { switch prefix {
case 161, 162, 163, 165, 166, 167, 168, 169:
return false
case 510...599, 150...199: case 510...599, 150...199:
return true return true
default: default:

View File

@ -0,0 +1,236 @@
# 看盘侠 — 代码质量 Review
**日期**: 2026-05-19
**审视分支**: `feat/v0.3.0-toast-confirm-feedback` @ `63c7c0b`
**源码规模**: 26 Swift 文件,~3000 行HoldingsView 395 行 / FundClient 244 行最大
**测试**: 0无 XCTest target无测试文件
---
## 一、架构 — 7/10
清晰分层:
```
data/ HTTP clients (actor) + parser + Models
storage/ 持久化 stores (@MainActor)
engine/ RefreshScheduler + FundStore + debug log
ui/ SwiftUI views + Theme + Toast/ConfirmDialog
FundPlugin 主类胶水
```
`@MainActor` 标在状态层 (FundStore / Watchlist / GoldPositionStore)`actor` 标在网络客户端 (FundClient / GoldClient / ETFClient / SpotGoldClient / GoldKlineClient / GoldMinuteClient)。**并发模型一致**。
### 问题 1store 持有 watchlist 但没转发 objectWillChange
`FundStore.swift:30` `let watchlist: Watchlist`。store 没 subscribe `watchlist.$funds` 转发到自己。watchlist 的 `@Published` 改动只对直接观察它的 view 生效。`HoldingsView` 直接 `store.watchlist.funds` 读能 work 是因为 SwiftUI 沿 @ObservedObject 链 propagation但 store 上的 `totalMarketValue` / `count(for:)` 这类 computed 不会触发 store 自己的 objectWillChange。**结果**watchlist add/remove 后hero card 重算依赖下次 store.estimates 触发的 view update 顺带过来,时序边缘有 stale 风险。
**修法**
```swift
init() {
self.watchlist = Watchlist()
watchlist.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
}
```
### 问题 2goldDailyBars / GoldRange 全链路 dead code
`Models.swift:181 GoldRange` + `FundStore.goldBars(for:)` + `GoldKlineClient.swift` 全部健在,但 `GoldView` 渲染的是 `goldMinuteLine`(分钟线),不再调 `goldBars(for:)`。整套日 K 线机制在 store 里循环 refreshview 没 render。**删能少 200 行**。
---
## 二、状态管理 — 7/10
### 强项
- `FundStore.shared` 单例view 全用 `@ObservedObject` 共享,一致
- `WatchlistFund.shares/costNav` Optional 设计 graceful 支持 watchlist-only 模式
### 问题 1estimates 整本字典拷贝
`FundStore.swift:94-112` 每次 refresh 拷贝整本 dict → mutate → assign 回 `self.estimates`。同理 `goldQuotes`。N 只基金 O(N) 复制。对 10-50 只 OK扩到 200 只 row diff 全量重算会 visible jank。
**修法**:单 entry update + `self.estimates[code] = ...` 直接 mutate依赖 `@Published` 的 willSet/didSet 一次性发 signal。
### 问题 2Watchlist init() 同步 load
`Watchlist.swift:18-26` init() 内同步 `Data(contentsOf: storeURL)` + JSON decode。watchlist.json 大到几 MB 会卡主线程。当前不是问题,但 ceiling 在那里。
---
## 三、并发安全 — 8/10
### 强项
- 所有 HTTP client 都标 `actor`,避免 URLSession 跨线程 hazard
- `@MainActor` 在 store 和 UI 控制器 (ToastController / ConfirmController)
- `Task { @MainActor in ... }` 在 plugin lifecycle 里跨域 hop 正确
### 🔴 BUG 1Toast.swift:64 sleep 时长 truncate
```swift
try? await Task.sleep(nanoseconds: UInt64(self?.durationSeconds ?? 2.6) * 1_000_000_000)
```
`UInt64(2.6) == 2`Double → UInt64 truncate。乘 1_000_000_000 得 2_000_000_000 ns = **2.0s**,不是 `durationSeconds: Double = 2.6` 想要的 2.6s。Toast 比设计早 0.6s 消失。
**修法**
```swift
let nanos = UInt64((self?.durationSeconds ?? 2.6) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
```
这是 typo 级 bug**v0.3.0 release 前必修**。
### 问题 2ConfirmController 并发 ask() 没保护
`ConfirmDialog.swift:54-56`:若两个 caller 同时 await `ask()`,第一个 continuation 被 resume(false),第二个 pending = req 覆盖。@MainActor 隔离让这 race 不会内存损坏,但语义上前者拿到一个"用户没操作"的 false。当前注释说"shouldn't normally happen"但没保证。**建议**:要么文档化要么改严格队列。
---
## 四、错误处理 — 5/10
### 🔴 问题 1AddView 搜索失败 silent swallow
`AddView.swift:138`
```swift
do { hits = try await FundClient.shared.search(q) }
catch { hits = [] }
```
用户搜东西网超时看到"未找到匹配的基金"。实际是 15s timeout。**P0 加 error toast**。
### 问题 2GoldClient / SpotGoldClient errors silent degrade
`FundStore.swift:140` `if let s = await spot { self.spotGold = s }` — fetch 失败 spotGold 保持上一次值。用户看到 stale price 不知道。**建议**:连续 3 次失败 → footer 显示"网络异常"。
### 问题 3FundDebugLog 写 `/tmp/fund-plugin.log` 无 rotation
`FundDebugLog.swift:14`production 装机后每次 refresh 都追加文件。macOS `/tmp` 不会自动清。size cap 缺。**建议**:写到 `~/Library/Logs/Mio Island/fund-plugin.log` + 10MB rotate或只在 DEBUG flag 启用。
---
## 五、网络层 — 8/10
### 强项
- 真懂 Sina/Eastmoney quirksGB18030 编码、Referer 校验、JSONP `jsonpgz(...)` 包装、cache-buster `?rt=ts`
- `withTaskGroup` 并行 fetch单 endpoint 死不拖累全 panel
- 全 HTTPSFundClient.estimate URL 也是 https
### 问题 1timeout 不一致
| Client | request / resource |
|---|---|
| FundClient | 15s / 25s |
| GoldClient | 6s / 12s |
| SpotGoldClient | 6s / 12s |
| ETFClient | 6s / 12s |
| GoldKlineClient | 8s / 15s |
| GoldMinuteClient | 8s / 15s |
混合超时本身 OK但 cold-start 时第一个 TLS 握手往往压在 fundgzFundClient 15s 是对的)。其它 client 沿用 6s 在 cold start 会被掐。**建议**:全 client 统一到 `URLSessionConfiguration.timeoutIntervalForRequest = 12` baseline。
### 问题 2没 retry / 没 exponential backoff
fetch 失败 → 等下次 cadence (60s/30min) → 再失败。冷启动 3 分钟没数据 = 用户走人。**建议**first refresh 失败 → 5s 后再试一次(不算正常 cadence
### 🔴 问题 3ETFClient.isETFCode 路由过宽
`EtfClient.swift:71-72`
```swift
case 510...599, 150...199:
return true
```
`150...199` 覆盖深市 159 (ETF) **但也覆盖 161/162/163 (LOF)** 和 167 (LOF) — LOF 走 OTC 净值估值不是分笔交易,被路由到 ETFClient 会失败/取错数据。**修法**:白名单收紧到 159, 165, 188, 159001-159999 实际是 ETF 的码段。
---
## 六、UI 层 — 7/10
### 强项
- `Theme.swift` 集中色彩 tokenall views 引用
- 一致的 RoundedRectangle + overlay04 + 0.5 stroke 风格
- `Color.upDown(value)` helper 抽象红涨绿跌
- `SparkLine.swift` 自绘 SVG-equivalent零依赖
### 问题 1watchlist.funds.map(\.code) 当 animation value
`HoldingsView.swift:131`
```swift
.animation(.spring(...), value: store.watchlist.funds.map(\.code))
```
每次 render 都构造新 `[String]`。性能上无害但 inefficient。**建议**Watchlist 加 `var codeFingerprint: Int { funds.reduce(0) { $0 &+ $1.code.hashValue } }`
### 问题 2没 reorder UI
`Watchlist.swift:56` 提供 `move(from:to:)` API但没 view 调用。drag-reorder 没实现。**P1**。
---
## 七、安全 — 7/10
### 强项
- 全 HTTPS
- 没存敏感 token / 用户登录 — 100% 本地
- watchlist.json 写到 user-domain Application Support不需要 entitlement
- `codesign --force --deep --sign -` ad-hoc 签名 OK
### 问题 1User-Agent "Mozilla/5.0"
短期 OK但上架后用户激增被识别为 bot 是 risk。**建议**UA 改成 `MioIsland-FundPlugin/0.3.0 (...)` —— 被 ban 时是定向不是误伤。
### 问题 2没 rate limit
`ExpandedView.onAppear` trigger refresh用户频繁 show/hide 刘海 panel 会蹬一波请求。**建议**onAppear 检查 `lastFundRefresh < 30s` 就跳过。
---
## 八、测试 — 0/10
**No tests at all**。HANDOFF.md 提到 `/tmp/test-fund-clients.swift` 单文件 smoke test 但不在 repo 里。
**v1 必须有的**
| 文件 | 测试场景 |
|---|---|
| `SinaQuoteParser` | GB18030 sample fixtures × 6 sourceCOMEX/London/SHFE/AU9999/sh/sz ETF|
| `ETFClient.isETFCode` | 边界码510, 512, 518, 159, 161 (LOF), 167 (LOF), 588 |
| `FundClient` JSONP 解析 | happy / `jsonpgz();` empty / 非 UTF-8 malformed |
| `Watchlist` save/load roundtrip | + 异常磁盘 atomic 写 |
| `RefreshScheduler.isAShareTradeHour` | 09:29 / 09:30 / 11:30 / 11:31 / 13:00 / 15:00 / 周末 |
---
## 九、Code smell 汇总
| 文件:行 | 问题 | 优先级 | 修复行数 |
|---|---|---|---|
| `Toast.swift:64` | `UInt64(2.6)` truncatetoast 早 0.6s 消失 | 🔴 P0 | 2 |
| `AddView.swift:138` | 搜索失败 swallow没 toast | 🔴 P0 | 5 |
| `EtfClient.swift:71` | 150-199 路由 ETF 把 LOF 误收 | 🔴 P0 | 5 |
| `FundStore.swift:160-163` + `Models.swift:181` + `GoldKlineClient.swift` | 日 K 线 dead code | 🟡 P1 | 删 ~200 |
| `Watchlist.swift:78` | init 同步 load 大文件 | 🟡 P1 | 异步迁移 |
| `FundDebugLog.swift` | 写 `/tmp` 无 rotation | 🟡 P1 | 移路径 + size cap |
| `HoldingsView.swift:131` | map(\.code) 每次 render 跑 | 🟢 P2 | 加 fingerprint |
| 全 repo | 0 tests | 🟡 P1 | 加 XCTest target |
| `FundStore.swift:30` | watchlist objectWillChange 未转发 | 🟢 P2 | 5 |
---
## 十、总评
代码质量 **7/10** — 架构清晰、并发模型一致、网络层懂中国数据源。
**主要短板**
1. **测试覆盖 = 0** —— 上架后崩了不知道哪坏
2. **Toast.swift:64 是 typo 级 bug**UInt64 cast 数值常量)— 必修
3. **dead code 还在** — GoldRange / goldDailyBars / GoldKlineClient 没人用了
4. **ETF 码段路由把 LOF 误收** — 用户体验直接坏
5. **silent network failures** — 没用户可见信号
修完 P0 三项 + 加最小测试集5 个 fixturecode quality 能拉到 9/10。

View File

@ -0,0 +1,124 @@
# 看盘侠 — 产品 Review
**日期**: 2026-05-19
**审视版本**: v0.3.0 (`feat/v0.3.0-toast-confirm-feedback` @ `63c7c0b`)
**装机验证**: `~/.config/codeisland/plugins/fund.bundle` v0.3.0 build 3 已在 Mio Island 3.0.0 运行
---
## 一、核心价值主张
| 维度 | 评分 | 说明 |
|---|---|---|
| 差异化 | 8/10 | 基金 + 黄金跨品类合一,刘海常驻,无服务端依赖 |
| 价值密度 | 7/10 | 持仓 P&L 实时算 + 黄金分钟线 + 多市场对比 pill 一屏内 |
| 上手摩擦 | 8/10 | 持仓 shares/cost 可不填graceful 降级到 watchlist 模式 |
### 强项
1. **跨品类合一** — 市面上养基宝只看基金,跟谁学只看金价。同时看一屏没几个。
2. **零服务端架构** — 数据全走天天/东方财富/新浪公开端点,永远不会因为后端关停就死。代价:没法做服务端聚合或推送。
3. **持仓非强制**`WatchlistFund.shares` / `costNav` 都是 `Double?`,没填也能用 watchlist 模式(`HoldingsView.swift:142-175` hero card 自动 fallback "X 只基金")。降低 onboarding 摩擦。
### 弱项
1. **README pricing 是空头支票** — README.md:98-105 写了 ¥49 once / ¥99 yearly 的 Pro tier 表格 + AI 解盘v0.3.0 全免费、license 系统不存在。要么删定价段,要么尽快做 license gating —— 否则上架审核员看到会困惑,用户看到会觉得"骗"。
2. **"看盘侠"名字 vs 实际功能 mismatch** — 名字暗示"看 K 线深度"(同花顺/雪球级别),实际是基金估值 + 金价。第一次打开的用户期待会被拉低。
3. **黄金为什么挂在基金 tracker 里?** — 概念上不重叠。如果用户只想看金价不看基金,"持仓 tab + 添加 tab" 都是 dead weight。
---
## 二、UX 流程完整度
### 走通 happy pathv0.3.0 新增反馈环)
1. 持仓空状态 → 点 "去添加" → tab 切到添加 ✓
2. 搜 "易方达蓝筹" → tap + → 绿 toast「已添加「xxx」到自选」✓v0.3 新)
3. 回持仓 tab → 看见 row → tap 展开 PositionEditor → 填份额+成本 → 绿 toast ✓
4. Hero card 自动算 总市值 / 今日盈亏 / 累计盈亏 / 收益率 ✓
5. hover row → 出 ✕ → tap → 红色 ConfirmDialog → 确认/取消 ✓v0.3 新,防误删)
### 断点与边缘 case
1. **搜索失败 silent swallow**`AddView.swift:138`
```swift
do { hits = try await FundClient.shared.search(q) }
catch { hits = [] }
```
用户搜 "易方达" 网超时,看到"未找到匹配的基金"。实际是 15s 超时不是没结果。**P0**error path 加错误 toast。
2. **没"搜索历史"** — 用户搜过一次就丢,下次重启又要打字。`AddView` state 完全 ephemeral。**P1**5 个 recent searches 就够。
3. **黄金 K 线产品意图不明**`GoldView` 实际渲染的是 `goldMinuteLine`(当日分钟线),但 README 还在讲"1月/3月/1年/全部" 日 K 切换,`Models.swift:181 GoldRange` enum 也还在。两套数据流并存只渲染一套。要么把日 K 切换的 UI 找回来,要么砍 dead code。
4. **"添加" tab 已添加 row** — `SearchRow.swift:182 .disabled(alreadyAdded)` 与 v0.3 新加的"再点弹 info toast"逻辑冲突 — disabled 状态会吃掉 tapinfo toast 永远不触发。需 cleanup。
5. **黄金持仓 vs 基金持仓 UX 不一致** — 基金 PositionEditor 在 row 下方展开 (`HoldingsView.swift:99`);黄金 GoldPositionCard 在 card 内 toggle (`GoldPositionCard.swift:32-43`)。同一概念两种交互。
6. **footer "更新于 X 分前"误读**`ExpandedView.swift:210` 显示上次刷新时间,但盘后实际 cadence 是 30 min。用户看到"更新于 25 分前"会以为坏了。**建议**:盘后改成"下次刷新 N 分后"或"盘后空闲"。
---
## 三、信息架构
| 强项 | 弱项 |
|---|---|
| 持仓 column header今日/累计)— 一眼看清两列含义 | "添加"tab 默认页写"数据源: 东方财富 fund.eastmoney.com" — 偏 dev-y |
| Hero card graceful 降级("X 只基金"| "国内金价 AU9999.SGE" — 普通用户不认这 ticker |
| 黄金 tab 一屏放下:实时 + 分钟线 + 持仓 + 伦敦/纽约 pill | 没"全局 status"footer 字段refresh 失败/网异常没 surface |
| `.upDown(value)` helper 一致的中国式红涨绿跌 | hero card 上 hover 没 affordance用户不知道 row 可点) |
---
## 四、与竞品对比
| | 看盘侠 | 养基宝 | 蛋卷基金 | 同花顺 Mac |
|---|---|---|---|---|
| 桌面常驻 | ✅ 刘海 | ❌ App | ❌ App | ❌ App |
| 隐私(无登录无服务端)| ✅ | ❌ | ❌ | ❌ |
| 基金 + 黄金一屏 | ✅ | ❌ | ❌ | ⚠️ 不同模块 |
| 持仓 P&L | ✅ | ✅ | ✅ | ✅ |
| AI 解盘 | ❌(计划)| ✅ | ❌ | ⚠️ |
| K 线深度 | ⚠️ 分钟线 | ❌ | ❌ | ✅✅ |
| 多账户 | ❌ | ✅ | ✅ | ✅ |
| ETF 实时 | ✅ | ⚠️ | ⚠️ | ✅ |
**差异化定位**:刘海 + 跨品类 + 隐私。瞄准"已经知道自己持仓、不想再装一个 app 占菜单栏"的轻量用户。**不要**对标同花顺做深度。
---
## 五、上架前必修P0
| # | 项 | 文件 / 位置 |
|---|---|---|
| 1 | README pricing 删掉或改成"Pro tier 计划中" | `README.md:98-105` |
| 2 | 搜索失败要弹 error toast | `AddView.swift:138` catch 块 |
| 3 | 删 dead K 线代码 OR 把日 K 切换 UI 加回来 | `GoldKlineClient` / `goldDailyBars` / `GoldRange` |
| 4 | footer 文案盘后改成"下次刷新 N 分后" | `ExpandedView.swift:209-213` |
| 5 | SearchRow.disabled vs info toast 二选一 | `AddView.swift:182` |
---
## 六、建议加P1
1. **个股 quote tab** — 已有 ETFClient (Sina sh/sz endpoint),扩展 A 股股票几乎就是 prefix 路由
2. **搜索历史** — 5 个 recent searches
3. **设置面板** — refresh interval 可调、tab 显示偏好(隐藏黄金?)
4. **每日 P&L 累计 chart** — 持仓 hero data 存 SQLite画周线趋势这是 Pro tier 的真正钩子,比 AI 解盘更落地)
5. **drag-reorder 持仓** — Watchlist 已经有 `move(from:to:)` APIview 没用
---
## 七、建议砍P-1
1. **黄金 K 线 1月/3月/1年/全部 切换** — 代码留着没用上;产品上"长线看金"跟 quick-glance 体验不匹配
2. **MioIsland 主题适配** — HANDOFF.md 自己说没做。固定 lime/gold 配色其实视觉更统一,不要补
---
## 八、总评
| 问 | 答 |
|---|---|
| v0.3.0 上架免费版? | **是** |
| v0.3.0 上架收钱版? | **否**pricing 是空头支票AI 解盘没影)|
| 上架前必修项数? | 5 个(见上)|
| 最大产品风险 | 名字"看盘侠" vs 实际功能 mismatch第一印象拉低 |
| 最大产品亮点 | 跨品类 + 刘海常驻 + 隐私三合一 |
**优先级 sequence**:修 P0 五项 → tag v0.3.0 → 上架免费 → 做"个股 tab"和"P&L 趋势"作为 Pro 钩子 → 再谈付费。