b2970e4c-45a2-423e-b84d-257.../Sources/ui/GoldView.swift
徐翔宇 e5a72bcf42 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>
2026-04-27 20:20:15 +08:00

238 lines
8.9 KiB
Swift

//
// 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)
)
)
}
}