mirror of
https://github.com/carey314/mio-plugin-fund.git
synced 2026-08-10 05:44:31 +00:00
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>
361 lines
14 KiB
Swift
361 lines
14 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|