mirror of
https://github.com/kitsiu1211-stack/mio-deepseek-balance.git
synced 2026-08-10 02:54:32 +00:00
361 lines
14 KiB
Swift
361 lines
14 KiB
Swift
|
|
//
|
|||
|
|
// DeepSeekBalancePlugin.swift
|
|||
|
|
// MioIsland 插件:DeepSeek 余额(刘海岛头部常驻 + 点开明细)
|
|||
|
|
//
|
|||
|
|
// 数据:~/.config/token-bar/status.json(token_bar.py 每 30 分钟抓取)
|
|||
|
|
// + 本地余额历史推算充值/消耗(DeepSeek 无公开账单 API,实测 404)
|
|||
|
|
//
|
|||
|
|
|
|||
|
|
import AppKit
|
|||
|
|
import SwiftUI
|
|||
|
|
import Combine
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
// MARK: - 数据层
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
private let statusFile = FileManager.default.homeDirectoryForCurrentUser
|
|||
|
|
.appendingPathComponent(".config/token-bar/status.json")
|
|||
|
|
private let historyFile = FileManager.default.homeDirectoryForCurrentUser
|
|||
|
|
.appendingPathComponent(".config/token-bar/balance_history.json")
|
|||
|
|
|
|||
|
|
struct ProviderStatus: Decodable {
|
|||
|
|
let balance: Double?
|
|||
|
|
let budget: Double?
|
|||
|
|
let error: String?
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct StatusData: Decodable {
|
|||
|
|
let providers: [ProviderStatus]?
|
|||
|
|
let updated: String?
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
struct BalancePoint: Codable {
|
|||
|
|
let t: TimeInterval
|
|||
|
|
let balance: Double
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Kimi Code 用量(GET /coding/v1/usages,OAuth token 来自 CLI 凭证文件)──
|
|||
|
|
struct KimiUsage {
|
|||
|
|
var limit: Int = 0
|
|||
|
|
var used: Int = 0
|
|||
|
|
var remaining: Int = 0
|
|||
|
|
var resetTime: String = ""
|
|||
|
|
var membership: String = ""
|
|||
|
|
var available = false
|
|||
|
|
|
|||
|
|
var remainingRatio: Double { limit > 0 ? Double(remaining) / Double(limit) : 0 }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private let kimiCredsFile = FileManager.default.homeDirectoryForCurrentUser
|
|||
|
|
.appendingPathComponent(".kimi-code/credentials/kimi-code.json")
|
|||
|
|
|
|||
|
|
func fetchKimiUsage() -> KimiUsage {
|
|||
|
|
var usage = KimiUsage()
|
|||
|
|
guard let data = try? Data(contentsOf: kimiCredsFile),
|
|||
|
|
let creds = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|||
|
|
let token = creds["access_token"] as? String,
|
|||
|
|
let url = URL(string: "https://api.kimi.com/coding/v1/usages") else { return usage }
|
|||
|
|
|
|||
|
|
var req = URLRequest(url: url)
|
|||
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|||
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|||
|
|
req.timeoutInterval = 8
|
|||
|
|
|
|||
|
|
guard let respData = try? NSURLConnection.sendSynchronousRequest(req, returning: nil),
|
|||
|
|
let json = try? JSONSerialization.jsonObject(with: respData) as? [String: Any],
|
|||
|
|
let u = json["usage"] as? [String: Any] else { return usage }
|
|||
|
|
|
|||
|
|
usage.limit = Int(u["limit"] as? String ?? "0") ?? 0
|
|||
|
|
usage.used = Int(u["used"] as? String ?? "0") ?? 0
|
|||
|
|
usage.remaining = Int(u["remaining"] as? String ?? "0") ?? 0
|
|||
|
|
usage.resetTime = u["resetTime"] as? String ?? ""
|
|||
|
|
if let user = json["user"] as? [String: Any],
|
|||
|
|
let m = user["membership"] as? [String: Any] {
|
|||
|
|
usage.membership = m["level"] as? String ?? ""
|
|||
|
|
}
|
|||
|
|
usage.available = true
|
|||
|
|
return usage
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 插件共享状态(SwiftUI 观察)
|
|||
|
|
final class BalanceStore: ObservableObject {
|
|||
|
|
@Published var balance: Double = 0
|
|||
|
|
@Published var budget: Double = 100
|
|||
|
|
@Published var updated: String = "--"
|
|||
|
|
@Published var history: [BalancePoint] = []
|
|||
|
|
@Published var kimi = KimiUsage()
|
|||
|
|
|
|||
|
|
var ratio: Double { budget > 0 ? balance / budget : 1 }
|
|||
|
|
|
|||
|
|
/// 余额比例 → 颜色:>=35% 蓝,<=20% 红,中间渐变
|
|||
|
|
var accent: Color {
|
|||
|
|
let t = min(max((0.35 - ratio) / 0.15, 0), 1)
|
|||
|
|
return Color(red: 0.30 + (0.95 - 0.30) * t,
|
|||
|
|
green: 0.42 + (0.25 - 0.42) * t,
|
|||
|
|
blue: 1.0 + (0.20 - 1.0) * t)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var isWarning: Bool { ratio <= 0.20 }
|
|||
|
|
|
|||
|
|
func refresh() {
|
|||
|
|
guard let data = try? Data(contentsOf: statusFile),
|
|||
|
|
let status = try? JSONDecoder().decode(StatusData.self, from: data),
|
|||
|
|
let prov = status.providers?.first, prov.error == nil, let bal = prov.balance else { return }
|
|||
|
|
balance = bal
|
|||
|
|
budget = prov.budget ?? 100
|
|||
|
|
updated = status.updated ?? "--"
|
|||
|
|
appendHistory(balance: bal)
|
|||
|
|
history = loadHistory()
|
|||
|
|
refreshKimi()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func refreshKimi() {
|
|||
|
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|||
|
|
let u = fetchKimiUsage()
|
|||
|
|
DispatchQueue.main.async { self?.kimi = u }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func appendHistory(balance: Double) {
|
|||
|
|
var h = loadHistory()
|
|||
|
|
if let last = h.last, abs(last.balance - balance) < 0.01 { return }
|
|||
|
|
h.append(BalancePoint(t: Date().timeIntervalSince1970, balance: balance))
|
|||
|
|
if h.count > 1000 { h = Array(h.suffix(1000)) }
|
|||
|
|
if let data = try? JSONEncoder().encode(h) {
|
|||
|
|
try? data.write(to: historyFile, options: .atomic)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func loadHistory() -> [BalancePoint] {
|
|||
|
|
guard let data = try? Data(contentsOf: historyFile),
|
|||
|
|
let h = try? JSONDecoder().decode([BalancePoint].self, from: data) else { return [] }
|
|||
|
|
return h
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 充值事件(余额上涨 >¥1)
|
|||
|
|
struct Recharge {
|
|||
|
|
let date: String
|
|||
|
|
let amount: Double
|
|||
|
|
let prevDays: Double?
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var recharges: [Recharge] {
|
|||
|
|
var events: [(t: TimeInterval, amount: Double, prevT: TimeInterval?)] = []
|
|||
|
|
for i in 1..<history.count {
|
|||
|
|
let delta = history[i].balance - history[i - 1].balance
|
|||
|
|
if delta > 1.0 {
|
|||
|
|
events.append((history[i].t, delta, events.last?.t))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let fmt = DateFormatter()
|
|||
|
|
fmt.dateFormat = "MM-dd"
|
|||
|
|
return events.reversed().prefix(5).map { e in
|
|||
|
|
Recharge(date: fmt.string(from: Date(timeIntervalSince1970: e.t)),
|
|||
|
|
amount: e.amount,
|
|||
|
|
prevDays: e.prevT.map { (e.t - $0) / 86400 })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var dailyBurn: Double? {
|
|||
|
|
guard let newest = history.last else { return nil }
|
|||
|
|
let cutoff = newest.t - 86400
|
|||
|
|
let oldest = history.last(where: { $0.t <= cutoff }) ?? history.first!
|
|||
|
|
let burn = oldest.balance - newest.balance
|
|||
|
|
return burn > 0.01 ? burn : nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
// MARK: - 插件主类
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
final class DeepSeekBalancePlugin: NSObject, MioPlugin {
|
|||
|
|
var id: String { "deepseek-balance" }
|
|||
|
|
var name: String { "DeepSeek 余额" }
|
|||
|
|
var icon: String { "yensign.circle.fill" }
|
|||
|
|
var version: String { "1.0.0" }
|
|||
|
|
|
|||
|
|
private let store = BalanceStore()
|
|||
|
|
private var timer: Timer?
|
|||
|
|
|
|||
|
|
func activate() {
|
|||
|
|
store.refresh()
|
|||
|
|
timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
|||
|
|
self?.store.refresh()
|
|||
|
|
}
|
|||
|
|
RunLoop.main.add(timer!, forMode: .common)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func deactivate() {
|
|||
|
|
timer?.invalidate()
|
|||
|
|
timer = nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 点开后的明细视图
|
|||
|
|
func makeView() -> NSView {
|
|||
|
|
NSHostingView(rootView: BalanceDetailView(store: store))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 刘海头部常驻小视图:¥ 徽章 + 余额数字(随余额渐变预警色)
|
|||
|
|
@objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
|
|||
|
|
guard slot == "header" else { return nil }
|
|||
|
|
return NSHostingView(rootView: BalanceHeaderView(store: store))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
// MARK: - SwiftUI 视图
|
|||
|
|
// ═══════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
/// 头部常驻:紧凑的 ¥ + 数字
|
|||
|
|
private struct BalanceHeaderView: View {
|
|||
|
|
@ObservedObject var store: BalanceStore
|
|||
|
|
@State private var pulsing = false
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
HStack(spacing: 4) {
|
|||
|
|
Text("¥")
|
|||
|
|
.font(.system(size: 9, weight: .bold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
.frame(width: 14, height: 14)
|
|||
|
|
.background(Circle().fill(store.accent))
|
|||
|
|
.opacity(store.isWarning ? (pulsing ? 1.0 : 0.4) : 1.0)
|
|||
|
|
Text(String(format: "%.0f", store.balance))
|
|||
|
|
.font(.system(size: 12, weight: .semibold).monospacedDigit())
|
|||
|
|
.foregroundColor(store.isWarning ? Color(red: 0.95, green: 0.25, blue: 0.2) : .white)
|
|||
|
|
if store.kimi.available {
|
|||
|
|
Text("K")
|
|||
|
|
.font(.system(size: 9, weight: .bold))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
.frame(width: 14, height: 14)
|
|||
|
|
.background(Circle().fill(Color(red: 0.48, green: 0.36, blue: 1.0)))
|
|||
|
|
.padding(.leading, 4)
|
|||
|
|
Text(String(format: "%.0f%%", store.kimi.remainingRatio * 100))
|
|||
|
|
.font(.system(size: 12, weight: .semibold).monospacedDigit())
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal, 6)
|
|||
|
|
.padding(.vertical, 2)
|
|||
|
|
.onAppear {
|
|||
|
|
if store.isWarning {
|
|||
|
|
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
|
|||
|
|
pulsing = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 点开后:余额明细
|
|||
|
|
private struct BalanceDetailView: View {
|
|||
|
|
@ObservedObject var store: BalanceStore
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
VStack(alignment: .leading, spacing: 18) {
|
|||
|
|
// 大数字 + 进度条
|
|||
|
|
VStack(alignment: .leading, spacing: 8) {
|
|||
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|||
|
|
Text("¥")
|
|||
|
|
.font(.system(size: 22, weight: .bold))
|
|||
|
|
.foregroundColor(store.accent)
|
|||
|
|
Text(String(format: "%.2f", store.balance))
|
|||
|
|
.font(.system(size: 40, weight: .bold).monospacedDigit())
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
}
|
|||
|
|
GeometryReader { geo in
|
|||
|
|
ZStack(alignment: .leading) {
|
|||
|
|
RoundedRectangle(cornerRadius: 3)
|
|||
|
|
.fill(Color.white.opacity(0.12))
|
|||
|
|
.frame(height: 6)
|
|||
|
|
RoundedRectangle(cornerRadius: 3)
|
|||
|
|
.fill(store.accent)
|
|||
|
|
.frame(width: geo.size.width * min(max(store.ratio, 0), 1), height: 6)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.frame(height: 6)
|
|||
|
|
HStack {
|
|||
|
|
Text(String(format: "剩余 %.0f%%", store.ratio * 100))
|
|||
|
|
Spacer()
|
|||
|
|
Text("更新于 \(store.updated)")
|
|||
|
|
}
|
|||
|
|
.font(.system(size: 11))
|
|||
|
|
.foregroundColor(.white.opacity(0.5))
|
|||
|
|
if store.isWarning {
|
|||
|
|
Text("⚠️ 余额告急,记得充值")
|
|||
|
|
.font(.system(size: 12, weight: .semibold))
|
|||
|
|
.foregroundColor(Color(red: 0.95, green: 0.25, blue: 0.2))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Kimi Code 用量
|
|||
|
|
if store.kimi.available {
|
|||
|
|
VStack(alignment: .leading, spacing: 8) {
|
|||
|
|
HStack {
|
|||
|
|
Text("Kimi Code 用量")
|
|||
|
|
.font(.system(size: 13, weight: .semibold))
|
|||
|
|
.foregroundColor(.white.opacity(0.9))
|
|||
|
|
Spacer()
|
|||
|
|
Text(store.kimi.membership.replacingOccurrences(of: "LEVEL_", with: ""))
|
|||
|
|
.font(.system(size: 10))
|
|||
|
|
.foregroundColor(.white.opacity(0.4))
|
|||
|
|
}
|
|||
|
|
GeometryReader { geo in
|
|||
|
|
ZStack(alignment: .leading) {
|
|||
|
|
RoundedRectangle(cornerRadius: 3)
|
|||
|
|
.fill(Color.white.opacity(0.12))
|
|||
|
|
.frame(height: 5)
|
|||
|
|
RoundedRectangle(cornerRadius: 3)
|
|||
|
|
.fill(Color(red: 0.48, green: 0.36, blue: 1.0))
|
|||
|
|
.frame(width: geo.size.width * min(max(store.kimi.remainingRatio, 0), 1), height: 5)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.frame(height: 5)
|
|||
|
|
Text("本周配额 \(store.kimi.used)/\(store.kimi.limit),剩余 \(store.kimi.remaining)")
|
|||
|
|
.font(.system(size: 11).monospacedDigit())
|
|||
|
|
.foregroundColor(.white.opacity(0.55))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 消耗速率
|
|||
|
|
if let burn = store.dailyBurn {
|
|||
|
|
Label(String(format: "近 24h 消耗 ¥%.2f", burn), systemImage: "chart.line.downtrend.xyaxis")
|
|||
|
|
.font(.system(size: 12))
|
|||
|
|
.foregroundColor(.white.opacity(0.75))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 充值明细
|
|||
|
|
VStack(alignment: .leading, spacing: 8) {
|
|||
|
|
Text("充值明细(快照推算)")
|
|||
|
|
.font(.system(size: 13, weight: .semibold))
|
|||
|
|
.foregroundColor(.white.opacity(0.9))
|
|||
|
|
if store.recharges.isEmpty {
|
|||
|
|
Text("暂无充值记录,历史数据积累中……")
|
|||
|
|
.font(.system(size: 12))
|
|||
|
|
.foregroundColor(.white.opacity(0.45))
|
|||
|
|
} else {
|
|||
|
|
ForEach(Array(store.recharges.enumerated()), id: \.offset) { _, r in
|
|||
|
|
HStack {
|
|||
|
|
Text(r.date)
|
|||
|
|
.foregroundColor(.white.opacity(0.5))
|
|||
|
|
Text(String(format: "+¥%.2f", r.amount))
|
|||
|
|
.foregroundColor(.white)
|
|||
|
|
Spacer()
|
|||
|
|
if let d = r.prevDays {
|
|||
|
|
Text(String(format: "上一笔用了 %.0f 天", d))
|
|||
|
|
.foregroundColor(.white.opacity(0.45))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.font(.system(size: 12).monospacedDigit())
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Spacer()
|
|||
|
|
}
|
|||
|
|
.padding(24)
|
|||
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|||
|
|
}
|
|||
|
|
}
|