mirror of
https://github.com/carey314/mio-plugin-fund.git
synced 2026-08-10 05:44:31 +00:00
v0.2.0: initial 看盘侠 — Fund & Gold plugin for MioIsland
Real-time OTC fund and gold tracker for the macOS notch. Three tabs: - 持仓: live intraday estimates from 天天基金, hero card with total value / today P&L / cumulative P&L, per-row 当日 + 累计. - 黄金: SHFE 沪金 realtime + daily K-line + 伦敦金 reference. - 添加: search 26k+ public funds via 东方财富 suggest API. No API keys, no Python, no servers. All data comes from public endpoints (天天基金, 东方财富, 新浪财经). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
e5a72bcf42
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
*.swiftmodule
|
||||||
|
*.dSYM
|
||||||
|
.build/
|
||||||
127
HANDOFF.md
Normal file
127
HANDOFF.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# 盯基金 v0.2 — 早晨交付报告
|
||||||
|
|
||||||
|
> 你睡前要求:参考设计文档 `Mac 灵动岛 - 基金面板.html` 重写 UI;找免费实数据接口;自己测;早晨给报告。
|
||||||
|
|
||||||
|
## 🧾 交付清单
|
||||||
|
|
||||||
|
### ✅ 已完成
|
||||||
|
|
||||||
|
| 项 | 证据 |
|
||||||
|
|---|---|
|
||||||
|
| 拉取设计文档 + 解压 tar 包 | `/tmp/fund-design/` |
|
||||||
|
| 读完 chat transcript / panel-holdings.jsx / panel-gold.jsx / panel-add.jsx / styles.css | 已对齐设计 token |
|
||||||
|
| **面板尺寸 380×540**(设计原值,原 v0.1 是 440×560) | `Info.plist` 已改 |
|
||||||
|
| **配色还原**:lime `#d4ff3a` + 红涨绿跌 + 黑底 | `Theme.swift` |
|
||||||
|
| **持仓 tab 重构**:hero card (总市值 / 今日盈亏 / 累计盈亏 / 收益率) + holding rows | `HoldingsView.swift` |
|
||||||
|
| **黄金 tab 重构**:hero + SVG sparkline + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 reference 卡 | `GoldView.swift` + `SparkLine.swift` |
|
||||||
|
| **添加 tab 重构**:搜索框 + tag 颜色 + 已加 checkmark | `AddView.swift` |
|
||||||
|
| **新增持仓编辑功能**(设计图没明示但逻辑上必须有) | `PositionEditor.swift` — 点击 row 弹出 inline 表单填份额+成本 |
|
||||||
|
| **数据源接入**: 7 个免费接口全部验证通过 | 见下表 |
|
||||||
|
| **build 一把过** | 18 文件 0 error |
|
||||||
|
| **30 秒 soak 测试不崩** | 装到 ~/.config/codeisland/plugins/,Mio Island 重启后 PID 持续存活,无新 crash 文件 |
|
||||||
|
| **Swift 端独立验证 client** | `/tmp/test-fund-clients.swift` 7 个 endpoint 真测 |
|
||||||
|
|
||||||
|
### ⚠️ 没完成
|
||||||
|
|
||||||
|
- **付费门槛** — v0.2 是 UI 壳,license 系统留给 v0.3
|
||||||
|
- **黄金「我的持仓」卡片**(设计图末尾那块) — 设计里有但因为黄金不像基金有「自选」概念,需要先和你确认产品逻辑:是单独输入金条克数还是关联某个黄金 ETF?我跳过这块没瞎做
|
||||||
|
- **K 线 hover tooltip** — 设计里只有静态当前点,hover 显示具体日期价格的功能没实现
|
||||||
|
- **数字 tick 动画**(设计里 `.flash-up` / `.flash-down`) — 跳过,性能成本 vs 用户感知比不划算
|
||||||
|
- **Mio Island 主题适配** — 我没跟随宿主主题(host 的 Theme 系统是 internal),插件用了固定的 lime/gold/red/green 配色
|
||||||
|
|
||||||
|
## 📊 数据源验证结果
|
||||||
|
|
||||||
|
| Endpoint | 用途 | 提供商 | 实测今晚 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 基金搜索 | 东方财富 | ✅ "易方达" → 110006 易方达货币A 等 |
|
||||||
|
| `fundgz.1234567.com.cn/js/{code}.js` | 盘中估值 | 天天基金 | ✅ 005827 估值 1.7669 (+0.53%) @ 04-24 15:00 |
|
||||||
|
| `api.fund.eastmoney.com/f10/lsjz` | 历史净值 | 东方财富 | ✅ 005827 近 3 日 1.7678 / 1.7575 / 1.7502 |
|
||||||
|
| `hq.sinajs.cn/list=AU0` | 沪金实时 (RMB/g) | 新浪 | ✅ 黄金连续 574.94 (GB18030 解码) |
|
||||||
|
| `hq.sinajs.cn/list=hf_GC` | 纽约金实时 (USD/oz) | 新浪 | ✅ 4728.253 USD/oz |
|
||||||
|
| `hq.sinajs.cn/list=hf_XAU` | 伦敦金实时 | 新浪 | ✅ 4708.05 |
|
||||||
|
| `stock2.finance.sina.com.cn/.../getInnerFuturesDailyKLine?symbol=AU0` | 沪金日 K 线 | 新浪 | ✅ 4032 bars 全部历史 |
|
||||||
|
|
||||||
|
**没用到 Python,没用 akshare,没用 subprocess,没用服务端**。纯 Swift HTTP / JSON / GB18030 解码。零依赖。
|
||||||
|
|
||||||
|
## 🧱 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── MioPlugin.swift # 协议 (verbatim from host)
|
||||||
|
├── FundPlugin.swift # 主入口 (NSPrincipalClass)
|
||||||
|
├── data/
|
||||||
|
│ ├── Models.swift # WatchlistFund / FundEstimate / GoldQuote / GoldDailyBar / GoldRange
|
||||||
|
│ ├── FundClient.swift # 3 个基金 API (search / estimate / history)
|
||||||
|
│ ├── GoldClient.swift # 4 路黄金实时 (沪金/伦金/纽金 + 上海现货占位)
|
||||||
|
│ ├── GoldKlineClient.swift # 沪金日 K 线
|
||||||
|
│ └── SinaQuoteParser.swift # GB18030 解码 + 字段位解析
|
||||||
|
├── storage/
|
||||||
|
│ └── Watchlist.swift # JSON 持久化 (持仓 + 份额 + 成本)
|
||||||
|
├── engine/
|
||||||
|
│ ├── RefreshScheduler.swift # 交易时段感知 (A 股/SHFE 时段)
|
||||||
|
│ └── FundStore.swift # @MainActor 状态层 (估值字典 + 黄金字典 + K线缓存)
|
||||||
|
└── ui/
|
||||||
|
├── Theme.swift # 设计 token 单一来源
|
||||||
|
├── ExpandedView.swift # 380×540 主面板 + 3 tab + footer
|
||||||
|
├── HeaderSlotView.swift # 20×20 notch 图标
|
||||||
|
├── HoldingsView.swift # 持仓 tab + hero card
|
||||||
|
├── GoldView.swift # 黄金 tab + 实时 + K 线
|
||||||
|
├── AddView.swift # 添加 tab
|
||||||
|
├── PositionEditor.swift # 内联编辑份额/成本
|
||||||
|
└── SparkLine.swift # 纯 SwiftUI 等价 SVG sparkline
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 自测方式(你早晨可以亲自跑一遍)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 跑 Swift 端数据源烟测
|
||||||
|
swift /tmp/test-fund-clients.swift
|
||||||
|
|
||||||
|
# 2. 重新装 v0.2 plugin
|
||||||
|
cd /tmp/mio-plugin-fund && bash build.sh install
|
||||||
|
|
||||||
|
# 3. 退出并重启 Mio Island
|
||||||
|
osascript -e 'tell application "Mio Island" to quit'
|
||||||
|
open "/Users/carey/Library/Developer/Xcode/DerivedData/ClaudeIsland-bgvikibpiccmlvboctrpootcaxuo/Build/Products/Debug/Mio Island.app"
|
||||||
|
|
||||||
|
# 4. 在刘海下找到 chart.line.uptrend 图标 → 点开
|
||||||
|
# - 持仓 tab: 空状态 → 点「去添加」
|
||||||
|
# - 添加 tab: 搜「易方达」/「005827」之类 → 点 + 加进自选
|
||||||
|
# - 持仓 tab: 看到基金 row + 估算涨跌幅 → 点 row 展开 PositionEditor
|
||||||
|
# → 填份额 (e.g. 100) + 成本 (e.g. 1.50) → 保存
|
||||||
|
# - hero card 现在应该显示 总市值 / 今日盈亏 / 累计盈亏 / 收益率
|
||||||
|
# - 黄金 tab: 沪金实时 + K 线 + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 pill
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 视觉对照
|
||||||
|
|
||||||
|
我没法截图(Screen Recording TCC 不给)。**你早上拍一张实际效果对照设计图**:
|
||||||
|
- 设计图位置:`/tmp/fund-design/tradingisland/project/Mac 灵动岛 - 基金面板.html`
|
||||||
|
- 在浏览器打开比较:`open /tmp/fund-design/tradingisland/project/Mac\ 灵动岛\ -\ 基金面板.html`
|
||||||
|
|
||||||
|
视觉差异预期(vs 设计图):
|
||||||
|
1. **设计图是 mock 数据 + 假涨跌**,我的是真数据 — 数字会不一样
|
||||||
|
2. **设计的「黄金 · 持仓」卡片**没做(说明里解释了原因)
|
||||||
|
3. **伦敦金/纽约金 reference 卡的位置**:设计放右下方,我放在 K 线下面 —— 放下面更突出实时价
|
||||||
|
4. **数字 flash 动画**没做
|
||||||
|
|
||||||
|
## ⚠️ 缩水的地方(按交付协议必须列)
|
||||||
|
|
||||||
|
1. **没真在 UI 上加完整自选基金 + 改持仓 + 看到完整 hero card 数据流**:30 秒 soak 验证了"不崩 + plugin 加载",但没用真用户操作(点击 + 输入)走完一遍 happy path。原因:Screen Recording 权限我没有,没法点击。**你早上手动跑一次最重要**。
|
||||||
|
2. **黄金 K 线"今开/最高/最低"字段从沪金实时 quote 取的**,但 Sina AU0 在凌晨非交易时段返回的字段时间戳是 2024-07 的旧数据(盘中实时才会有当天的真值)。**白天交易时段会自动正确**,凌晨看会怪——这是数据源固有特性,不是 bug。
|
||||||
|
3. **付费门槛没做** — v0.2 所有功能免费可用,license 系统等 v0.3 加。
|
||||||
|
4. **没接触 Mio Island host 主题系统** — 插件是固定深色配色,宿主切了 retro 主题这里也不会跟着变。设计图本来就是固定深色 lime 风格,我按设计来。
|
||||||
|
5. **`/tmp/mio-plugin-fund` 还在 tmp**,没 push 到 GitHub。早晨你点头我可以创建 `xmqywx/mio-plugin-fund` 仓库 push 上去 + 准备上架 miomio.chat。
|
||||||
|
|
||||||
|
## 🚀 早晨待办(需要你拍板)
|
||||||
|
|
||||||
|
- [ ] 亲手测一遍 happy path(搜基金 → 加 → 编辑持仓 → 看 hero card)
|
||||||
|
- [ ] 看 K 线和设计图对比满意度
|
||||||
|
- [ ] 决定要不要做"黄金持仓"那块(输入克数+成本 → 算盈亏)
|
||||||
|
- [ ] 决定要不要做付费门槛(v0.3 任务清单)
|
||||||
|
- [ ] 决定要不要 push 到 GitHub + 上架 miomio.chat
|
||||||
|
- [ ] 群公告文案需不需要我准备
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
晚安。
|
||||||
38
Info.plist
Normal file
38
Info.plist
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>zh_CN</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>FundPlugin</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.mioisland.plugin.fund</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>看盘侠</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>BNDL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.2.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>2</string>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>FundPlugin.FundPlugin</string>
|
||||||
|
<!--
|
||||||
|
Panel size: 380×580.
|
||||||
|
- 380 wide so the island isn't too thick.
|
||||||
|
- 580 tall = 40pt notch reservation + 540pt of card content
|
||||||
|
(hero + tabs + list/chart + footer). Mirrors the Music
|
||||||
|
Player layout pattern: dedicate the top band for the
|
||||||
|
physical notch + host's floating back-chevron, then put
|
||||||
|
actual plugin chrome below.
|
||||||
|
Host clamp is [280-1200] × [120-900] so we sit comfortably.
|
||||||
|
-->
|
||||||
|
<key>MioPluginPreferredWidth</key>
|
||||||
|
<integer>380</integer>
|
||||||
|
<key>MioPluginPreferredHeight</key>
|
||||||
|
<integer>580</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 MioMioOS
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
109
README.md
Normal file
109
README.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 看盘侠 — Fund & Gold Tracker for MioIsland
|
||||||
|
|
||||||
|
Real-time OTC fund and gold tracker for the macOS notch. Built as a
|
||||||
|
native `.bundle` plugin for [MioIsland](https://github.com/MioMioOS/MioIsland).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 持仓 (Holdings)
|
||||||
|
- Live intraday fund estimates (~1 min granularity from 天天基金).
|
||||||
|
- Hero card with 总市值 / 今日盈亏 / 累计盈亏 / 收益率 — only computed
|
||||||
|
for funds where you've filled in shares + cost basis.
|
||||||
|
- Per-fund row with name, code, latest NAV, day rate %, day ¥ P&L.
|
||||||
|
- Click any row to inline-edit shares + cost basis (or clear).
|
||||||
|
|
||||||
|
### 黄金 (Gold)
|
||||||
|
- Realtime SHFE 沪金 quote (RMB/g) as the primary chart.
|
||||||
|
- Daily K-line with 1月 / 3月 / 1年 / 全部 range tabs.
|
||||||
|
- Reference cards for 伦敦金 (XAU/USD) and 纽约金 (COMEX GC) with
|
||||||
|
realtime price + day change.
|
||||||
|
- Historical chart back to 2008.
|
||||||
|
|
||||||
|
### 添加 (Add)
|
||||||
|
- Search 26,000+ public funds by code, name, or pinyin (东方财富 suggest API).
|
||||||
|
- Category-coded chips (ETF / 指数 / 股票 / 混合 / 债券 / QDII / etc).
|
||||||
|
- Already-added state with checkmark.
|
||||||
|
|
||||||
|
## Data sources (no API keys, no Python, no servers)
|
||||||
|
|
||||||
|
| Endpoint | Provider | Format |
|
||||||
|
|---|---|---|
|
||||||
|
| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 东方财富 | JSON |
|
||||||
|
| `fundgz.1234567.com.cn/js/{code}.js` | 天天基金 | JSONP |
|
||||||
|
| `api.fund.eastmoney.com/f10/lsjz` | 东方财富 | JSON |
|
||||||
|
| `hq.sinajs.cn/list={symbol}` | 新浪财经 | CSV (GB18030) |
|
||||||
|
| `stock2.finance.sina.com.cn/.../IndexService.getInnerFuturesDailyKLine` | 新浪财经 | JSON array |
|
||||||
|
|
||||||
|
All endpoints are 10+ years old, used by every Chinese fintech app. No auth.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <this repo>
|
||||||
|
cd mio-plugin-fund
|
||||||
|
./build.sh install
|
||||||
|
# Restart Mio Island (Cmd+Q + reopen)
|
||||||
|
```
|
||||||
|
|
||||||
|
### From marketplace
|
||||||
|
|
||||||
|
(After release: install via miomio.chat plugin store.)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # produce build/fund.bundle + build/fund.zip
|
||||||
|
./build.sh install # build + copy to ~/.config/codeisland/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/
|
||||||
|
├── MioPlugin.swift # protocol (verbatim from host)
|
||||||
|
├── FundPlugin.swift # principal class
|
||||||
|
├── data/
|
||||||
|
│ ├── Models.swift # WatchlistFund, FundEstimate, GoldQuote, …
|
||||||
|
│ ├── FundClient.swift # search + estimate + history
|
||||||
|
│ ├── GoldClient.swift # 4 realtime gold sources
|
||||||
|
│ ├── GoldKlineClient.swift # daily K-line history
|
||||||
|
│ └── SinaQuoteParser.swift # GB18030 → GoldQuote parser
|
||||||
|
├── storage/
|
||||||
|
│ └── Watchlist.swift # persisted JSON watchlist + positions
|
||||||
|
├── engine/
|
||||||
|
│ ├── RefreshScheduler.swift # trade-hour-aware refresh cadence
|
||||||
|
│ └── FundStore.swift # @MainActor state surface
|
||||||
|
└── ui/
|
||||||
|
├── Theme.swift # design tokens (lime / gold / red-up / green-down)
|
||||||
|
├── ExpandedView.swift # 380×540 panel, 3-tab strip + footer
|
||||||
|
├── HeaderSlotView.swift # 20×20 notch icon
|
||||||
|
├── HoldingsView.swift # 持仓 tab
|
||||||
|
├── GoldView.swift # 黄金 tab
|
||||||
|
├── AddView.swift # 添加 tab
|
||||||
|
├── PositionEditor.swift # inline shares + cost form
|
||||||
|
└── SparkLine.swift # SVG-equivalent line chart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh cadence
|
||||||
|
|
||||||
|
| Tier | When | Interval |
|
||||||
|
|---|---|---|
|
||||||
|
| Funds intraday | 09:30-11:30, 13:00-15:00 (CST, Mon-Fri) | 60s |
|
||||||
|
| Funds idle | Otherwise | 30 min |
|
||||||
|
| Gold | 24/7 | 30s |
|
||||||
|
| Gold K-line history | On launch + manual refresh | — |
|
||||||
|
|
||||||
|
## Pricing (planned)
|
||||||
|
|
||||||
|
| Tier | Content |
|
||||||
|
|---|---|
|
||||||
|
| Free | 3 funds, daily NAV only, no gold, no AI |
|
||||||
|
| Pro (¥49 once / ¥99 yearly) | Unlimited watchlist, intraday estimates, gold panel, AI 解盘 |
|
||||||
|
|
||||||
|
Not enforced yet — v0.2 is the UI shell. Paid gating goes in v0.3.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
52
Sources/FundPlugin.swift
Normal file
52
Sources/FundPlugin.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
//
|
||||||
|
// FundPlugin.swift
|
||||||
|
// Mio Island plugin: 盯基金
|
||||||
|
//
|
||||||
|
// Principal class. Module = FundPlugin, Class = FundPlugin →
|
||||||
|
// NSPrincipalClass = "FundPlugin.FundPlugin".
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class FundPlugin: NSObject, MioPlugin {
|
||||||
|
var id: String { "fund" }
|
||||||
|
var name: String { "看盘侠" }
|
||||||
|
var icon: String { "chart.line.uptrend.xyaxis" }
|
||||||
|
var version: String { "0.2.0" }
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
FundDebugLog.write("plugin activate")
|
||||||
|
// Kick off the refresh loops as soon as the plugin enables.
|
||||||
|
// The store is a singleton so the loops survive panel show/hide.
|
||||||
|
Task { @MainActor in
|
||||||
|
FundStore.shared.start()
|
||||||
|
await FundStore.shared.refreshNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivate() {
|
||||||
|
FundDebugLog.write("plugin deactivate")
|
||||||
|
Task { @MainActor in
|
||||||
|
FundStore.shared.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeView() -> NSView {
|
||||||
|
let view = NSHostingView(rootView: ExpandedView())
|
||||||
|
view.autoresizingMask = [.width, .height]
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
|
||||||
|
switch slot {
|
||||||
|
case "header":
|
||||||
|
let v = NSHostingView(rootView: HeaderSlotView())
|
||||||
|
v.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
||||||
|
v.setFrameSize(NSSize(width: 20, height: 20))
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/MioPlugin.swift
Normal file
18
Sources/MioPlugin.swift
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// MioPlugin.swift
|
||||||
|
// Mio Island Plugin SDK (verbatim copy from host).
|
||||||
|
// Runtime conformance is by ObjC selector, not module identity.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
@objc protocol MioPlugin: AnyObject {
|
||||||
|
var id: String { get }
|
||||||
|
var name: String { get }
|
||||||
|
var icon: String { get }
|
||||||
|
var version: String { get }
|
||||||
|
func activate()
|
||||||
|
func deactivate()
|
||||||
|
func makeView() -> NSView
|
||||||
|
@objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView?
|
||||||
|
}
|
||||||
154
Sources/data/EtfClient.swift
Normal file
154
Sources/data/EtfClient.swift
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
//
|
||||||
|
// EtfClient.swift
|
||||||
|
// 看盘侠 plugin v0.3
|
||||||
|
//
|
||||||
|
// Realtime quotes for exchange-traded funds (场内 ETF). Different
|
||||||
|
// endpoint from open-end mutual funds — ETFs trade on Shanghai/Shenzhen
|
||||||
|
// exchanges with stock-like tick data.
|
||||||
|
//
|
||||||
|
// Endpoint: `https://hq.sinajs.cn/list={prefix}{code}` where prefix is
|
||||||
|
// "sh" (上交所) or "sz" (深交所), encoded GB18030. Same family as the
|
||||||
|
// gold AU0 endpoint we already use, just stock-format payload instead
|
||||||
|
// of futures.
|
||||||
|
//
|
||||||
|
// Field layout (verified 2026-04-26 with sz159206 卫星 ETF):
|
||||||
|
// [0] name (GBK) "卫星ETF"
|
||||||
|
// [1] prevClose (昨收) 1.854
|
||||||
|
// [2] open (今开) 1.867
|
||||||
|
// [3] current (现价) 1.806
|
||||||
|
// [4] high (最高) 1.857
|
||||||
|
// [5] low (最低) 1.801
|
||||||
|
// [6] bid1 (买一价) 1.806
|
||||||
|
// [7] ask1 (卖一价) 1.807
|
||||||
|
// [8] volume (成交量·股) 902910170
|
||||||
|
// [9] turnover (成交额·元) 1643683554.028
|
||||||
|
// [10..29] 五档买卖盘
|
||||||
|
// [30] date "2026-04-24"
|
||||||
|
// [31] time "15:00:00"
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Minimal subset of the Sina stock payload we surface in the UI.
|
||||||
|
struct ETFQuote: Equatable {
|
||||||
|
let code: String // raw 6-digit code (no prefix)
|
||||||
|
let name: String
|
||||||
|
let prevClose: Double
|
||||||
|
let open: Double
|
||||||
|
let last: Double
|
||||||
|
let high: Double
|
||||||
|
let low: Double
|
||||||
|
let updatedAt: Date?
|
||||||
|
|
||||||
|
var change: Double { last - prevClose }
|
||||||
|
var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
actor ETFClient {
|
||||||
|
static let shared = ETFClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 6
|
||||||
|
cfg.timeoutIntervalForResource = 12
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff this looks like a tradeable ETF code (Shanghai or Shenzhen).
|
||||||
|
/// Routing rules taken from CSRC code blocks (3-digit prefix of the
|
||||||
|
/// 6-digit ticker):
|
||||||
|
/// - 51x, 52x, 56x, 58x → Shanghai ETFs (sh prefix)
|
||||||
|
/// - 15x, 16x, 18x → Shenzhen ETFs (sz prefix)
|
||||||
|
/// Anything else falls back to open-end mutual fund routing.
|
||||||
|
/// 6-digit ticker / 1000 = leading 3 digits — earlier code mistakenly
|
||||||
|
/// used /10000 which always returned the leading 2 digits and made
|
||||||
|
/// every ETF look like an OTC fund.
|
||||||
|
static func isETFCode(_ code: String) -> Bool {
|
||||||
|
guard code.count == 6, let n = Int(code) else { return false }
|
||||||
|
let prefix = n / 1000
|
||||||
|
switch prefix {
|
||||||
|
case 510...599, 150...199:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns "sh" or "sz" for the given ETF code, or nil if it isn't an ETF.
|
||||||
|
static func sinaPrefix(for code: String) -> String? {
|
||||||
|
guard code.count == 6, let n = Int(code) else { return nil }
|
||||||
|
let prefix = n / 1000
|
||||||
|
if (510...599).contains(prefix) { return "sh" }
|
||||||
|
if (150...199).contains(prefix) { return "sz" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quote(for code: String) async throws -> ETFQuote {
|
||||||
|
guard let prefix = Self.sinaPrefix(for: code) else {
|
||||||
|
throw FundClientError.malformed("\(code) is not an ETF code")
|
||||||
|
}
|
||||||
|
let url = URL(string: "https://hq.sinajs.cn/list=\(prefix)\(code)")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
|
|
||||||
|
// Sina serves GB18030 so the Chinese name field decodes cleanly.
|
||||||
|
let gbEnc = String.Encoding(rawValue:
|
||||||
|
CFStringConvertEncodingToNSStringEncoding(
|
||||||
|
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
|
||||||
|
guard let s = String(data: data, encoding: gbEnc) ?? String(data: data, encoding: .utf8),
|
||||||
|
let lQ = s.firstIndex(of: "\""),
|
||||||
|
let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else {
|
||||||
|
throw FundClientError.malformed("etf payload parse failed for \(code)")
|
||||||
|
}
|
||||||
|
let body = String(s[s.index(after: lQ)..<rQ])
|
||||||
|
let fields = body.components(separatedBy: ",")
|
||||||
|
guard fields.count >= 30 else {
|
||||||
|
throw FundClientError.malformed("etf field count \(fields.count) for \(code)")
|
||||||
|
}
|
||||||
|
guard let prevClose = Double(fields[1]),
|
||||||
|
let open = Double(fields[2]),
|
||||||
|
let last = Double(fields[3]),
|
||||||
|
let high = Double(fields[4]),
|
||||||
|
let low = Double(fields[5]) else {
|
||||||
|
throw FundClientError.malformed("etf number parse failed for \(code)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateStr = fields.count > 30 ? fields[30] : ""
|
||||||
|
let timeStr = fields.count > 31 ? fields[31] : ""
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)")
|
||||||
|
|
||||||
|
return ETFQuote(
|
||||||
|
code: code, name: fields[0],
|
||||||
|
prevClose: prevClose, open: open, last: last,
|
||||||
|
high: high, low: low, updatedAt: updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch many ETF quotes in parallel; logs per-code failures so
|
||||||
|
/// silent breakage in production is debuggable.
|
||||||
|
func quotes(for codes: [String]) async -> [ETFQuote] {
|
||||||
|
await withTaskGroup(of: ETFQuote?.self) { group in
|
||||||
|
for c in codes {
|
||||||
|
group.addTask {
|
||||||
|
do { return try await self.quote(for: c) }
|
||||||
|
catch {
|
||||||
|
FundDebugLog.write("ETF quote \(c) failed: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out: [ETFQuote] = []
|
||||||
|
for await q in group { if let q { out.append(q) } }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
Sources/data/FundClient.swift
Normal file
244
Sources/data/FundClient.swift
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
//
|
||||||
|
// FundClient.swift
|
||||||
|
// 盯基金 plugin
|
||||||
|
//
|
||||||
|
// HTTP client for fund data. Three endpoints, all 10+ years old,
|
||||||
|
// used by every Chinese fintech app. No Python dependency.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// 1. Search: https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx
|
||||||
|
// (Eastmoney — JSON)
|
||||||
|
// 2. Estimate: http://fundgz.1234567.com.cn/js/{code}.js
|
||||||
|
// (TianTian — JSONP `jsonpgz({...})`)
|
||||||
|
// 3. History: https://api.fund.eastmoney.com/f10/lsjz?fundCode={code}&pageSize=N
|
||||||
|
// (Eastmoney F10 — JSON, requires Referer header)
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FundClientError: Error, LocalizedError {
|
||||||
|
case badStatus(Int)
|
||||||
|
case malformed(String)
|
||||||
|
case decode(Error)
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badStatus(let code): return "HTTP \(code)"
|
||||||
|
case .malformed(let msg): return "Malformed response: \(msg)"
|
||||||
|
case .decode(let err): return "Decode failed: \(err.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor FundClient {
|
||||||
|
static let shared = FundClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
// 8s wasn't enough on cold start — TLS handshake to
|
||||||
|
// fundgz.1234567.com.cn often took 7-10s after app launch and
|
||||||
|
// tripped the request timeout. 15s gives the first request
|
||||||
|
// headroom; subsequent reuses are sub-second.
|
||||||
|
cfg.timeoutIntervalForRequest = 15
|
||||||
|
cfg.timeoutIntervalForResource = 25
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 1. Search
|
||||||
|
|
||||||
|
/// Fuzzy search for funds by name, code, or pinyin abbreviation.
|
||||||
|
/// Returns up to 10 hits.
|
||||||
|
func search(_ query: String) async throws -> [FundSearchHit] {
|
||||||
|
let trimmed = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty,
|
||||||
|
let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let url = URL(string: "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=\(encoded)")!
|
||||||
|
let (data, resp) = try await session.data(from: url)
|
||||||
|
try Self.assertOK(resp)
|
||||||
|
|
||||||
|
struct Envelope: Decodable {
|
||||||
|
let ErrCode: Int
|
||||||
|
let Datas: [Hit]
|
||||||
|
}
|
||||||
|
struct Hit: Decodable {
|
||||||
|
let CODE: String
|
||||||
|
let NAME: String
|
||||||
|
let CATEGORYDESC: String?
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let env = try JSONDecoder().decode(Envelope.self, from: data)
|
||||||
|
return env.Datas.prefix(10).map {
|
||||||
|
FundSearchHit(code: $0.CODE, name: $0.NAME, category: $0.CATEGORYDESC)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw FundClientError.decode(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 2. Intraday estimate
|
||||||
|
|
||||||
|
/// Fetch the live intraday estimate for one fund.
|
||||||
|
///
|
||||||
|
/// Endpoint returns JSONP wrapped in `jsonpgz(...)`. Out-of-session
|
||||||
|
/// (weekends, after-hours), `gsz` / `gszzl` / `gztime` may be empty
|
||||||
|
/// strings — we return nil for those fields rather than fake them.
|
||||||
|
func estimate(for code: String) async throws -> FundEstimate {
|
||||||
|
guard !code.isEmpty else { throw FundClientError.malformed("empty code") }
|
||||||
|
// Cache-buster — Tiantian aggressively caches; without `_=ts`
|
||||||
|
// we can be served a 5-minute-old snapshot.
|
||||||
|
let ts = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
// HTTPS — Tiantian also serves over TLS, and Mio's host ATS
|
||||||
|
// policy may block plain HTTP, which would silently kill OTC
|
||||||
|
// fund estimates without surfacing in the UI.
|
||||||
|
let url = URL(string: "https://fundgz.1234567.com.cn/js/\(code).js?rt=\(ts)")!
|
||||||
|
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("https://fund.eastmoney.com/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, resp) = try await session.data(for: req)
|
||||||
|
try Self.assertOK(resp)
|
||||||
|
|
||||||
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
|
throw FundClientError.malformed("non-utf8")
|
||||||
|
}
|
||||||
|
// Strip JSONP wrapper: jsonpgz({...});
|
||||||
|
guard let lParen = raw.firstIndex(of: "("),
|
||||||
|
let rParen = raw.lastIndex(of: ")") else {
|
||||||
|
// Tiantian returns `jsonpgz();` (empty) for codes that don't
|
||||||
|
// exist or aren't yet tradeable — surface that distinctly.
|
||||||
|
if raw.contains("jsonpgz();") {
|
||||||
|
throw FundClientError.malformed("fund not found: \(code)")
|
||||||
|
}
|
||||||
|
throw FundClientError.malformed("missing JSONP parens")
|
||||||
|
}
|
||||||
|
let inner = raw[raw.index(after: lParen)..<rParen]
|
||||||
|
guard let innerData = String(inner).data(using: .utf8) else {
|
||||||
|
throw FundClientError.malformed("inner non-utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Raw: Decodable {
|
||||||
|
let fundcode: String
|
||||||
|
let name: String
|
||||||
|
let jzrq: String // "2026-04-23"
|
||||||
|
let dwjz: String
|
||||||
|
let gsz: String?
|
||||||
|
let gszzl: String?
|
||||||
|
let gztime: String? // "2026-04-24 15:00"
|
||||||
|
}
|
||||||
|
let r: Raw
|
||||||
|
do { r = try JSONDecoder().decode(Raw.self, from: innerData) }
|
||||||
|
catch { throw FundClientError.decode(error) }
|
||||||
|
|
||||||
|
let dateFmt = DateFormatter.fundDate
|
||||||
|
let timeFmt = DateFormatter.fundDateTime
|
||||||
|
guard let pubDate = dateFmt.date(from: r.jzrq),
|
||||||
|
let pubNav = Double(r.dwjz) else {
|
||||||
|
throw FundClientError.malformed("unparseable published nav")
|
||||||
|
}
|
||||||
|
let estNav = r.gsz.flatMap { Double($0) }
|
||||||
|
let estRate = r.gszzl.flatMap { Double($0) }
|
||||||
|
let estAt = r.gztime.flatMap { timeFmt.date(from: $0) }
|
||||||
|
|
||||||
|
return FundEstimate(
|
||||||
|
code: r.fundcode, name: r.name,
|
||||||
|
publishedDate: pubDate, publishedNav: pubNav,
|
||||||
|
estimatedNav: estNav, estimatedRate: estRate,
|
||||||
|
estimatedAt: estAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: fetch many estimates in parallel. Failures for
|
||||||
|
/// individual codes are swallowed (returned as missing entries) so
|
||||||
|
/// one dead code doesn't sink the whole refresh.
|
||||||
|
func estimates(for codes: [String]) async -> [FundEstimate] {
|
||||||
|
await withTaskGroup(of: FundEstimate?.self) { group in
|
||||||
|
for c in codes {
|
||||||
|
group.addTask {
|
||||||
|
do { return try await self.estimate(for: c) }
|
||||||
|
catch {
|
||||||
|
FundDebugLog.write("OTC estimate \(c) failed: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out: [FundEstimate] = []
|
||||||
|
out.reserveCapacity(codes.count)
|
||||||
|
for await result in group {
|
||||||
|
if let r = result { out.append(r) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 3. Historical NAV
|
||||||
|
|
||||||
|
/// Fetch the most recent N NAV points (default 30).
|
||||||
|
/// Used to render the sparkline in the expanded panel.
|
||||||
|
func history(for code: String, limit: Int = 30) async throws -> [FundNavPoint] {
|
||||||
|
var comps = URLComponents(string: "https://api.fund.eastmoney.com/f10/lsjz")!
|
||||||
|
comps.queryItems = [
|
||||||
|
.init(name: "fundCode", value: code),
|
||||||
|
.init(name: "pageIndex", value: "1"),
|
||||||
|
.init(name: "pageSize", value: String(limit)),
|
||||||
|
]
|
||||||
|
var req = URLRequest(url: comps.url!)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("http://fundf10.eastmoney.com/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, resp) = try await session.data(for: req)
|
||||||
|
try Self.assertOK(resp)
|
||||||
|
|
||||||
|
struct Envelope: Decodable {
|
||||||
|
struct Inner: Decodable { let LSJZList: [Row] }
|
||||||
|
struct Row: Decodable {
|
||||||
|
let FSRQ: String // "2026-04-24"
|
||||||
|
let DWJZ: String // "1.7678"
|
||||||
|
let JZZZL: String // "0.59" (% — sometimes "" for the very first day)
|
||||||
|
}
|
||||||
|
let Data: Inner
|
||||||
|
}
|
||||||
|
|
||||||
|
let env: Envelope
|
||||||
|
do { env = try JSONDecoder().decode(Envelope.self, from: data) }
|
||||||
|
catch { throw FundClientError.decode(error) }
|
||||||
|
|
||||||
|
let fmt = DateFormatter.fundDate
|
||||||
|
// Reverse so list is oldest → newest (sparkline draws left-to-right).
|
||||||
|
return env.Data.LSJZList.reversed().compactMap { row in
|
||||||
|
guard let d = fmt.date(from: row.FSRQ),
|
||||||
|
let nav = Double(row.DWJZ) else { return nil }
|
||||||
|
let rate = Double(row.JZZZL) ?? 0.0
|
||||||
|
return FundNavPoint(date: d, unitNav: nav, dailyRate: rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: helpers
|
||||||
|
|
||||||
|
private static func assertOK(_ resp: URLResponse) throws {
|
||||||
|
guard let http = resp as? HTTPURLResponse else { return }
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
throw FundClientError.badStatus(http.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let fundDate: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
static let fundDateTime: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
59
Sources/data/GoldClient.swift
Normal file
59
Sources/data/GoldClient.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// GoldClient.swift
|
||||||
|
// 盯基金 plugin
|
||||||
|
//
|
||||||
|
// Realtime gold quotes from Sina Finance. One endpoint, four flavors.
|
||||||
|
//
|
||||||
|
// Sina serves comma-separated field strings, NOT JSON. Encoding is
|
||||||
|
// GB18030 for Chinese name fields, ASCII for numbers — see
|
||||||
|
// SinaQuoteParser for details.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor GoldClient {
|
||||||
|
static let shared = GoldClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 6
|
||||||
|
cfg.timeoutIntervalForResource = 12
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: One source
|
||||||
|
|
||||||
|
func quote(for source: GoldQuote.Source) async throws -> GoldQuote {
|
||||||
|
let symbol = source.sinaSymbol
|
||||||
|
let url = URL(string: "https://hq.sinajs.cn/list=\(symbol)")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
// Sina enforces a Referer check on hq.sinajs.cn endpoints —
|
||||||
|
// requests without a finance.sina.com.cn referer return empty
|
||||||
|
// payloads. Documented hack but it has held since 2016.
|
||||||
|
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
|
guard let q = SinaQuoteParser.parse(data: data, source: source) else {
|
||||||
|
throw FundClientError.malformed("sina parse failed for \(symbol)")
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Bulk
|
||||||
|
|
||||||
|
/// Fetch all four sources in parallel. Failures degrade silently
|
||||||
|
/// so off-hours empty payloads don't sink the panel.
|
||||||
|
func quoteAll() async -> [GoldQuote] {
|
||||||
|
await withTaskGroup(of: GoldQuote?.self) { group in
|
||||||
|
for src in [GoldQuote.Source.comex, .london, .shfeFutures] {
|
||||||
|
group.addTask { try? await self.quote(for: src) }
|
||||||
|
}
|
||||||
|
var out: [GoldQuote] = []
|
||||||
|
for await q in group { if let q { out.append(q) } }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Sources/data/GoldKlineClient.swift
Normal file
73
Sources/data/GoldKlineClient.swift
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// GoldKlineClient.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Daily K-line history for the gold chart. Uses Sina's classic
|
||||||
|
// inner-futures endpoint:
|
||||||
|
//
|
||||||
|
// https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=AU0
|
||||||
|
//
|
||||||
|
// Returns a JSON array of arrays:
|
||||||
|
// [[date, open, high, low, close, volume], ...]
|
||||||
|
//
|
||||||
|
// Stable since 2008, used by every futures-tracker website in China.
|
||||||
|
// Falls back gracefully if the endpoint format ever changes — we
|
||||||
|
// parse defensively.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor GoldKlineClient {
|
||||||
|
static let shared = GoldKlineClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 8
|
||||||
|
cfg.timeoutIntervalForResource = 15
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the full daily K-line for the given symbol (default AU0
|
||||||
|
/// = SHFE 沪金主连). Returns oldest → newest.
|
||||||
|
func dailyKLine(symbol: String = "AU0") async throws -> [GoldDailyBar] {
|
||||||
|
let url = URL(string:
|
||||||
|
"https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=\(symbol)"
|
||||||
|
)!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
|
|
||||||
|
// Sina returns: [["2008-01-09","230.950","230.990","221.880","223.300","103364"], ...]
|
||||||
|
// Strings only — we parse to Double.
|
||||||
|
guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String]] else {
|
||||||
|
throw FundClientError.malformed("kline array decode failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd"
|
||||||
|
|
||||||
|
return arr.compactMap { row -> GoldDailyBar? in
|
||||||
|
guard row.count >= 5,
|
||||||
|
let date = fmt.date(from: row[0]),
|
||||||
|
let open = Double(row[1]),
|
||||||
|
let high = Double(row[2]),
|
||||||
|
let low = Double(row[3]),
|
||||||
|
let close = Double(row[4]) else { return nil }
|
||||||
|
let volume = row.count >= 6 ? (Double(row[5]) ?? 0) : 0
|
||||||
|
return GoldDailyBar(date: date, open: open, high: high, low: low, close: close, volume: volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: slice the most recent N days.
|
||||||
|
func recentDailyKLine(days: Int?, symbol: String = "AU0") async throws -> [GoldDailyBar] {
|
||||||
|
let full = try await dailyKLine(symbol: symbol)
|
||||||
|
guard let n = days, n < full.count else { return full }
|
||||||
|
return Array(full.suffix(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
84
Sources/data/GoldMinuteClient.swift
Normal file
84
Sources/data/GoldMinuteClient.swift
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// GoldMinuteClient.swift
|
||||||
|
// 看盘侠 plugin v0.3
|
||||||
|
//
|
||||||
|
// Realtime minute-line for SHFE 沪金 AU0 — replaces the daily K-line
|
||||||
|
// in the chart widget. This matches what Alipay / 同花顺 show in the
|
||||||
|
// "国内金价" panel: an intraday tick line spanning
|
||||||
|
// 20:00 → 02:30 (night session) → 09:00 → 15:30 (day session).
|
||||||
|
//
|
||||||
|
// Endpoint: Sina futures inner getMinLine
|
||||||
|
// https://stock.finance.sina.com.cn/futures/api/jsonp.php
|
||||||
|
// /var-_t=/InnerFuturesNewService.getMinLine?symbol=AU0
|
||||||
|
//
|
||||||
|
// Returns JSONP: `var-_t=([[time, last, avgPrice, vol, oi, prevClose, date], ...]);`
|
||||||
|
// First row carries the prevClose + date; subsequent rows are tick samples.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One minute sample from the AU0 intraday line.
|
||||||
|
struct GoldMinutePoint: Equatable {
|
||||||
|
/// Trading time, formatted "HH:mm" (e.g. "21:00").
|
||||||
|
let time: String
|
||||||
|
/// Last traded price (RMB/g).
|
||||||
|
let price: Double
|
||||||
|
/// Cumulative volume up to this minute.
|
||||||
|
let volume: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
actor GoldMinuteClient {
|
||||||
|
static let shared = GoldMinuteClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 8
|
||||||
|
cfg.timeoutIntervalForResource = 15
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch today's minute line for the given futures symbol (default
|
||||||
|
/// AU0 = SHFE 沪金主连). Returns oldest → newest.
|
||||||
|
func minuteLine(symbol: String = "AU0") async throws -> [GoldMinutePoint] {
|
||||||
|
let url = URL(string:
|
||||||
|
"https://stock.finance.sina.com.cn/futures/api/jsonp.php/var-_t=/InnerFuturesNewService.getMinLine?symbol=\(symbol)"
|
||||||
|
)!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
|
throw FundClientError.malformed("minline non-utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip JSONP wrapper: `var-_t=(<json>);` (Sina injects a redirect
|
||||||
|
// script first; ignore that and keep the part starting at `var-_t=(`).
|
||||||
|
guard let lParen = raw.range(of: "var-_t=(")?.upperBound,
|
||||||
|
let rParen = raw[lParen...].lastIndex(of: ")") else {
|
||||||
|
throw FundClientError.malformed("minline jsonp wrapper missing")
|
||||||
|
}
|
||||||
|
let inner = String(raw[lParen..<rParen])
|
||||||
|
|
||||||
|
// Empty payload (off-hours / no session yet today) is `null`.
|
||||||
|
if inner.trimmingCharacters(in: .whitespaces) == "null" { return [] }
|
||||||
|
|
||||||
|
guard let innerData = inner.data(using: .utf8),
|
||||||
|
let arr = try? JSONSerialization.jsonObject(with: innerData) as? [[Any]] else {
|
||||||
|
throw FundClientError.malformed("minline array decode")
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr.compactMap { row -> GoldMinutePoint? in
|
||||||
|
// Layout: [time, last, avgPrice, volume, openInterest, prevClose?, date?]
|
||||||
|
guard row.count >= 4 else { return nil }
|
||||||
|
// Sina returns numbers as strings inside the array — coerce.
|
||||||
|
let timeStr = row[0] as? String ?? ""
|
||||||
|
let priceStr = row[1] as? String ?? ""
|
||||||
|
let volStr = row[3] as? String ?? "0"
|
||||||
|
guard let p = Double(priceStr), p > 0 else { return nil }
|
||||||
|
return GoldMinutePoint(time: timeStr, price: p, volume: Double(volStr) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
Sources/data/Models.swift
Normal file
198
Sources/data/Models.swift
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
//
|
||||||
|
// Models.swift
|
||||||
|
// 盯基金 plugin v0.2 (UI redesign per design spec)
|
||||||
|
//
|
||||||
|
// Models grew from "watchlist with name+code" to "actual holding with
|
||||||
|
// cost basis and shares" so the design's hero card (总市值 / 今日盈亏 /
|
||||||
|
// 累计盈亏) can compute real numbers. Cost/shares are optional — if
|
||||||
|
// empty, the row falls back to "watchlist only" display (rate %
|
||||||
|
// without ¥ amounts), so we don't force every user to type cost basis
|
||||||
|
// on day one.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Fund
|
||||||
|
|
||||||
|
/// One fund the user has subscribed to (lives in the watchlist).
|
||||||
|
/// `shares` and `costNav` are optional: when set, the holdings hero
|
||||||
|
/// card shows real ¥ P&L; when nil, only the rate % displays.
|
||||||
|
struct WatchlistFund: Codable, Equatable, Identifiable {
|
||||||
|
let code: String // "005827"
|
||||||
|
let name: String // "易方达蓝筹精选混合"
|
||||||
|
var addedAt: Date
|
||||||
|
var displayOrder: Int
|
||||||
|
|
||||||
|
/// Shares held (份额). Optional — nil means "I just want to watch this fund".
|
||||||
|
var shares: Double?
|
||||||
|
/// Average cost basis per share (持仓成本/单位净值).
|
||||||
|
var costNav: Double?
|
||||||
|
|
||||||
|
var id: String { code }
|
||||||
|
|
||||||
|
/// True iff the user has filled in both shares + cost basis.
|
||||||
|
var hasPosition: Bool {
|
||||||
|
guard let s = shares, s > 0, let c = costNav, c > 0 else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost amount = shares × cost basis (only valid when hasPosition).
|
||||||
|
var costAmount: Double? {
|
||||||
|
guard let s = shares, let c = costNav else { return nil }
|
||||||
|
return s * c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's intraday estimate + last-published NAV for one fund.
|
||||||
|
/// Returned by `FundClient.estimate(for:)` (the
|
||||||
|
/// `fundgz.1234567.com.cn/js/{code}.js` JSONP feed).
|
||||||
|
struct FundEstimate: Equatable {
|
||||||
|
let code: String
|
||||||
|
let name: String
|
||||||
|
let publishedDate: Date // jzrq — date the published NAV applies to
|
||||||
|
let publishedNav: Double // dwjz — last published unit NAV
|
||||||
|
let estimatedNav: Double? // gsz — intraday estimated NAV (nil out of session)
|
||||||
|
let estimatedRate: Double? // gszzl — intraday %, e.g. 0.53 means +0.53%
|
||||||
|
let estimatedAt: Date? // gztime — last time estimate refreshed
|
||||||
|
|
||||||
|
/// "Best available" NAV — use intraday estimate if we have it, else
|
||||||
|
/// fall back to last published. The hero card uses this to compute
|
||||||
|
/// market value during trade hours.
|
||||||
|
var bestNav: Double { estimatedNav ?? publishedNav }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row from the historical NAV table (used for sparkline).
|
||||||
|
struct FundNavPoint: Equatable {
|
||||||
|
let date: Date
|
||||||
|
let unitNav: Double
|
||||||
|
let dailyRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One match from the eastmoney fund-suggest endpoint.
|
||||||
|
/// Includes "category" (fund type) and a heuristic "tag" for the
|
||||||
|
/// design's coloured chip.
|
||||||
|
struct FundSearchHit: Equatable, Identifiable {
|
||||||
|
let code: String
|
||||||
|
let name: String
|
||||||
|
let category: String? // e.g. "混合型-灵活" / "债券型-混合二级"
|
||||||
|
var id: String { code }
|
||||||
|
|
||||||
|
/// Coarse-grained tag derived from category for the design's coloured
|
||||||
|
/// chip in the search list. Not authoritative — just hint colour.
|
||||||
|
var displayTag: String {
|
||||||
|
guard let c = category?.lowercased() else { return "基金" }
|
||||||
|
if c.contains("指数") { return "指数" }
|
||||||
|
if c.contains("股票") { return "股票" }
|
||||||
|
if c.contains("混合") { return "混合" }
|
||||||
|
if c.contains("债券") { return "债券" }
|
||||||
|
if c.contains("货币") { return "货币" }
|
||||||
|
if c.contains("qdii") { return "QDII" }
|
||||||
|
if c.contains("etf") { return "ETF" }
|
||||||
|
return "基金"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gold
|
||||||
|
|
||||||
|
/// Gold realtime quote. Same struct services SHFE 沪金 (RMB/g, used as
|
||||||
|
/// the chart price) and the foreign reference quotes (COMEX/London,
|
||||||
|
/// USD/oz with RMB/g conversion alongside).
|
||||||
|
struct GoldQuote: Equatable, Identifiable {
|
||||||
|
enum Source: String, Codable, Hashable, CaseIterable {
|
||||||
|
case shanghaiSpot = "shanghai_spot"
|
||||||
|
case shfeFutures = "shfe_futures"
|
||||||
|
case comex = "comex"
|
||||||
|
case london = "london"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .shanghaiSpot: return "上海金"
|
||||||
|
case .shfeFutures: return "沪金"
|
||||||
|
case .comex: return "纽约金"
|
||||||
|
case .london: return "伦敦金"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sina symbol for `https://hq.sinajs.cn/list={symbol}`
|
||||||
|
var sinaSymbol: String {
|
||||||
|
switch self {
|
||||||
|
case .shanghaiSpot: return "AU0"
|
||||||
|
case .shfeFutures: return "AU0"
|
||||||
|
case .comex: return "hf_GC"
|
||||||
|
case .london: return "hf_XAU"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: Source
|
||||||
|
let last: Double
|
||||||
|
/// Today's opening price. Distinct from `prevClose` — UI's "今开"
|
||||||
|
/// row needs this, not yesterday's close.
|
||||||
|
let open: Double?
|
||||||
|
let prevClose: Double?
|
||||||
|
let high: Double?
|
||||||
|
let low: Double?
|
||||||
|
let updatedAt: Date?
|
||||||
|
/// Set when source ∈ {comex, london} so UI can show ¥/g alongside USD/oz.
|
||||||
|
let rmbPerGram: Double?
|
||||||
|
var id: String { source.rawValue }
|
||||||
|
|
||||||
|
var change: Double? {
|
||||||
|
guard let p = prevClose else { return nil }
|
||||||
|
return last - p
|
||||||
|
}
|
||||||
|
var changeRate: Double? {
|
||||||
|
guard let p = prevClose, p != 0 else { return nil }
|
||||||
|
return (last - p) / p * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One day's OHLCV for the gold K-line chart.
|
||||||
|
struct GoldDailyBar: Equatable {
|
||||||
|
let date: Date
|
||||||
|
let open: Double
|
||||||
|
let high: Double
|
||||||
|
let low: Double
|
||||||
|
let close: Double
|
||||||
|
let volume: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User's gold holding position. Both fields optional so an empty
|
||||||
|
/// holding is valid (you might just want to watch the price without
|
||||||
|
/// committing). Persisted as JSON next to the watchlist.
|
||||||
|
struct GoldPosition: Codable, Equatable {
|
||||||
|
/// Total grams held (克). Nil = no holding entered yet.
|
||||||
|
var grams: Double?
|
||||||
|
/// Average buy-in cost per gram (元/克).
|
||||||
|
var costPerGram: Double?
|
||||||
|
|
||||||
|
var hasPosition: Bool {
|
||||||
|
guard let g = grams, g > 0, let c = costPerGram, c > 0 else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var costAmount: Double? {
|
||||||
|
guard let g = grams, let c = costPerGram else { return nil }
|
||||||
|
return g * c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time range for the gold chart tab strip (1月/3月/1年/全部).
|
||||||
|
enum GoldRange: String, CaseIterable, Identifiable {
|
||||||
|
case oneMonth = "1月"
|
||||||
|
case threeMonth = "3月"
|
||||||
|
case oneYear = "1年"
|
||||||
|
case all = "全部"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
/// Number of days to slice from the daily history. `nil` = all.
|
||||||
|
var days: Int? {
|
||||||
|
switch self {
|
||||||
|
case .oneMonth: return 22 // ~22 trading days/month
|
||||||
|
case .threeMonth: return 66
|
||||||
|
case .oneYear: return 250
|
||||||
|
case .all: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
Sources/data/SinaQuoteParser.swift
Normal file
184
Sources/data/SinaQuoteParser.swift
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
//
|
||||||
|
// SinaQuoteParser.swift
|
||||||
|
// 盯基金 plugin
|
||||||
|
//
|
||||||
|
// Parses Sina Finance's classic JS-string quote format. Every realtime
|
||||||
|
// endpoint of the form `https://hq.sinajs.cn/list=XXX` returns the same
|
||||||
|
// shape:
|
||||||
|
//
|
||||||
|
// var hq_str_XXX="field1,field2,field3,...";
|
||||||
|
//
|
||||||
|
// Field positions are domain-dependent. We only decode the four flavors
|
||||||
|
// this plugin uses (hf_* foreign futures and AU* domestic futures).
|
||||||
|
//
|
||||||
|
// GBK is the encoding for the Chinese name fields. We don't care about
|
||||||
|
// those — we only need the numeric fields, which are pure ASCII and
|
||||||
|
// parse fine even when the rest of the string is garbled bytes.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SinaQuoteParser {
|
||||||
|
|
||||||
|
/// Parse a Sina quote response into a single GoldQuote.
|
||||||
|
/// Returns nil if the data is empty (Sina returns a one-byte
|
||||||
|
/// `""` payload for unknown symbols).
|
||||||
|
static func parse(rawString: String, source: GoldQuote.Source) -> GoldQuote? {
|
||||||
|
// Find the first `="` ... `";` payload.
|
||||||
|
guard let eq = rawString.firstIndex(of: "="),
|
||||||
|
let firstQuote = rawString[eq...].firstIndex(of: "\"") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let afterQuote = rawString.index(after: firstQuote)
|
||||||
|
guard let lastQuote = rawString[afterQuote...].lastIndex(of: "\"") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let body = String(rawString[afterQuote..<lastQuote])
|
||||||
|
guard !body.isEmpty else { return nil }
|
||||||
|
let fields = body.components(separatedBy: ",")
|
||||||
|
|
||||||
|
switch source {
|
||||||
|
case .comex, .london:
|
||||||
|
return parseForeignFutures(fields: fields, source: source)
|
||||||
|
case .shfeFutures, .shanghaiSpot:
|
||||||
|
return parseDomesticFutures(fields: fields, source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Sina quote response from raw `Data` (handles encoding).
|
||||||
|
/// We force decode with UTF-8 first; if that fails (Chinese name
|
||||||
|
/// fields use GBK), fall back to GBK. Numeric fields parse the
|
||||||
|
/// same either way.
|
||||||
|
static func parse(data: Data, source: GoldQuote.Source) -> GoldQuote? {
|
||||||
|
if let s = String(data: data, encoding: .utf8) {
|
||||||
|
return parse(rawString: s, source: source)
|
||||||
|
}
|
||||||
|
// Chinese encodings — Sina uses GB18030 (superset of GBK).
|
||||||
|
let cfStr = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
|
||||||
|
let nsEnc = String.Encoding(rawValue: cfStr)
|
||||||
|
if let s = String(data: data, encoding: nsEnc) {
|
||||||
|
return parse(rawString: s, source: source)
|
||||||
|
}
|
||||||
|
// Last resort: ASCII with lossy conversion. Numeric fields survive.
|
||||||
|
let lossy = String(data: data, encoding: .ascii) ?? ""
|
||||||
|
return parse(rawString: lossy, source: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Foreign futures (hf_*)
|
||||||
|
//
|
||||||
|
// Verified field layout (from `https://hq.sinajs.cn/list=hf_GC` on
|
||||||
|
// 2026-04-25 nightly run — see test-final.py output):
|
||||||
|
//
|
||||||
|
// [0] last (latest) 4728.253
|
||||||
|
// [1] (empty / bid sometimes)
|
||||||
|
// [2] open 4725.100
|
||||||
|
// [3] high 4725.300 ← bug-prone: actually high
|
||||||
|
// [4] day high 4757.100
|
||||||
|
// [5] day low 4672.200
|
||||||
|
// [6] update time "04:59:58"
|
||||||
|
// [7] prev settle 4724.000
|
||||||
|
// [8] prev close 4715.600
|
||||||
|
// [9-11] zeros / volume / position
|
||||||
|
// [12] date "2026-04-25"
|
||||||
|
// [13] name (GBK garbled here)
|
||||||
|
//
|
||||||
|
// We only need: last, day high, day low, prev close, update time, date.
|
||||||
|
private static func parseForeignFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? {
|
||||||
|
guard fields.count >= 13 else { return nil }
|
||||||
|
guard let last = Double(fields[0]) else { return nil }
|
||||||
|
let high = Double(fields[4])
|
||||||
|
let low = Double(fields[5])
|
||||||
|
let prevClose = Double(fields[8]).nonZero
|
||||||
|
let timeStr = fields[6]
|
||||||
|
let dateStr = fields[12]
|
||||||
|
let updatedAt = combineForeign(date: dateStr, time: timeStr)
|
||||||
|
|
||||||
|
// hf_GC / hf_XAU return USD/oz. RMB conversion is NOT included
|
||||||
|
// in the raw feed (akshare adds it on the Python side using a
|
||||||
|
// separate FX call). We compute it here only when the realtime
|
||||||
|
// USD/CNY rate is available — for v0.1 we punt and let the UI
|
||||||
|
// call FxClient if needed.
|
||||||
|
let open = Double(fields[2])
|
||||||
|
return GoldQuote(
|
||||||
|
source: source, last: last,
|
||||||
|
open: open, prevClose: prevClose, high: high, low: low,
|
||||||
|
updatedAt: updatedAt, rmbPerGram: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Domestic futures (AU0 etc.)
|
||||||
|
//
|
||||||
|
// Verified field layout from `https://hq.sinajs.cn/list=AU0` (2026-04-25):
|
||||||
|
//
|
||||||
|
// [0] name (GBK)
|
||||||
|
// [1] (volume of last tick) 145957
|
||||||
|
// [2] open 574.86
|
||||||
|
// [3] high 585.84
|
||||||
|
// [4] low 574.40
|
||||||
|
// [5] last 574.94
|
||||||
|
// [6] bid1 581.58
|
||||||
|
// [7] ask1 581.60
|
||||||
|
// [8] last_settle? 581.56
|
||||||
|
// [9] reserved 0.00
|
||||||
|
// [10] prev close 572.76
|
||||||
|
// [13] volume 189097
|
||||||
|
// [14] open interest 308940
|
||||||
|
// [17] date "2024-07-17" ← stale on this snapshot, normal off-hours
|
||||||
|
//
|
||||||
|
// Domestic AU is RMB/g — we set rmbPerGram = last directly.
|
||||||
|
private static func parseDomesticFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? {
|
||||||
|
guard fields.count >= 11 else { return nil }
|
||||||
|
guard let last = Double(fields[5]), last > 0 else { return nil }
|
||||||
|
let high = Double(fields[3])
|
||||||
|
let low = Double(fields[4])
|
||||||
|
let prevClose = Double(fields[10]).nonZero
|
||||||
|
|
||||||
|
// Sina exposes a settled date but not a precise time stamp
|
||||||
|
// for AU realtime. Use the trade date + current wall time
|
||||||
|
// when fields[17] is available; otherwise leave nil.
|
||||||
|
let dateStr = fields.count > 17 ? fields[17] : ""
|
||||||
|
let updatedAt = combineDomestic(date: dateStr)
|
||||||
|
let open = Double(fields[2])
|
||||||
|
|
||||||
|
return GoldQuote(
|
||||||
|
source: source, last: last,
|
||||||
|
open: open, prevClose: prevClose, high: high, low: low,
|
||||||
|
updatedAt: updatedAt, rmbPerGram: last
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date helpers
|
||||||
|
|
||||||
|
/// Foreign futures format "2026-04-25" + "04:59:58" → Date in
|
||||||
|
/// America/New_York-equivalent rendered in CN time zone for display.
|
||||||
|
private static func combineForeign(date: String, time: String) -> Date? {
|
||||||
|
guard !date.isEmpty, !time.isEmpty else { return nil }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
// Sina serves the NY/London exchange wall time as-is; we tag
|
||||||
|
// it with Asia/Shanghai so it lines up with the Mac's local
|
||||||
|
// clock for "时间感". Investors care more about elapsed-time
|
||||||
|
// freshness than which time zone the exchange ran in.
|
||||||
|
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return f.date(from: "\(date) \(time)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func combineDomestic(date: String) -> Date? {
|
||||||
|
guard !date.isEmpty else { return nil }
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Optional where Wrapped == Double {
|
||||||
|
/// Sina sometimes encodes "no prev close" as 0. Drop that to nil
|
||||||
|
/// so downstream code doesn't compute a +∞ percent change.
|
||||||
|
var nonZero: Double? {
|
||||||
|
if case .some(let v) = self, v > 0 { return v }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Sources/data/SpotGoldClient.swift
Normal file
102
Sources/data/SpotGoldClient.swift
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// SpotGoldClient.swift
|
||||||
|
// 看盘侠 plugin v0.3
|
||||||
|
//
|
||||||
|
// 上海金现货 AU9999 (SGE) realtime — same data Alipay shows on the
|
||||||
|
// 「国内金价」 tab. Distinct from the SHFE futures AU0 we already pull:
|
||||||
|
// AU9999 is the SGE physical-spot reference price retail jewellery
|
||||||
|
// banks quote off, whereas AU0 is the futures contract.
|
||||||
|
//
|
||||||
|
// Endpoint: `https://hq.sinajs.cn/list=gds_AU9999`
|
||||||
|
//
|
||||||
|
// Field layout (verified 2026-04-26):
|
||||||
|
// [0] current price 1040.00
|
||||||
|
// [1] ? 0
|
||||||
|
// [2] ? 1039.00 (looks like avg or bid?)
|
||||||
|
// [3] ? 1049.00
|
||||||
|
// [4] high 1044.00
|
||||||
|
// [5] low 1034.00
|
||||||
|
// [6] time "02:30:00"
|
||||||
|
// [7] prevClose 1033.25
|
||||||
|
// [8] open 1039.90
|
||||||
|
// [9] volume 3136
|
||||||
|
// [10] (purity?) 100.00
|
||||||
|
// [11] (purity?) 100.00
|
||||||
|
// [12] date "2026-04-25"
|
||||||
|
// [13] name (GBK) "黄金99"
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Spot gold quote (SGE AU9999 / AU99.99).
|
||||||
|
struct SpotGoldQuote: Equatable {
|
||||||
|
let last: Double // current
|
||||||
|
let prevClose: Double // 昨收
|
||||||
|
let open: Double // 今开
|
||||||
|
let high: Double // 最高
|
||||||
|
let low: Double // 最低
|
||||||
|
let updatedAt: Date?
|
||||||
|
let name: String // "黄金99"
|
||||||
|
|
||||||
|
var change: Double { last - prevClose }
|
||||||
|
var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SpotGoldClient {
|
||||||
|
static let shared = SpotGoldClient()
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.timeoutIntervalForRequest = 6
|
||||||
|
cfg.timeoutIntervalForResource = 12
|
||||||
|
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
self.session = URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the current AU9999 spot quote.
|
||||||
|
func quote() async throws -> SpotGoldQuote {
|
||||||
|
let url = URL(string: "https://hq.sinajs.cn/list=gds_AU9999")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
|
||||||
|
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
|
||||||
|
|
||||||
|
let (data, _) = try await session.data(for: req)
|
||||||
|
// Sina serves GB18030 here too (name field is Chinese).
|
||||||
|
let gbEnc = String.Encoding(rawValue:
|
||||||
|
CFStringConvertEncodingToNSStringEncoding(
|
||||||
|
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
|
||||||
|
let s = String(data: data, encoding: gbEnc)
|
||||||
|
?? String(data: data, encoding: .utf8)
|
||||||
|
?? ""
|
||||||
|
|
||||||
|
guard let lQ = s.firstIndex(of: "\""),
|
||||||
|
let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else {
|
||||||
|
throw FundClientError.malformed("AU9999 payload parse")
|
||||||
|
}
|
||||||
|
let body = String(s[s.index(after: lQ)..<rQ])
|
||||||
|
let fields = body.components(separatedBy: ",")
|
||||||
|
// Off-hours can return an empty payload; guard.
|
||||||
|
guard fields.count >= 13,
|
||||||
|
let last = Double(fields[0]),
|
||||||
|
let high = Double(fields[4]),
|
||||||
|
let low = Double(fields[5]),
|
||||||
|
let prevClose = Double(fields[7]),
|
||||||
|
let open = Double(fields[8]) else {
|
||||||
|
throw FundClientError.malformed("AU9999 field decode (\(fields.count) fields)")
|
||||||
|
}
|
||||||
|
let timeStr = fields[6]
|
||||||
|
let dateStr = fields[12]
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)")
|
||||||
|
let name = fields.count > 13 ? fields[13] : "上海金"
|
||||||
|
|
||||||
|
return SpotGoldQuote(
|
||||||
|
last: last, prevClose: prevClose, open: open,
|
||||||
|
high: high, low: low, updatedAt: updatedAt, name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/engine/FundDebugLog.swift
Normal file
41
Sources/engine/FundDebugLog.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// FundDebugLog.swift
|
||||||
|
// 看盘侠 plugin
|
||||||
|
//
|
||||||
|
// Plugin-bundle NSLog calls don't reliably surface in `log show` from
|
||||||
|
// the host process — they get filtered, deduped, or eaten by the
|
||||||
|
// subsystem. This helper writes timestamped lines to a known file so
|
||||||
|
// we can `tail -f` it during debugging without guessing log filters.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FundDebugLog {
|
||||||
|
static let path = "/tmp/fund-plugin.log"
|
||||||
|
private static let queue = DispatchQueue(label: "fund.debug.log")
|
||||||
|
private static let dateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone.current
|
||||||
|
f.dateFormat = "HH:mm:ss.SSS"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func write(_ message: String) {
|
||||||
|
let stamp = dateFmt.string(from: Date())
|
||||||
|
let line = "[\(stamp)] \(message)\n"
|
||||||
|
queue.async {
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
if FileManager.default.fileExists(atPath: path) {
|
||||||
|
if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
|
||||||
|
try? handle.seekToEnd()
|
||||||
|
try? handle.write(contentsOf: data)
|
||||||
|
try? handle.close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try? data.write(to: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
Sources/engine/FundStore.swift
Normal file
211
Sources/engine/FundStore.swift
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
//
|
||||||
|
// FundStore.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Glue between watchlist + clients + UI. Now also caches gold daily
|
||||||
|
// K-line so the chart can switch ranges (1月/3月/1年/全部) without
|
||||||
|
// re-fetching.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class FundStore: ObservableObject {
|
||||||
|
static let shared = FundStore()
|
||||||
|
|
||||||
|
@Published private(set) var estimates: [String: FundEstimate] = [:]
|
||||||
|
@Published private(set) var goldQuotes: [GoldQuote.Source: GoldQuote] = [:]
|
||||||
|
@Published private(set) var goldDailyBars: [GoldDailyBar] = []
|
||||||
|
/// Spot gold (AU9999) — what Alipay shows on 「国内金价」 tab.
|
||||||
|
@Published private(set) var spotGold: SpotGoldQuote?
|
||||||
|
/// Today's intraday minute line for AU0 — replaces daily K-line in chart.
|
||||||
|
@Published private(set) var goldMinuteLine: [GoldMinutePoint] = []
|
||||||
|
@Published private(set) var lastFundRefresh: Date?
|
||||||
|
@Published private(set) var lastGoldRefresh: Date?
|
||||||
|
@Published private(set) var lastGoldHistRefresh: Date?
|
||||||
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
let watchlist: Watchlist
|
||||||
|
let goldPosition: GoldPositionStore
|
||||||
|
private let scheduler = RefreshScheduler()
|
||||||
|
private var fundTask: Task<Void, Never>?
|
||||||
|
private var goldTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.watchlist = Watchlist()
|
||||||
|
self.goldPosition = GoldPositionStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard fundTask == nil else { return }
|
||||||
|
fundTask = Task { [weak self] in await self?.fundLoop() }
|
||||||
|
goldTask = Task { [weak self] in await self?.goldLoop() }
|
||||||
|
|
||||||
|
// K-line history is rarely refreshed — once on start, then once
|
||||||
|
// every hour (price moved enough to redraw the bottom of the chart).
|
||||||
|
Task { [weak self] in await self?.refreshGoldKLine() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
fundTask?.cancel(); fundTask = nil
|
||||||
|
goldTask?.cancel(); goldTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual triggers
|
||||||
|
|
||||||
|
func refreshNow() async {
|
||||||
|
await refreshFunds()
|
||||||
|
await refreshGold()
|
||||||
|
await refreshGoldKLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fund refresh
|
||||||
|
|
||||||
|
private func fundLoop() async {
|
||||||
|
await refreshFunds()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let interval = scheduler.interval(for: .fundsActive)
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await refreshFunds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshFunds() async {
|
||||||
|
let codes = watchlist.codes
|
||||||
|
guard !codes.isEmpty else { return }
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
// Split codes by venue: ETFs (场内 — Shanghai/Shenzhen exchange)
|
||||||
|
// get the stock-like Sina endpoint, mutual funds (场外) get the
|
||||||
|
// Tiantian estimation feed. Same FundEstimate output shape so the
|
||||||
|
// UI doesn't care which venue a row came from.
|
||||||
|
let etfCodes = codes.filter { ETFClient.isETFCode($0) }
|
||||||
|
let otcCodes = codes.filter { !ETFClient.isETFCode($0) }
|
||||||
|
FundDebugLog.write("refreshFunds start codes=\(codes) etf=\(etfCodes) otc=\(otcCodes)")
|
||||||
|
|
||||||
|
async let otcResults: [FundEstimate] = FundClient.shared.estimates(for: otcCodes)
|
||||||
|
async let etfQuotes: [ETFQuote] = ETFClient.shared.quotes(for: etfCodes)
|
||||||
|
|
||||||
|
var dict = self.estimates
|
||||||
|
let otc = await otcResults
|
||||||
|
let etf = await etfQuotes
|
||||||
|
FundDebugLog.write("refreshFunds got otc=\(otc.count)/\(otcCodes.count) etf=\(etf.count)/\(etfCodes.count)")
|
||||||
|
for r in otc { dict[r.code] = r }
|
||||||
|
for q in etf {
|
||||||
|
// Map ETFQuote → FundEstimate so UI is uniform. published =
|
||||||
|
// prevClose, intraday = current price, rate = day change %.
|
||||||
|
dict[q.code] = FundEstimate(
|
||||||
|
code: q.code,
|
||||||
|
name: q.name,
|
||||||
|
publishedDate: q.updatedAt ?? Date(),
|
||||||
|
publishedNav: q.prevClose,
|
||||||
|
estimatedNav: q.last,
|
||||||
|
estimatedRate: q.changeRate,
|
||||||
|
estimatedAt: q.updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.estimates = dict
|
||||||
|
self.lastFundRefresh = Date()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gold realtime
|
||||||
|
|
||||||
|
private func goldLoop() async {
|
||||||
|
await refreshGold()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let interval = scheduler.interval(for: .gold)
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await refreshGold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshGold() async {
|
||||||
|
// Three things in parallel: legacy realtime (London/NY/SHFE),
|
||||||
|
// AU9999 spot, and the minute line. None block the others —
|
||||||
|
// each falls back silently on failure.
|
||||||
|
async let realtime: [GoldQuote] = GoldClient.shared.quoteAll()
|
||||||
|
async let spot: SpotGoldQuote? = try? SpotGoldClient.shared.quote()
|
||||||
|
async let minute: [GoldMinutePoint] = (try? await GoldMinuteClient.shared.minuteLine()) ?? []
|
||||||
|
|
||||||
|
var dict = self.goldQuotes
|
||||||
|
for q in await realtime { dict[q.source] = q }
|
||||||
|
self.goldQuotes = dict
|
||||||
|
if let s = await spot { self.spotGold = s }
|
||||||
|
let m = await minute
|
||||||
|
if !m.isEmpty { self.goldMinuteLine = m }
|
||||||
|
self.lastGoldRefresh = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gold K-line history
|
||||||
|
|
||||||
|
private func refreshGoldKLine() async {
|
||||||
|
do {
|
||||||
|
let bars = try await GoldKlineClient.shared.dailyKLine(symbol: "AU0")
|
||||||
|
self.goldDailyBars = bars
|
||||||
|
self.lastGoldHistRefresh = Date()
|
||||||
|
} catch {
|
||||||
|
NSLog("[fund-plugin] gold kline fetch failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slice the loaded K-line for a given range. Returns the most
|
||||||
|
/// recent N bars (or all if range == .all).
|
||||||
|
func goldBars(for range: GoldRange) -> [GoldDailyBar] {
|
||||||
|
guard let n = range.days else { return goldDailyBars }
|
||||||
|
return Array(goldDailyBars.suffix(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aggregate computed for hero card
|
||||||
|
|
||||||
|
/// Total market value across all watchlist funds that have a
|
||||||
|
/// position (shares + costNav set). Returns nil when no positions.
|
||||||
|
var totalMarketValue: Double? {
|
||||||
|
var total: Double = 0
|
||||||
|
var any = false
|
||||||
|
for f in watchlist.funds {
|
||||||
|
guard let shares = f.shares, shares > 0 else { continue }
|
||||||
|
let nav = estimates[f.code]?.bestNav
|
||||||
|
guard let n = nav else { continue }
|
||||||
|
total += shares * n
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
return any ? total : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total cost basis (Σ shares × costNav) across positions. Nil if none.
|
||||||
|
var totalCost: Double? {
|
||||||
|
var total: Double = 0
|
||||||
|
var any = false
|
||||||
|
for f in watchlist.funds {
|
||||||
|
guard let cost = f.costAmount else { continue }
|
||||||
|
total += cost
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
return any ? total : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum of today's ¥ P&L across funds with positions.
|
||||||
|
/// Today's ¥ delta on one fund = shares × bestNav × (estimatedRate / (100 + estimatedRate))
|
||||||
|
/// We approximate with: shares × (bestNav - publishedNav).
|
||||||
|
var totalDayPnL: Double? {
|
||||||
|
var total: Double = 0
|
||||||
|
var any = false
|
||||||
|
for f in watchlist.funds {
|
||||||
|
guard let shares = f.shares, shares > 0 else { continue }
|
||||||
|
guard let est = estimates[f.code] else { continue }
|
||||||
|
// Day ¥ = shares × (intraday nav - last published nav)
|
||||||
|
let estNav = est.estimatedNav ?? est.publishedNav
|
||||||
|
let delta = (estNav - est.publishedNav) * shares
|
||||||
|
total += delta
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
return any ? total : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Sources/engine/RefreshScheduler.swift
Normal file
87
Sources/engine/RefreshScheduler.swift
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// RefreshScheduler.swift
|
||||||
|
// 盯基金 plugin
|
||||||
|
//
|
||||||
|
// Drives the refresh cadence based on what's tradeable right now.
|
||||||
|
// Different things have different refresh budgets:
|
||||||
|
//
|
||||||
|
// Tradeable now Cadence
|
||||||
|
// ─────────────────────────────────
|
||||||
|
// A-share funds (intraday) 60 s (estimate API is ~1 min granularity)
|
||||||
|
// A-share funds (closed) 30 min (just to pick up newly published NAV)
|
||||||
|
// Foreign gold (24/5) 30 s (Sina updates GC/XAU continuously)
|
||||||
|
// Domestic gold 30 s while SHFE open
|
||||||
|
//
|
||||||
|
// Trade hours (Asia/Shanghai):
|
||||||
|
// A-share: 09:30–11:30, 13:00–15:00, Mon–Fri
|
||||||
|
// SHFE day: 09:00–10:15, 10:30–11:30, 13:30–15:00
|
||||||
|
// SHFE night: 21:00–02:30
|
||||||
|
// COMEX gold: 06:00–05:00 next day (essentially 23h, 5 days/week)
|
||||||
|
// LBMA London: rolls with COMEX in practice
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class RefreshScheduler {
|
||||||
|
|
||||||
|
enum Tier {
|
||||||
|
/// Funds intraday cadence.
|
||||||
|
case fundsActive
|
||||||
|
/// Funds idle cadence — only listening for end-of-day NAV publish.
|
||||||
|
case fundsIdle
|
||||||
|
/// Realtime gold tier (always on, foreign markets virtually 24/7).
|
||||||
|
case gold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns refresh interval (seconds) for a tier at the given moment.
|
||||||
|
func interval(for tier: Tier, at now: Date = Date()) -> TimeInterval {
|
||||||
|
switch tier {
|
||||||
|
case .fundsActive:
|
||||||
|
return isAShareTradeHour(now) ? 60 : 30 * 60
|
||||||
|
case .fundsIdle:
|
||||||
|
return 30 * 60
|
||||||
|
case .gold:
|
||||||
|
// Always 30s — foreign gold trades virtually 24/7. SHFE has
|
||||||
|
// closed windows but we don't bother backing off; the request
|
||||||
|
// is cheap and Sina caches the latest tick anyway.
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `now` falls inside an A-share trading session
|
||||||
|
/// (09:30–11:30, 13:00–15:00 China time, Mon–Fri, no holiday awareness yet).
|
||||||
|
func isAShareTradeHour(_ now: Date = Date()) -> Bool {
|
||||||
|
var cal = Calendar(identifier: .gregorian)
|
||||||
|
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
let weekday = cal.component(.weekday, from: now) // 1=Sun … 7=Sat
|
||||||
|
guard (2...6).contains(weekday) else { return false }
|
||||||
|
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
|
||||||
|
// 09:30–11:30
|
||||||
|
if hm >= 9 * 60 + 30 && hm < 11 * 60 + 30 { return true }
|
||||||
|
// 13:00–15:00
|
||||||
|
if hm >= 13 * 60 && hm < 15 * 60 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff SHFE gold is in a trading window (loose — no holiday).
|
||||||
|
func isSHFETradeHour(_ now: Date = Date()) -> Bool {
|
||||||
|
var cal = Calendar(identifier: .gregorian)
|
||||||
|
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||||
|
let weekday = cal.component(.weekday, from: now)
|
||||||
|
// Day session also runs Mon-Fri; night session technically can
|
||||||
|
// span into Sat early morning but we keep it simple.
|
||||||
|
guard (2...6).contains(weekday) else { return false }
|
||||||
|
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
|
||||||
|
// 09:00-10:15
|
||||||
|
if hm >= 9 * 60 && hm < 10 * 60 + 15 { return true }
|
||||||
|
// 10:30-11:30
|
||||||
|
if hm >= 10 * 60 + 30 && hm < 11 * 60 + 30 { return true }
|
||||||
|
// 13:30-15:00
|
||||||
|
if hm >= 13 * 60 + 30 && hm < 15 * 60 { return true }
|
||||||
|
// 21:00-23:59 + 00:00-02:30
|
||||||
|
if hm >= 21 * 60 { return true }
|
||||||
|
if hm < 2 * 60 + 30 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Sources/storage/GoldPositionStore.swift
Normal file
57
Sources/storage/GoldPositionStore.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// GoldPositionStore.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Persists the single GoldPosition to its own JSON file (separate
|
||||||
|
// from watchlist.json — different shape, different lifecycle).
|
||||||
|
//
|
||||||
|
// Path: ~/Library/Application Support/Mio Island/Fund/gold-position.json
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GoldPositionStore: ObservableObject {
|
||||||
|
@Published private(set) var position: GoldPosition = GoldPosition(grams: nil, costPerGram: nil)
|
||||||
|
|
||||||
|
private let storeURL: URL
|
||||||
|
private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.gold-position", qos: .userInitiated)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let appSupport = FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
self.storeURL = dir.appendingPathComponent("gold-position.json")
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(grams: Double?, costPerGram: Double?) {
|
||||||
|
position = GoldPosition(grams: grams, costPerGram: costPerGram)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = try? Data(contentsOf: storeURL),
|
||||||
|
let decoded = try? JSONDecoder().decode(GoldPosition.self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
position = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let snapshot = position
|
||||||
|
queue.async { [storeURL] in
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(snapshot)
|
||||||
|
let tmp = storeURL.appendingPathExtension("tmp")
|
||||||
|
try data.write(to: tmp, options: .atomic)
|
||||||
|
_ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp)
|
||||||
|
} catch {
|
||||||
|
NSLog("[fund-plugin] gold position save failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
Sources/storage/Watchlist.swift
Normal file
118
Sources/storage/Watchlist.swift
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// Watchlist.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// User's selected funds + their cost basis / shares (when set).
|
||||||
|
// Persisted as JSON to:
|
||||||
|
// ~/Library/Application Support/Mio Island/Fund/watchlist.json
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class Watchlist: ObservableObject {
|
||||||
|
@Published private(set) var funds: [WatchlistFund] = []
|
||||||
|
|
||||||
|
private let storeURL: URL
|
||||||
|
private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.watchlist", qos: .userInitiated)
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let appSupport = FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
self.storeURL = dir.appendingPathComponent("watchlist.json")
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func add(_ hit: FundSearchHit) {
|
||||||
|
guard !funds.contains(where: { $0.code == hit.code }) else { return }
|
||||||
|
let item = WatchlistFund(
|
||||||
|
code: hit.code, name: hit.name,
|
||||||
|
addedAt: Date(),
|
||||||
|
displayOrder: (funds.map { $0.displayOrder }.max() ?? 0) + 1,
|
||||||
|
shares: nil, costNav: nil
|
||||||
|
)
|
||||||
|
funds.append(item)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(code: String) {
|
||||||
|
funds.removeAll { $0.code == code }
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePosition(code: String, shares: Double?, costNav: Double?) {
|
||||||
|
guard let idx = funds.firstIndex(where: { $0.code == code }) else { return }
|
||||||
|
var f = funds[idx]
|
||||||
|
f.shares = shares
|
||||||
|
f.costNav = costNav
|
||||||
|
funds[idx] = f
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func move(from source: IndexSet, to destination: Int) {
|
||||||
|
funds.move(fromOffsets: source, toOffset: destination)
|
||||||
|
for (idx, var f) in funds.enumerated() {
|
||||||
|
f.displayOrder = idx
|
||||||
|
funds[idx] = f
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ code: String) -> Bool {
|
||||||
|
funds.contains { $0.code == code }
|
||||||
|
}
|
||||||
|
|
||||||
|
var codes: [String] { funds.map(\.code) }
|
||||||
|
|
||||||
|
func fund(code: String) -> WatchlistFund? {
|
||||||
|
funds.first { $0.code == code }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = try? Data(contentsOf: storeURL) else { return }
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder.fundDecoder.decode([WatchlistFund].self, from: data)
|
||||||
|
self.funds = decoded.sorted(by: { $0.displayOrder < $1.displayOrder })
|
||||||
|
} catch {
|
||||||
|
NSLog("[fund-plugin] watchlist load failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let snapshot = self.funds
|
||||||
|
queue.async { [storeURL] in
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder.fundEncoder
|
||||||
|
let data = try encoder.encode(snapshot)
|
||||||
|
let tmp = storeURL.appendingPathExtension("tmp")
|
||||||
|
try data.write(to: tmp, options: .atomic)
|
||||||
|
_ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp)
|
||||||
|
} catch {
|
||||||
|
NSLog("[fund-plugin] watchlist save failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension JSONEncoder {
|
||||||
|
static var fundEncoder: JSONEncoder {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension JSONDecoder {
|
||||||
|
static var fundDecoder: JSONDecoder {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
227
Sources/ui/AddView.swift
Normal file
227
Sources/ui/AddView.swift
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
//
|
||||||
|
// AddView.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// 「添加」tab. Search field + scrollable list of hits with category
|
||||||
|
// tag, code, and an add button. Mirrors the design's panel-add.jsx
|
||||||
|
// but uses the live Eastmoney suggest API instead of mock data.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddView: View {
|
||||||
|
@ObservedObject var store: FundStore
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var hits: [FundSearchHit] = []
|
||||||
|
@State private var isSearching = false
|
||||||
|
@State private var debounceTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
searchField
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
sectionTitle
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(FundTheme.overlay06)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(FundTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
TextField("搜索基金代码 / 名称", text: $query)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
if isSearching {
|
||||||
|
ProgressView().scaleEffect(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
.frame(height: 36)
|
||||||
|
.onChange(of: query) { _, newValue in
|
||||||
|
debounceTask?.cancel()
|
||||||
|
let q = newValue
|
||||||
|
debounceTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await runSearch(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionTitle: some View {
|
||||||
|
HStack {
|
||||||
|
Text(query.isEmpty ? "添加你的基金" : "搜索结果")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
.tracking(0.5)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var list: some View {
|
||||||
|
if query.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer().frame(height: 30)
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
.padding(20)
|
||||||
|
.background(Circle().fill(FundTheme.overlay04))
|
||||||
|
Text("输入基金代码或名称开始搜索")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
Text("数据源: 东方财富 fund.eastmoney.com")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else if hits.isEmpty && !isSearching {
|
||||||
|
VStack {
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
Text("未找到匹配的基金")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(hits) { hit in
|
||||||
|
SearchRow(
|
||||||
|
hit: hit,
|
||||||
|
alreadyAdded: store.watchlist.contains(hit.code)
|
||||||
|
) {
|
||||||
|
if !store.watchlist.contains(hit.code) {
|
||||||
|
store.watchlist.add(hit)
|
||||||
|
Task { await store.refreshNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runSearch(_ q: String) async {
|
||||||
|
guard !q.isEmpty else { hits = []; return }
|
||||||
|
isSearching = true
|
||||||
|
defer { isSearching = false }
|
||||||
|
do {
|
||||||
|
hits = try await FundClient.shared.search(q)
|
||||||
|
} catch {
|
||||||
|
hits = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SearchRow: View {
|
||||||
|
let hit: FundSearchHit
|
||||||
|
let alreadyAdded: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(hit.name)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
tagChip(hit.displayTag, color: tagColor(hit.displayTag))
|
||||||
|
Text(hit.code)
|
||||||
|
.font(.system(size: 10.5, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
if let cat = hit.category, cat != hit.displayTag {
|
||||||
|
Text(cat)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
addBtn
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(isHovered ? FundTheme.overlay04 : Color.clear)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(alreadyAdded)
|
||||||
|
.onHover { isHovered = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addBtn: some View {
|
||||||
|
Group {
|
||||||
|
if alreadyAdded {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.overlay08)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.downGreen)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.lime)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tagChip(_ text: String, color: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 9.5, weight: .bold))
|
||||||
|
.foregroundColor(color)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(color.opacity(0.18))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tagColor(_ tag: String) -> Color {
|
||||||
|
switch tag {
|
||||||
|
case "ETF": return Color(red: 0xFF/255, green: 0x8A/255, blue: 0x4C/255)
|
||||||
|
case "指数": return Color(red: 0x4C/255, green: 0xB6/255, blue: 0xFF/255)
|
||||||
|
case "股票": return Color(red: 0xA7/255, green: 0x85/255, blue: 0xFF/255)
|
||||||
|
case "QDII": return Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255)
|
||||||
|
case "混合": return Color(red: 0x9A/255, green: 0xA0/255, blue: 0xA8/255)
|
||||||
|
case "债券": return Color(red: 0x2C/255, green: 0xD4/255, blue: 0x7E/255)
|
||||||
|
case "货币": return Color(red: 0x7A/255, green: 0xC0/255, blue: 0xC0/255)
|
||||||
|
default: return Color(red: 0x88/255, green: 0x88/255, blue: 0x88/255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
Sources/ui/ExpandedView.swift
Normal file
243
Sources/ui/ExpandedView.swift
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
//
|
||||||
|
// ExpandedView.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Top-level panel container — 380×540 to match design spec. Renders
|
||||||
|
// the title bar (with refresh button + live dot) + tab pill strip +
|
||||||
|
// active tab body (HoldingsView / GoldView / AddView).
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ExpandedView: View {
|
||||||
|
@ObservedObject var store: FundStore = .shared
|
||||||
|
|
||||||
|
enum Tab: String, CaseIterable, Identifiable {
|
||||||
|
case holdings = "持仓"
|
||||||
|
case gold = "黄金"
|
||||||
|
case add = "添加"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var tab: Tab = .holdings
|
||||||
|
@State private var refreshSpinning = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Notch reservation strip (40pt) — same pattern Music Player
|
||||||
|
// uses. The physical camera/notch module + the host's
|
||||||
|
// floating back-chevron (at y=12, host-controlled) live in
|
||||||
|
// this band. No plugin paint here so the dark Island shell
|
||||||
|
// shows through cleanly behind the notch.
|
||||||
|
Color.clear.frame(height: 40)
|
||||||
|
topBar
|
||||||
|
tabStrip
|
||||||
|
body_
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(width: 380, height: 580)
|
||||||
|
.background(
|
||||||
|
ZStack {
|
||||||
|
FundTheme.panelBg
|
||||||
|
// very subtle radial highlight at the top to feel like the
|
||||||
|
// notch glow rolling onto the panel
|
||||||
|
RadialGradient(
|
||||||
|
colors: [Color.white.opacity(0.04), Color.clear],
|
||||||
|
center: .top,
|
||||||
|
startRadius: 4, endRadius: 220
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(
|
||||||
|
UnevenRoundedRectangle(
|
||||||
|
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
store.start()
|
||||||
|
Task { await store.refreshNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Top bar
|
||||||
|
|
||||||
|
private var topBar: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// The notch strip above (40pt) already clears the host's
|
||||||
|
// floating back-chevron, so the title can sit flush at the
|
||||||
|
// panel's leading edge — no horizontal indent needed.
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.lime)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.shadow(color: FundTheme.lime.opacity(0.6), radius: 4)
|
||||||
|
Text("看盘侠")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
Button {
|
||||||
|
refreshSpinning = true
|
||||||
|
Task {
|
||||||
|
await store.refreshNow()
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
refreshSpinning = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(refreshSpinning ? FundTheme.lime : FundTheme.fg55)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(Circle().fill(Color.clear))
|
||||||
|
.rotationEffect(.degrees(refreshSpinning ? 360 : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.6), value: refreshSpinning)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tabs
|
||||||
|
|
||||||
|
private var tabStrip: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Tab.allCases) { t in
|
||||||
|
tabPill(label: t.rawValue, count: count(for: t), selected: tab == t) {
|
||||||
|
tab = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tabPill(label: String, count: Int?, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12.5, weight: .semibold))
|
||||||
|
if let c = count {
|
||||||
|
Text("\(c)")
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.opacity(selected ? 0.55 : 0.7)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(selected ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) : FundTheme.fg70)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(selected ? FundTheme.lime : FundTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(selected ? Color.clear : FundTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func count(for tab: Tab) -> Int? {
|
||||||
|
switch tab {
|
||||||
|
case .holdings: return store.watchlist.funds.count
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var body_: some View {
|
||||||
|
switch tab {
|
||||||
|
case .holdings:
|
||||||
|
HoldingsView(store: store) { tab = .add }
|
||||||
|
case .gold:
|
||||||
|
GoldView(store: store)
|
||||||
|
case .add:
|
||||||
|
AddView(store: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Footer
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
LiveDot()
|
||||||
|
Text(footerLeftText)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(footerRightText)
|
||||||
|
}
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.clear)
|
||||||
|
.overlay(
|
||||||
|
Rectangle()
|
||||||
|
.fill(FundTheme.overlay04)
|
||||||
|
.frame(height: 0.5),
|
||||||
|
alignment: .top
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerLeftText: String {
|
||||||
|
switch tab {
|
||||||
|
case .holdings: return store.estimates.isEmpty ? "等待数据..." : "实时估值"
|
||||||
|
case .gold: return "沪金 SHFE"
|
||||||
|
case .add: return "东方财富搜索"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerRightText: String {
|
||||||
|
if let t = lastUpdate {
|
||||||
|
return "更新于 \(relative(t))"
|
||||||
|
}
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastUpdate: Date? {
|
||||||
|
switch tab {
|
||||||
|
case .holdings: return store.lastFundRefresh
|
||||||
|
case .gold: return store.lastGoldRefresh
|
||||||
|
case .add: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relative(_ d: Date) -> String {
|
||||||
|
let s = Int(Date().timeIntervalSince(d))
|
||||||
|
if s < 60 { return "\(s)s 前" }
|
||||||
|
if s < 3600 { return "\(s / 60)分前" }
|
||||||
|
return "\(s / 3600)时前"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Live dot (small pulsing green dot, copied from design)
|
||||||
|
|
||||||
|
private struct LiveDot: View {
|
||||||
|
@State private var pulse = false
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.downGreen)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
.shadow(color: FundTheme.downGreen.opacity(0.7), radius: 4)
|
||||||
|
.scaleEffect(pulse ? 0.8 : 1.0)
|
||||||
|
.opacity(pulse ? 0.4 : 1.0)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {
|
||||||
|
pulse = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
Sources/ui/GoldPositionCard.swift
Normal file
239
Sources/ui/GoldPositionCard.swift
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
//
|
||||||
|
// GoldPositionCard.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// 「我的持仓」card under the gold chart. Mirrors the design's
|
||||||
|
// `.gold-position` block. Tap-to-edit grams + cost-per-gram inline.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GoldPositionCard: View {
|
||||||
|
@ObservedObject var store: FundStore
|
||||||
|
@State private var isEditing = false
|
||||||
|
@State private var gramsText = ""
|
||||||
|
@State private var costText = ""
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
enum Field { case grams, cost }
|
||||||
|
|
||||||
|
/// Current gold price in RMB/g (from SHFE realtime).
|
||||||
|
private var currentPrice: Double? {
|
||||||
|
store.goldQuotes[.shfeFutures]?.last
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isEditing {
|
||||||
|
editor
|
||||||
|
} else {
|
||||||
|
display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(FundTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(FundTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display
|
||||||
|
|
||||||
|
private var display: some View {
|
||||||
|
let p = store.goldPosition.position
|
||||||
|
let cur = currentPrice
|
||||||
|
let marketValue: Double? = {
|
||||||
|
guard let g = p.grams, let cur else { return nil }
|
||||||
|
return g * cur
|
||||||
|
}()
|
||||||
|
let pnl: Double? = {
|
||||||
|
guard let mv = marketValue, let cost = p.costAmount else { return nil }
|
||||||
|
return mv - cost
|
||||||
|
}()
|
||||||
|
let pnlPct: Double? = {
|
||||||
|
guard let pnl = pnl, let cost = p.costAmount, cost > 0 else { return nil }
|
||||||
|
return pnl / cost * 100
|
||||||
|
}()
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if !p.hasPosition {
|
||||||
|
// Empty state inline — encourage the user to fill in.
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("我的持仓")
|
||||||
|
.font(.system(size: 11.5, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
Text("点击右侧添加持仓信息")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
beginEdit()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.background(Circle().fill(FundTheme.gold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("我的持仓")
|
||||||
|
.font(.system(size: 11.5, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
Spacer()
|
||||||
|
if let mv = marketValue {
|
||||||
|
Text("¥ \(FundFormat.unsignedMoney(mv))")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text("—")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
if let g = p.grams, let c = p.costPerGram {
|
||||||
|
Text("\(FundFormat.unsignedMoney(g, decimals: 2)) g · 成本 \(String(format: "%.2f", c)) / g")
|
||||||
|
.font(.system(size: 11.5))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let pnl, let pct = pnlPct {
|
||||||
|
Text("\(FundFormat.money(pnl)) · \(FundFormat.percent(pct))")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.upDown(pnl))
|
||||||
|
.monospacedDigit()
|
||||||
|
} else if marketValue != nil {
|
||||||
|
Text("等价格刷新…")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Spacer()
|
||||||
|
Button(action: beginEdit) {
|
||||||
|
Text("编辑")
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(height: 22)
|
||||||
|
.background(Capsule().fill(FundTheme.overlay06))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Editor
|
||||||
|
|
||||||
|
private var editor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("编辑黄金持仓")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button(action: cancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.background(Circle().fill(FundTheme.overlay06))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
inputField(label: "克数 (g)", text: $gramsText, placeholder: "50", field: .grams)
|
||||||
|
inputField(label: "成本(元/g)", text: $costText, placeholder: "550.00", field: .cost)
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button(action: clear) {
|
||||||
|
Text("清除")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(Capsule().fill(FundTheme.overlay06))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
Button(action: save) {
|
||||||
|
Text("保存")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(Capsule().fill(FundTheme.gold))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inputField(label: String, text: Binding<String>, placeholder: String, field: Field) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 9.5))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.focused($focused, equals: field)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.black.opacity(0.4))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(
|
||||||
|
focused == field
|
||||||
|
? FundTheme.gold.opacity(0.5)
|
||||||
|
: FundTheme.overlay08,
|
||||||
|
lineWidth: 0.8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginEdit() {
|
||||||
|
let p = store.goldPosition.position
|
||||||
|
gramsText = p.grams.map { String(format: "%.2f", $0) } ?? ""
|
||||||
|
costText = p.costPerGram.map { String(format: "%.2f", $0) } ?? ""
|
||||||
|
isEditing = true
|
||||||
|
focused = .grams
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel() {
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let g = Double(gramsText.trimmingCharacters(in: .whitespaces))
|
||||||
|
let c = Double(costText.trimmingCharacters(in: .whitespaces))
|
||||||
|
let cleanG = (g.map { $0 > 0 } ?? false) ? g : nil
|
||||||
|
let cleanC = (c.map { $0 > 0 } ?? false) ? c : nil
|
||||||
|
store.goldPosition.update(grams: cleanG, costPerGram: cleanC)
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clear() {
|
||||||
|
store.goldPosition.update(grams: nil, costPerGram: nil)
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
237
Sources/ui/GoldView.swift
Normal file
237
Sources/ui/GoldView.swift
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
//
|
||||||
|
// GoldView.swift
|
||||||
|
// 看盘侠 plugin v0.3
|
||||||
|
//
|
||||||
|
// 「黄金」tab — Alipay-style 「国内金价」 layout:
|
||||||
|
//
|
||||||
|
// ┌──────────────────────────────────────────────┐
|
||||||
|
// │ 黄金99 AU9999.SGE 盘中休市 04-25 │ hero
|
||||||
|
// │ 1040.00 +6.75 +0.65% │
|
||||||
|
// │ 今开 1039.90 · 最高 1044.00 · 最低 1034.00 │
|
||||||
|
// ├──────────────────────────────────────────────┤
|
||||||
|
// │ intraday line chart │ chart
|
||||||
|
// │ 20:00 ────── 02:30/09:00 ────── 15:30 │
|
||||||
|
// ├──────────────────────────────────────────────┤
|
||||||
|
// │ 我的持仓 │ GoldPositionCard
|
||||||
|
// └──────────────────────────────────────────────┘
|
||||||
|
// [伦敦金 pill] [纽约金 pill] reference
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GoldView: View {
|
||||||
|
@ObservedObject var store: FundStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
hero
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
chart
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
sessionAxisStrip
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
GoldPositionCard(store: store)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
referencePills
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero (AU9999 spot)
|
||||||
|
|
||||||
|
private var hero: some View {
|
||||||
|
let q = store.spotGold
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.gold)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
.shadow(color: FundTheme.gold.opacity(0.6), radius: 4)
|
||||||
|
Text("国内金价")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.gold.opacity(0.85))
|
||||||
|
.tracking(0.5)
|
||||||
|
Text("AU9999.SGE")
|
||||||
|
.font(.system(size: 9.5, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
Spacer()
|
||||||
|
if let t = q?.updatedAt {
|
||||||
|
Text(timeOnly(t))
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||||
|
if let q {
|
||||||
|
Text(String(format: "%.2f", q.last))
|
||||||
|
.font(.system(size: 30, weight: .bold))
|
||||||
|
.foregroundColor(.upDown(q.change))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(FundFormat.money(q.change))
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.upDown(q.change))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(FundFormat.percent(q.changeRate))
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(.upDown(q.change))
|
||||||
|
.monospacedDigit()
|
||||||
|
Spacer()
|
||||||
|
Text("元/克")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
} else {
|
||||||
|
Text("--")
|
||||||
|
.font(.system(size: 30, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
Text("加载中…")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 1)
|
||||||
|
|
||||||
|
if let q {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
metaItem("今开", String(format: "%.2f", q.open))
|
||||||
|
metaItem("最高", String(format: "%.2f", q.high))
|
||||||
|
metaItem("最低", String(format: "%.2f", q.low))
|
||||||
|
metaItem("昨收", String(format: "%.2f", q.prevClose))
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
FundTheme.gold.opacity(0.08),
|
||||||
|
FundTheme.gold.opacity(0.02)
|
||||||
|
],
|
||||||
|
startPoint: .top, endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(FundTheme.gold.opacity(0.22), lineWidth: 0.8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stacked vertical (label tiny on top, value below) so 4-digit
|
||||||
|
/// values like 1039.90 don't wrap mid-number when packed across the
|
||||||
|
/// 380pt panel.
|
||||||
|
private func metaItem(_ k: String, _ v: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(k)
|
||||||
|
.font(.system(size: 9.5))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
Text(v)
|
||||||
|
.font(.system(size: 11.5, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeOnly(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||||
|
f.dateFormat = "MM-dd HH:mm"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart (intraday minute line — AU0)
|
||||||
|
|
||||||
|
private var chart: some View {
|
||||||
|
let prices = store.goldMinuteLine.map(\.price)
|
||||||
|
return SparkLine(
|
||||||
|
values: prices,
|
||||||
|
lineColor: FundTheme.gold,
|
||||||
|
fillColor: FundTheme.gold,
|
||||||
|
pulseColor: FundTheme.gold
|
||||||
|
)
|
||||||
|
.frame(height: 110)
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color.white.opacity(0.02))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.white.opacity(0.05), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session axis (replaces 1月/3月/1年/全部 with trade-session anchors)
|
||||||
|
|
||||||
|
private var sessionAxisStrip: some View {
|
||||||
|
HStack {
|
||||||
|
Text("20:00")
|
||||||
|
Spacer()
|
||||||
|
Text("02:30 / 09:00")
|
||||||
|
Spacer()
|
||||||
|
Text("15:30")
|
||||||
|
}
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.top, -2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reference pills
|
||||||
|
|
||||||
|
private var referencePills: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
referencePill(source: .london)
|
||||||
|
referencePill(source: .comex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func referencePill(source: GoldQuote.Source) -> some View {
|
||||||
|
let q = store.goldQuotes[source]
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(source.displayName)
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
if let q {
|
||||||
|
Text("$\(String(format: "%.2f", q.last))")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
if let r = q.changeRate {
|
||||||
|
Text(FundFormat.percent(r))
|
||||||
|
.font(.system(size: 10.5, weight: .semibold))
|
||||||
|
.foregroundColor(.upDown(r))
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("--")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(FundTheme.overlay04)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(FundTheme.overlay08, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/ui/HeaderSlotView.swift
Normal file
41
Sources/ui/HeaderSlotView.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// HeaderSlotView.swift
|
||||||
|
// 盯基金 plugin
|
||||||
|
//
|
||||||
|
// 20×20 icon that lives in the notch header bar. We can't fit text
|
||||||
|
// in 20pt, so the visual contract is: solid icon = idle / red icon
|
||||||
|
// = portfolio down today / green icon = portfolio up today. Tap
|
||||||
|
// opens the expanded panel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HeaderSlotView: View {
|
||||||
|
@ObservedObject var store: FundStore = .shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite tint: bias toward red/green when the day's average
|
||||||
|
/// estimated rate across watchlist funds has a sign. Falls back
|
||||||
|
/// to white when no estimates yet (e.g. first launch, off-hours).
|
||||||
|
private var tint: Color {
|
||||||
|
let rates = store.estimates.values.compactMap { $0.estimatedRate }
|
||||||
|
guard !rates.isEmpty else { return .white.opacity(0.85) }
|
||||||
|
let avg = rates.reduce(0, +) / Double(rates.count)
|
||||||
|
if avg > 0.05 {
|
||||||
|
// Chinese convention: red = up.
|
||||||
|
return Color(red: 1.0, green: 0.30, blue: 0.30)
|
||||||
|
} else if avg < -0.05 {
|
||||||
|
// Green = down.
|
||||||
|
return Color(red: 0.20, green: 0.80, blue: 0.40)
|
||||||
|
}
|
||||||
|
return .white.opacity(0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
360
Sources/ui/HoldingsView.swift
Normal file
360
Sources/ui/HoldingsView.swift
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
//
|
||||||
|
// HoldingsView.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// 「持仓」tab. Hero card with 总市值 + 今日盈亏 / 累计盈亏 / 收益率,
|
||||||
|
// followed by a scrollable list of fund rows. Falls back to an
|
||||||
|
// "empty state" when no funds are added (matches design).
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HoldingsView: View {
|
||||||
|
@ObservedObject var store: FundStore
|
||||||
|
let onAddTap: () -> Void
|
||||||
|
@State private var editingCode: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if store.watchlist.funds.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(FundTheme.overlay04)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
}
|
||||||
|
Text("还没添加自选基金")
|
||||||
|
.font(.system(size: 12.5))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
Button(action: onAddTap) {
|
||||||
|
Text("去添加")
|
||||||
|
.font(.system(size: 12.5, weight: .semibold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.frame(height: 30)
|
||||||
|
.background(Capsule().fill(FundTheme.lime))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero + list
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
heroCard
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
columnHeader
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 2) {
|
||||||
|
ForEach(store.watchlist.funds) { f in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
HoldingRow(
|
||||||
|
fund: f,
|
||||||
|
estimate: store.estimates[f.code],
|
||||||
|
onTap: {
|
||||||
|
editingCode = (editingCode == f.code) ? nil : f.code
|
||||||
|
},
|
||||||
|
onRemove: {
|
||||||
|
if editingCode == f.code { editingCode = nil }
|
||||||
|
store.watchlist.remove(code: f.code)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if editingCode == f.code {
|
||||||
|
PositionEditor(
|
||||||
|
fund: f,
|
||||||
|
onSave: { shares, cost in
|
||||||
|
store.watchlist.updatePosition(
|
||||||
|
code: f.code, shares: shares, costNav: cost
|
||||||
|
)
|
||||||
|
editingCode = nil
|
||||||
|
},
|
||||||
|
onCancel: { editingCode = nil }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero card
|
||||||
|
|
||||||
|
private var heroCard: some View {
|
||||||
|
let mv = store.totalMarketValue
|
||||||
|
let cost = store.totalCost
|
||||||
|
let day = store.totalDayPnL
|
||||||
|
let total: Double? = {
|
||||||
|
guard let mv, let cost else { return nil }
|
||||||
|
return mv - cost
|
||||||
|
}()
|
||||||
|
let totalRate: Double? = {
|
||||||
|
guard let total, let cost, cost > 0 else { return nil }
|
||||||
|
return total / cost * 100
|
||||||
|
}()
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(mv == nil ? "自选监控" : "总市值")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.tracking(0.3)
|
||||||
|
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||||
|
if let mv {
|
||||||
|
Text("¥ \(FundFormat.unsignedMoney(mv))")
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.monospacedDigit()
|
||||||
|
Text("CNY")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
} else {
|
||||||
|
Text("\(store.watchlist.funds.count) 只基金")
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
Text("· 添加持仓查看盈亏")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mv != nil {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
statBlock(label: "今日盈亏", value: day)
|
||||||
|
statBlock(label: "累计盈亏", value: total)
|
||||||
|
statBlock(label: "收益率", value: totalRate, isPercent: true)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
FundTheme.lime.opacity(0.10),
|
||||||
|
FundTheme.lime.opacity(0.02)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column header strip aligning with HoldingRow's two stat columns.
|
||||||
|
/// Without it the user can't tell which number is 今日 vs 累计 — both
|
||||||
|
/// look identical in a single row, and 养基宝 uses the same affordance.
|
||||||
|
private var columnHeader: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("今日")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
Text("累计")
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statBlock(label: String, value: Double?, isPercent: Bool = false) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 10.5))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
if let v = value {
|
||||||
|
Text(isPercent ? FundFormat.percent(v) : FundFormat.money(v))
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.upDown(v))
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text("—")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Holding row
|
||||||
|
|
||||||
|
private struct HoldingRow: View {
|
||||||
|
let fund: WatchlistFund
|
||||||
|
let estimate: FundEstimate?
|
||||||
|
let onTap: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-column layout matching 养基宝:
|
||||||
|
/// [name + ¥market value · 实时价] [当日 ¥ / %] [累计 ¥ / %]
|
||||||
|
/// When no position is set, left side falls back to code · NAV and
|
||||||
|
/// the 累计 column is hidden (no cost basis to compute against).
|
||||||
|
private var content: some View {
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
// Left: name + market value (or code) · realtime nav
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(fund.name)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let mv = marketValue {
|
||||||
|
Text("¥ \(FundFormat.unsignedMoney(mv))")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.monospacedDigit()
|
||||||
|
} else {
|
||||||
|
Text(fund.code)
|
||||||
|
.font(.system(size: 10.5, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg40)
|
||||||
|
}
|
||||||
|
if let nav = estimate?.bestNav, nav > 0 {
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
Text(FundFormat.nav(nav))
|
||||||
|
.font(.system(size: 10.5, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Middle: 当日 (today's gain). Always rendered; falls back
|
||||||
|
// to em-dash when estimate is missing so columns stay aligned
|
||||||
|
// with the header strip above the list.
|
||||||
|
statColumn(amount: todayPnL, rate: estimate?.estimatedRate)
|
||||||
|
|
||||||
|
// Right: 累计 (cumulative). Always rendered for column
|
||||||
|
// alignment — shows a dim em-dash when the user hasn't
|
||||||
|
// entered cost basis yet so the user knows where to look
|
||||||
|
// once they fill it in.
|
||||||
|
statColumn(amount: cumulativePnL, rate: cumulativeRate)
|
||||||
|
|
||||||
|
if isHovered {
|
||||||
|
Button(action: onRemove) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(FundTheme.fg45)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isHovered ? FundTheme.overlay04 : Color.clear)
|
||||||
|
)
|
||||||
|
.onHover { isHovered = $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stat column: ¥ amount on top (large bold), % rate below
|
||||||
|
/// (small). When ¥ is unavailable (no position) but rate is known,
|
||||||
|
/// promote the rate to the headline slot. When neither is known,
|
||||||
|
/// dim em-dashes preserve column alignment with the header strip.
|
||||||
|
/// Width 80pt so 5-digit ¥ amounts like "+4,329.79" don't truncate.
|
||||||
|
@ViewBuilder
|
||||||
|
private func statColumn(amount: Double?, rate: Double?) -> some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
if let a = amount {
|
||||||
|
Text(FundFormat.money(a))
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(.upDown(a))
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
if let r = rate {
|
||||||
|
Text(FundFormat.percent(r))
|
||||||
|
.font(.system(size: 10.5, weight: .medium))
|
||||||
|
.foregroundColor(.upDown(r))
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} else if let r = rate {
|
||||||
|
Text(FundFormat.percent(r))
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundColor(.upDown(r))
|
||||||
|
.monospacedDigit()
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text("—")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg35)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 80, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computed P&L
|
||||||
|
|
||||||
|
/// Today's market value = shares × current best NAV.
|
||||||
|
/// Nil when shares unset (we won't show ¥ holding amount then).
|
||||||
|
private var marketValue: Double? {
|
||||||
|
guard let shares = fund.shares, shares > 0,
|
||||||
|
let est = estimate, est.bestNav > 0 else { return nil }
|
||||||
|
return shares * est.bestNav
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's ¥ P&L: shares × (intraday est nav - last published nav).
|
||||||
|
private var todayPnL: Double? {
|
||||||
|
guard let shares = fund.shares, shares > 0 else { return nil }
|
||||||
|
guard let est = estimate else { return nil }
|
||||||
|
let estNav = est.estimatedNav ?? est.publishedNav
|
||||||
|
return (estNav - est.publishedNav) * shares
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative ¥ P&L: shares × (current best nav - cost basis).
|
||||||
|
private var cumulativePnL: Double? {
|
||||||
|
guard let shares = fund.shares, let cost = fund.costNav,
|
||||||
|
let est = estimate else { return nil }
|
||||||
|
return (est.bestNav - cost) * shares
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative % return: (current - cost) / cost × 100.
|
||||||
|
private var cumulativeRate: Double? {
|
||||||
|
guard let cost = fund.costNav, cost > 0,
|
||||||
|
let est = estimate else { return nil }
|
||||||
|
return (est.bestNav - cost) / cost * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
141
Sources/ui/PositionEditor.swift
Normal file
141
Sources/ui/PositionEditor.swift
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
//
|
||||||
|
// PositionEditor.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Inline editor for a holding's shares + cost basis. Opened from the
|
||||||
|
// holding row's "edit" button, dismissed by Save / Cancel.
|
||||||
|
//
|
||||||
|
// Why inline (rather than a separate window): plugins can't open NSWindows
|
||||||
|
// cleanly without going through the host. Inline editing inside the
|
||||||
|
// panel keeps the experience contained — and the user is already
|
||||||
|
// looking at this row.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PositionEditor: View {
|
||||||
|
let fund: WatchlistFund
|
||||||
|
let onSave: (Double?, Double?) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@State private var sharesText: String = ""
|
||||||
|
@State private var costText: String = ""
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
enum Field { case shares, cost }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("编辑持仓")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button(action: onCancel) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.background(Circle().fill(FundTheme.overlay06))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
inputField(
|
||||||
|
label: "持仓份额",
|
||||||
|
text: $sharesText,
|
||||||
|
placeholder: "0",
|
||||||
|
field: .shares
|
||||||
|
)
|
||||||
|
inputField(
|
||||||
|
label: "成本(元/份)",
|
||||||
|
text: $costText,
|
||||||
|
placeholder: "1.0000",
|
||||||
|
field: .cost
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button(action: clear) {
|
||||||
|
Text("清除")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(FundTheme.overlay06)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Spacer()
|
||||||
|
Button(action: save) {
|
||||||
|
Text("保存")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(Capsule().fill(FundTheme.lime))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(FundTheme.overlay06)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
// Pre-fill if values already exist
|
||||||
|
if let s = fund.shares { sharesText = String(format: "%.2f", s) }
|
||||||
|
if let c = fund.costNav { costText = String(format: "%.4f", c) }
|
||||||
|
focused = .shares
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inputField(label: String, text: Binding<String>, placeholder: String, field: Field) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 9.5))
|
||||||
|
.foregroundColor(FundTheme.fg55)
|
||||||
|
TextField(placeholder, text: text)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(FundTheme.fgPrimary)
|
||||||
|
.focused($focused, equals: field)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.black.opacity(0.4))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(
|
||||||
|
focused == field
|
||||||
|
? FundTheme.lime.opacity(0.5)
|
||||||
|
: FundTheme.overlay08,
|
||||||
|
lineWidth: 0.8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let shares = Double(sharesText.trimmingCharacters(in: .whitespaces))
|
||||||
|
let cost = Double(costText.trimmingCharacters(in: .whitespaces))
|
||||||
|
// Treat 0 / negative as "clear" too.
|
||||||
|
let cleanShares = (shares.map { $0 > 0 } ?? false) ? shares : nil
|
||||||
|
let cleanCost = (cost.map { $0 > 0 } ?? false) ? cost : nil
|
||||||
|
onSave(cleanShares, cleanCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clear() {
|
||||||
|
onSave(nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Sources/ui/SparkLine.swift
Normal file
125
Sources/ui/SparkLine.swift
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
//
|
||||||
|
// SparkLine.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Pure-SwiftUI port of the design's <svg> sparkline for the gold
|
||||||
|
// chart. Handles:
|
||||||
|
// - smooth quadratic curve through points
|
||||||
|
// - filled area under the curve with a vertical gradient
|
||||||
|
// - pulsing "current point" indicator
|
||||||
|
// - dashed midline grid
|
||||||
|
// - empty / 1-point degenerate cases
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SparkLine: View {
|
||||||
|
let values: [Double]
|
||||||
|
let lineColor: Color
|
||||||
|
let fillColor: Color
|
||||||
|
let pulseColor: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let W = geo.size.width
|
||||||
|
let H = geo.size.height
|
||||||
|
let padX: CGFloat = 8
|
||||||
|
let padY: CGFloat = 14
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// dashed midline
|
||||||
|
Path { p in
|
||||||
|
p.move(to: CGPoint(x: 0, y: H / 2))
|
||||||
|
p.addLine(to: CGPoint(x: W, y: H / 2))
|
||||||
|
}
|
||||||
|
.stroke(
|
||||||
|
Color.white.opacity(0.04),
|
||||||
|
style: StrokeStyle(lineWidth: 0.5, lineCap: .round, dash: [2, 4])
|
||||||
|
)
|
||||||
|
|
||||||
|
// bail when not enough data
|
||||||
|
if values.count >= 2,
|
||||||
|
let maxV = values.max(),
|
||||||
|
let minV = values.min(),
|
||||||
|
maxV != minV {
|
||||||
|
|
||||||
|
let xs: (Int) -> CGFloat = { i in
|
||||||
|
padX + (CGFloat(i) / CGFloat(values.count - 1)) * (W - padX * 2)
|
||||||
|
}
|
||||||
|
let ys: (Double) -> CGFloat = { v in
|
||||||
|
let t = (v - minV) / (maxV - minV)
|
||||||
|
return padY + (1 - CGFloat(t)) * (H - padY * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// area fill
|
||||||
|
Path { p in
|
||||||
|
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
|
||||||
|
for i in 1..<values.count {
|
||||||
|
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
|
||||||
|
}
|
||||||
|
p.addLine(to: CGPoint(x: xs(values.count - 1), y: H))
|
||||||
|
p.addLine(to: CGPoint(x: xs(0), y: H))
|
||||||
|
p.closeSubpath()
|
||||||
|
}
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [fillColor.opacity(0.45), fillColor.opacity(0)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// line
|
||||||
|
Path { p in
|
||||||
|
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
|
||||||
|
for i in 1..<values.count {
|
||||||
|
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [lineColor.opacity(0.9), lineColor],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
|
||||||
|
)
|
||||||
|
|
||||||
|
// current point dot + pulsing halo
|
||||||
|
let lastX = xs(values.count - 1)
|
||||||
|
let lastY = ys(values[values.count - 1])
|
||||||
|
Circle()
|
||||||
|
.fill(pulseColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.position(x: lastX, y: lastY)
|
||||||
|
PulsingRing(color: pulseColor)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.position(x: lastX, y: lastY)
|
||||||
|
} else {
|
||||||
|
// degenerate: flat line in the middle
|
||||||
|
Path { p in
|
||||||
|
p.move(to: CGPoint(x: padX, y: H / 2))
|
||||||
|
p.addLine(to: CGPoint(x: W - padX, y: H / 2))
|
||||||
|
}
|
||||||
|
.stroke(lineColor.opacity(0.5), style: StrokeStyle(lineWidth: 1.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PulsingRing: View {
|
||||||
|
let color: Color
|
||||||
|
@State private var pulse = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(pulse ? 0 : 0.6), lineWidth: 1.2)
|
||||||
|
.scaleEffect(pulse ? 1.4 : 0.6)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 2.0).repeatForever(autoreverses: false)) {
|
||||||
|
pulse = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Sources/ui/Theme.swift
Normal file
88
Sources/ui/Theme.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// 盯基金 plugin v0.2
|
||||||
|
//
|
||||||
|
// Single source of truth for the design's colour tokens. Keeps the
|
||||||
|
// per-view code from re-deriving the same hex values.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum FundTheme {
|
||||||
|
// Lime accent — same value as the design's `#d4ff3a`.
|
||||||
|
static let lime = Color(red: 0xD4/255, green: 0xFF/255, blue: 0x3A/255)
|
||||||
|
|
||||||
|
// Gold accent for the gold hero card and chart.
|
||||||
|
static let gold = Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255)
|
||||||
|
|
||||||
|
// China convention: red = up, green = down.
|
||||||
|
static let upRed = Color(red: 0xFF/255, green: 0x5E/255, blue: 0x5E/255)
|
||||||
|
static let downGreen = Color(red: 0x2C/255, green: 0xD4/255, blue: 0x7E/255)
|
||||||
|
|
||||||
|
// Panel background — pure black with a hint of warmth.
|
||||||
|
static let panelBg = Color(red: 0x05/255, green: 0x05/255, blue: 0x05/255)
|
||||||
|
|
||||||
|
// Subtle white overlays used everywhere.
|
||||||
|
static let overlay04 = Color.white.opacity(0.04)
|
||||||
|
static let overlay06 = Color.white.opacity(0.06)
|
||||||
|
static let overlay08 = Color.white.opacity(0.08)
|
||||||
|
static let overlay12 = Color.white.opacity(0.12)
|
||||||
|
static let overlay18 = Color.white.opacity(0.18)
|
||||||
|
|
||||||
|
// Foreground tints
|
||||||
|
static let fgPrimary = Color(red: 0xF4/255, green: 0xF4/255, blue: 0xF5/255)
|
||||||
|
static let fg85 = Color.white.opacity(0.85)
|
||||||
|
static let fg70 = Color.white.opacity(0.7)
|
||||||
|
static let fg55 = Color.white.opacity(0.55)
|
||||||
|
static let fg45 = Color.white.opacity(0.45)
|
||||||
|
static let fg40 = Color.white.opacity(0.4)
|
||||||
|
static let fg35 = Color.white.opacity(0.35)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color helper
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Pick red/green based on a signed value. `0` returns a neutral white.
|
||||||
|
/// Chinese convention: positive = red, negative = green.
|
||||||
|
static func upDown(_ value: Double) -> Color {
|
||||||
|
if value > 0.0001 { return FundTheme.upRed }
|
||||||
|
if value < -0.0001 { return FundTheme.downGreen }
|
||||||
|
return FundTheme.fg70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Number formatting
|
||||||
|
|
||||||
|
enum FundFormat {
|
||||||
|
/// "+0.53%" / "-1.20%" / "0.00%"
|
||||||
|
static func percent(_ value: Double, decimals: Int = 2) -> String {
|
||||||
|
let sign = value > 0 ? "+" : ""
|
||||||
|
return "\(sign)\(String(format: "%.\(decimals)f", value))%"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "+1,234.56" / "-1,234.56" / "0.00"
|
||||||
|
static func money(_ value: Double, decimals: Int = 2) -> String {
|
||||||
|
let sign = value > 0 ? "+" : ""
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.minimumFractionDigits = decimals
|
||||||
|
formatter.maximumFractionDigits = decimals
|
||||||
|
let abs = formatter.string(from: NSNumber(value: Swift.abs(value))) ?? "0"
|
||||||
|
if value < 0 { return "-\(abs)" }
|
||||||
|
return "\(sign)\(abs)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "1,234.56" — no sign, used for "总市值"-like displays.
|
||||||
|
static func unsignedMoney(_ value: Double, decimals: Int = 2) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.minimumFractionDigits = decimals
|
||||||
|
formatter.maximumFractionDigits = decimals
|
||||||
|
return formatter.string(from: NSNumber(value: value)) ?? "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "1.7669" — fixed decimals for NAV display.
|
||||||
|
static func nav(_ value: Double, decimals: Int = 4) -> String {
|
||||||
|
String(format: "%.\(decimals)f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
build.sh
Executable file
67
build.sh
Executable file
@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the 盯基金 plugin as a .bundle for Mio Island.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build.sh # produce build/fund.bundle + build/fund.zip
|
||||||
|
# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
PLUGIN_NAME="fund"
|
||||||
|
MODULE_NAME="FundPlugin"
|
||||||
|
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
|
||||||
|
BUILD_DIR="build"
|
||||||
|
|
||||||
|
SOURCES=$(find Sources -name "*.swift" -type f)
|
||||||
|
SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..."
|
||||||
|
|
||||||
|
rm -rf "${BUILD_DIR}"
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS"
|
||||||
|
|
||||||
|
# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+
|
||||||
|
# which means Apple Silicon dominant, and the Sina/Eastmoney HTTP
|
||||||
|
# bits are platform-agnostic. Add x86_64 + lipo if Intel users surface.
|
||||||
|
swiftc \
|
||||||
|
-emit-library \
|
||||||
|
-module-name "${MODULE_NAME}" \
|
||||||
|
-target arm64-apple-macos15.0 \
|
||||||
|
-sdk "$(xcrun --show-sdk-path)" \
|
||||||
|
-O \
|
||||||
|
-o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \
|
||||||
|
${SOURCES}
|
||||||
|
|
||||||
|
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||||
|
|
||||||
|
if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
|
||||||
|
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ad-hoc sign the whole bundle. --deep covers any future nested
|
||||||
|
# resources without needing the script changed.
|
||||||
|
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
# zip for marketplace upload (顶层是 .bundle 目录, 没有 ._* 垃圾)
|
||||||
|
cd "${BUILD_DIR}"
|
||||||
|
rm -f "${PLUGIN_NAME}.zip"
|
||||||
|
zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}"
|
||||||
|
cd ..
|
||||||
|
echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip"
|
||||||
|
|
||||||
|
if [ "${1:-}" = "install" ]; then
|
||||||
|
PLUGIN_DIR="${HOME}/.config/codeisland/plugins"
|
||||||
|
mkdir -p "${PLUGIN_DIR}"
|
||||||
|
rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}"
|
||||||
|
cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/"
|
||||||
|
echo "✓ Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}"
|
||||||
|
echo " Restart Mio Island (Cmd+Q + reopen) to load the new build."
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Install locally:"
|
||||||
|
echo " ./build.sh install"
|
||||||
|
fi
|
||||||
Loading…
Reference in New Issue
Block a user