diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7e0f51..92a6037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,7 @@ jobs: run: ./script/test_settings_codable.sh - name: Run compact popover checks run: ./script/test_compact_popover.sh + - name: Run Today overview checks + run: ./script/test_today_overview.sh - name: Build TokenStep run: ./script/build_swiftui_and_run.sh --no-launch diff --git a/CHANGELOG.md b/CHANGELOG.md index 80eed20..72d05e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ TokenStep 各版本的更新内容,新版本在前。每个版本的完整说明在 [docs/RELEASE_NOTES_<版本号>.md](docs/)。 +## 0.2.21 更新:仪表盘与设置改版 + +仪表盘和设置参考 Token Rank 个人页重新设计:今日页新增与昨天对比、Token 构成、近 5 周热力图和按客户端堆叠的分时图;历史页新增年度热力图、常用时段和最长连续达标;设置改为左侧栏分组,改动立即生效。详见 [0.2.21 发布说明](docs/RELEASE_NOTES_0.2.21.md)。 + ## 0.2.20 更新:额度到点重置,面板信息保持最新 额度窗口过了重置时间显示「已重置」并自动重新读取;面板一直开着时,倒计时、时间进度竖线和提前用完的提示也会每分钟更新;修复刘海卡片关闭菜单后意外收起、鼠标指针停在手形的问题。详见 [0.2.20 发布说明](docs/RELEASE_NOTES_0.2.20.md)。 diff --git a/README.md b/README.md index de0897b..1749a87 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.20.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.21.dmg) 也可以从 Release 页面查看所有版本: @@ -32,9 +32,9 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases -## 最新版本:0.2.20 +## 最新版本:0.2.21 -额度窗口过了重置时间显示「已重置」并自动重新读取;面板一直开着时,倒计时、时间进度竖线和提前用完的提示也会每分钟更新;修复刘海卡片关闭菜单后意外收起、鼠标指针停在手形的问题。详见 [0.2.20 发布说明](docs/RELEASE_NOTES_0.2.20.md)。 +仪表盘和设置参考 Token Rank 个人页重新设计:今日页新增与昨天对比、Token 构成、近 5 周热力图和按客户端堆叠的分时图;历史页新增年度热力图、常用时段和最长连续达标;设置改为左侧栏分组,改动立即生效。详见 [0.2.21 发布说明](docs/RELEASE_NOTES_0.2.21.md)。 历次更新(主题包、引力动效、自动更新等)见 [CHANGELOG.md](CHANGELOG.md)。 @@ -95,7 +95,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.20.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.21.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -166,7 +166,7 @@ TokenStepSwift/dist/TokenStep.app 公开发布强制执行 Developer ID 签名、Apple 公证、票据装订、系统分发检查和隔离安装验证。不再生成可被误上传的“仅签名、未公证”发布包: ```bash -TOKENSTEP_VERSION=0.2.20 \ +TOKENSTEP_VERSION=0.2.21 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 1cca0da..4ff8817 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -30,6 +30,66 @@ enum TokenStepLocalization { private static let translations: [TokenStepLanguage: [String: String]] = [ .en: [ + "还没到": "Not yet", + "这一小时没有用量": "No usage this hour", + "改动立即生效,不需要保存。": "Changes apply right away. Nothing to save.", + "主题和配色,随时切换。": "Themes and colors. Switch any time.", + "只读取本机日志里的用量数字,不读对话内容。": "Reads only usage numbers from local logs, never conversations.", + "读取订阅的剩余额度,显示在面板顶部。密钥只存进钥匙串。": "Reads the quota left on your plans and shows it at the top of the panel. Keys stay in the Keychain.", + "在面板里显示你在 Token Rank 公开榜单上的排名。": "Shows your rank on the public Token Rank board in the panel.", + "只在本机统计,不上传用量": "Counts on this Mac only, uploads nothing", + "恢复默认设置": "Restore defaults", + "各自本机登录态": "Each tool's local login", + "Token Rank 排行榜": "Token Rank board", + "只读取公开榜单和本机 Token Rank 账号,TokenStep 不上传任何用量": "Reads only the public board and your local Token Rank account. TokenStep uploads no usage.", + "在面板里显示": "Show in the panel", + "等待关联": "Waiting to link", + "自 %@ 起": "Since %@", + "达标 %d 天": "%d goal days", + "最长连续达标": "Longest goal streak", + "当前连续 %d 天": "Current streak %d days", + "消耗热力图": "Usage heatmap", + "按天 · 描边为今天": "By day · outlined is today", + "%d月": "%d月", + "常用时段": "Busy hours", + "近 30 天": "Last 30 days", + "最常在 %02d:00–%02d:00 用 Agent": "Busiest %02d:00–%02d:00", + "日均 %@": "%@ a day", + "累计分布": "All-time split", + "每日明细": "Daily detail", + "导出 CSV": "Export CSV", + "展开全部 %d 天": "Show all %d days", + "主力客户端": "Top client", + "%@ 今天": "%@ today", + "未达标": "Below goal", + "%d 圈": "%d laps", + "今日已跑满 %d 圈": "%d laps done today", + "比昨天同一时间": "Versus this time yesterday", + "多 %@": "%@ more", + "少 %@": "%@ less", + "Token 构成": "Token mix", + "缓存命中 %@": "Cache hits %@", + "估算金额": "Estimated cost", + "按官方单价估算": "At official list prices", + "活跃时长": "Active time", + "有用量的整点数": "Hours with usage", + "工具调用 %@ 次": "%@ tool calls", + "共 %d 人": "of %d", + "累计 Token": "Total tokens", + "活跃 %d 天": "%d active days", + "近 5 周": "Last 5 weeks", + "全部历史 →": "All history →", + "连续达标 %d 天": "%d-day goal streak", + "一": "M", + "三": "W", + "五": "F", + "日": "S", + "今日分时消耗": "Today by hour", + "峰值 %02d:00 · %@": "Peak %02d:00 · %@", + "另有 %@ 没有时间戳,未计入分时": "%@ more has no timestamp and is not in the hourly chart", + "客户端": "Clients", + "模型排行": "Top models", + "今日 · 金额为估算": "Today · costs are estimates", "自动": "Auto", "刘海左侧": "Left of notch", "刘海右侧": "Right of notch", @@ -724,6 +784,66 @@ enum TokenStepLocalization { "榜": "Rank", ], .zhHant: [ + "还没到": "還沒到", + "这一小时没有用量": "這一小時沒有用量", + "改动立即生效,不需要保存。": "改動立即生效,不需要儲存。", + "主题和配色,随时切换。": "主題和配色,隨時切換。", + "只读取本机日志里的用量数字,不读对话内容。": "只讀取本機日誌裡的用量數字,不讀對話內容。", + "读取订阅的剩余额度,显示在面板顶部。密钥只存进钥匙串。": "讀取訂閱的剩餘額度,顯示在面板頂部。密鑰只存進鑰匙圈。", + "在面板里显示你在 Token Rank 公开榜单上的排名。": "在面板裡顯示你在 Token Rank 公開榜單上的排名。", + "只在本机统计,不上传用量": "只在本機統計,不上傳用量", + "恢复默认设置": "恢復預設設定", + "各自本机登录态": "各自本機登入狀態", + "Token Rank 排行榜": "Token Rank 排行榜", + "只读取公开榜单和本机 Token Rank 账号,TokenStep 不上传任何用量": "只讀取公開榜單和本機 Token Rank 帳號,TokenStep 不上傳任何用量", + "在面板里显示": "在面板裡顯示", + "等待关联": "等待關聯", + "自 %@ 起": "自 %@ 起", + "达标 %d 天": "達標 %d 天", + "最长连续达标": "最長連續達標", + "当前连续 %d 天": "目前連續 %d 天", + "消耗热力图": "消耗熱力圖", + "按天 · 描边为今天": "按天 · 描邊為今天", + "%d月": "%d月", + "常用时段": "常用時段", + "近 30 天": "近 30 天", + "最常在 %02d:00–%02d:00 用 Agent": "最常在 %02d:00–%02d:00 用 Agent", + "日均 %@": "日均 %@", + "累计分布": "累計分佈", + "每日明细": "每日明細", + "导出 CSV": "匯出 CSV", + "展开全部 %d 天": "展開全部 %d 天", + "主力客户端": "主力客戶端", + "%@ 今天": "%@ 今天", + "未达标": "未達標", + "%d 圈": "%d 圈", + "今日已跑满 %d 圈": "今日已跑滿 %d 圈", + "比昨天同一时间": "比昨天同一時間", + "多 %@": "多 %@", + "少 %@": "少 %@", + "Token 构成": "Token 構成", + "缓存命中 %@": "快取命中 %@", + "估算金额": "估算金額", + "按官方单价估算": "按官方單價估算", + "活跃时长": "活躍時長", + "有用量的整点数": "有用量的整點數", + "工具调用 %@ 次": "工具呼叫 %@ 次", + "共 %d 人": "共 %d 人", + "累计 Token": "累計 Token", + "活跃 %d 天": "活躍 %d 天", + "近 5 周": "近 5 週", + "全部历史 →": "全部歷史 →", + "连续达标 %d 天": "連續達標 %d 天", + "一": "一", + "三": "三", + "五": "五", + "日": "日", + "今日分时消耗": "今日分時消耗", + "峰值 %02d:00 · %@": "峰值 %02d:00 · %@", + "另有 %@ 没有时间戳,未计入分时": "另有 %@ 沒有時間戳,未計入分時", + "客户端": "客戶端", + "模型排行": "模型排行", + "今日 · 金额为估算": "今日 · 金額為估算", "经典": "經典", "奥德赛": "奧德賽", "星际": "星際", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index 043493b..1a30417 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -260,11 +260,16 @@ struct TokenCard: View { self.content = content() } + /// Close to the Token Rank profile cards: a small radius, a hairline and, + /// on the classic theme, only a faint shadow. + static var cornerRadius: CGFloat { 16 } + var body: some View { - let shape = RoundedRectangle(cornerRadius: 24, style: .continuous) + let shape = RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) content - .padding(24) + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) .background { ZStack { shape.fill(Color.tokenSurface) @@ -286,13 +291,18 @@ struct TokenCard: View { ZStack { shape.stroke(Color.tokenHairline) if TokenStepThemeRuntime.isVoyage { - VoyageCardOrnament(cornerRadius: 24) + VoyageCardOrnament(cornerRadius: Self.cornerRadius) } else if TokenStepThemeRuntime.isInterstellar { - InterstellarCardOrnament(cornerRadius: 24) + InterstellarCardOrnament(cornerRadius: Self.cornerRadius) } } } - .shadow(color: Color.tokenShadow, radius: 24, x: 0, y: 14) + .shadow( + color: TokenStepThemeRuntime.isCinematic ? Color.tokenShadow : Color.black.opacity(0.04), + radius: TokenStepThemeRuntime.isCinematic ? 24 : 2, + x: 0, + y: TokenStepThemeRuntime.isCinematic ? 14 : 1 + ) } } @@ -619,102 +629,6 @@ struct TokenToolLegend: View { } } -struct ContributionWallView: View { - var rows: [DailyUsage] - var goal: Int - var weeks: Int = 34 - - private var rowByDate: [String: DailyUsage] { - Dictionary(uniqueKeysWithValues: rows.map { ($0.date, $0) }) - } - - var body: some View { - let calendar = Calendar(identifier: .gregorian) - let today = calendar.startOfDay(for: Date()) - let rawStart = calendar.date(byAdding: .day, value: -(weeks * 7 - 1), to: today) ?? today - let weekday = calendar.component(.weekday, from: rawStart) - let mondayOffset = (weekday + 5) % 7 - let start = calendar.date(byAdding: .day, value: -mondayOffset, to: rawStart) ?? rawStart - - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 5) { - ForEach(0.. today ? Color.clear : contributionColor(tokens: usage?.totalTokens ?? 0, goal: goal)) - .frame(width: 15, height: 15) - .overlay { - if calendar.isDate(day, inSameDayAs: today) { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .stroke(Color.tokenGreenDark, lineWidth: 1.5) - } - } - .help(day > today ? "" : cellHelp(date: key, usage: usage)) - } - } - } - } - - HStack { - MetricPill(label: L("活跃"), value: localizedDays(rows.filter { $0.totalTokens > 0 }.count)) - MetricPill(label: L("达标"), value: localizedDays(rows.filter { $0.totalTokens >= goal }.count)) - MetricPill(label: L("最高"), value: TokenStepFormat.tokens(rows.map(\.totalTokens).max() ?? 0, compact: true)) - Spacer() - Text(L("少")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - ForEach([0, Int(Double(goal) * 0.25), Int(Double(goal) * 0.7), goal, goal * 2, goal * 3], id: \.self) { value in - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(contributionColor(tokens: value, goal: goal)) - .frame(width: 15, height: 15) - } - Text(L("多")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - - private func localizedDays(_ count: Int) -> String { - TokenStepLocalization.language == .en ? "\(count)d" : "\(count) 天" - } - - private func monthLabel(_ date: Date) -> String { - let month = Calendar(identifier: .gregorian).component(.month, from: date) - if TokenStepLocalization.language == .en { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US") - formatter.dateFormat = "MMM" - return formatter.string(from: date) - } - return "\(month)月" - } - - private func cellHelp(date: String, usage: DailyUsage?) -> String { - let tokens = usage?.totalTokens ?? 0 - var lines = ["\(date) · \(TokenStepFormat.tokens(tokens, compact: true))"] - if let tools = usage?.tools, !tools.isEmpty, tokens > 0 { - let parts = orderedToolEntries(tools).prefix(4).map { entry in - let percent = Int((Double(entry.tokens) / Double(tokens) * 100).rounded()) - return "\(entry.name) \(percent)%" - } - lines.append(parts.joined(separator: " · ")) - } - return lines.joined(separator: "\n") - } -} - func contributionColor(tokens: Int, goal: Int) -> Color { guard tokens > 0 else { return Color.tokenTrack } if tokens >= max(goal, 1) { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift new file mode 100644 index 0000000..e33dd9f --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift @@ -0,0 +1,637 @@ +import SwiftUI + +// MARK: - Shared pieces + +/// Card title with an optional muted note on the right, as on the Token Rank +/// profile page. +struct DashboardCardHeader: View { + var title: String + var trailing: Trailing + + init(_ title: String, @ViewBuilder trailing: () -> Trailing) { + self.title = title + self.trailing = trailing() + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Spacer(minLength: 8) + trailing + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } +} + +extension DashboardCardHeader where Trailing == EmptyView { + init(_ title: String) { + self.init(title) { EmptyView() } + } +} + +enum DashboardHeatColors { + static func color(level: Int) -> Color { + let palette = TokenStepThemeRuntime.palette + switch level { + case 4: return palette.activity4.color + case 3: return palette.activity3.color + case 2: return palette.activity2.color + case 1: return palette.activity1.color + default: return Color.tokenTrack + } + } +} + +// MARK: - Ring + +struct TodayRingCard: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + let lap = appState.todayLap + let composition = TodayOverviewModel.composition(appState.todayAgentWork) + TokenCard { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 22) { + ZStack { + ring(lap) + VStack(spacing: 2) { + CompactPopoverStyle.tokens(appState.today.totalTokens, size: 28, weight: .bold, design: .rounded) + .foregroundStyle(Color.tokenInk) + .minimumScaleFactor(0.6) + .lineLimit(1) + Text(L("今日 Token")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .frame(width: 108) + } + .frame(width: 144, height: 144) + + VStack(alignment: .leading, spacing: 8) { + Text(lap.lapStatusText) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.tokenGreenDark) + .monospacedDigit() + Text(LFormat("每圈目标 %@", TokenStepFormat.tokens(appState.settings.dailyGoalTokens, compact: true))) + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.72)) + if lap.completedLaps > 0 { + Text(LFormat("今日已跑满 %d 圈", lap.completedLaps)) + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.72)) + } + if let delta = yesterdayDelta { + VStack(alignment: .leading, spacing: 2) { + Text(L("比昨天同一时间")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(delta >= 0 + ? LFormat("多 %@", TokenStepFormat.tokens(delta, compact: true)) + : LFormat("少 %@", TokenStepFormat.tokens(-delta, compact: true))) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(delta >= 0 ? Color.tokenGreenDark : Color.tokenInk.opacity(0.7)) + .monospacedDigit() + } + .padding(.top, 2) + } + } + Spacer(minLength: 0) + } + + if !composition.isEmpty { + compositionView(composition) + } + } + } + } + + @ViewBuilder + private func ring(_ lap: TokenStepLapProgress) -> some View { + if TokenStepThemeRuntime.isVoyage { + VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 12, color: lap.ringColor) + } else if TokenStepThemeRuntime.isInterstellar { + ProgressRingView(progress: lap.currentLapProgress, lineWidth: 12, color: lap.ringColor) + .overlay { + InterstellarEventHorizonEmblem() + .padding(30) + .opacity(0.4) + } + } else { + ProgressRingView(progress: lap.currentLapProgress, lineWidth: 12, color: lap.ringColor) + } + } + + private func compositionView(_ composition: TodayOverviewModel.Composition) -> some View { + let parts: [(String, Int, Color)] = [ + (L("输入"), composition.freshInput, TokenStepThemeRuntime.palette.activity3.color), + (L("缓存读取"), composition.cacheRead, TokenStepThemeRuntime.palette.activity1.color), + (L("输出"), composition.output, TokenStepThemeRuntime.palette.activity4.color) + ] + return VStack(alignment: .leading, spacing: 8) { + HStack { + Text(L("Token 构成")) + Spacer() + if let rate = appState.todayAgentWork.cacheHitRate { + Text(LFormat("缓存命中 %@", TokenStepFormat.percent(rate * 100))) + .monospacedDigit() + } + } + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + GeometryReader { proxy in + let visible = parts.filter { $0.1 > 0 } + let gaps = CGFloat(max(visible.count - 1, 0)) * 2 + HStack(spacing: 2) { + ForEach(visible, id: \.0) { part in + Rectangle() + .fill(part.2) + .frame(width: max(2, (proxy.size.width - gaps) * composition.share(part.1))) + } + } + } + .frame(height: 8) + .clipShape(Capsule()) + + HStack(spacing: 14) { + ForEach(parts, id: \.0) { part in + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 2).fill(part.2).frame(width: 8, height: 8) + Text("\(part.0) \(TokenStepFormat.tokens(part.1, compact: true))") + .monospacedDigit() + } + } + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + private var yesterdayDelta: Int? { + let calendar = TokenStepClock.calendar + let now = Date() + guard appState.today.date == DateFormatter.tokenStepDay.string(from: now), + let yesterday = calendar.date(byAdding: .day, value: -1, to: now) + else { return nil } + let key = DateFormatter.tokenStepDay.string(from: yesterday) + return TodayOverviewModel.deltaVersusYesterday( + today: appState.todayAgentWork, + yesterday: appState.snapshot.agentWork(for: key), + throughHour: calendar.component(.hour, from: now) + ) + } +} + +// MARK: - Metric tiles + +struct TodayMetricTile: View { + var label: String + var value: String + var note: String? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.tokenInk) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + if let note { + Text(note) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, minHeight: 104, maxHeight: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(Color.tokenHairline)) + .accessibilityElement(children: .combine) + } +} + +struct TodayMetricsGrid: View { + @EnvironmentObject private var appState: AppState + /// 2 for a 2×2 block beside the ring, 4 for one row under it. + var columns: Int = 2 + + var body: some View { + let work = appState.todayAgentWork + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columns), + spacing: 12 + ) { + TodayMetricTile( + label: L("估算金额"), + value: TokenStepFormat.money(appState.today.cost), + note: L("按官方单价估算") + ) + TodayMetricTile( + label: L("活跃时长"), + value: LFormat("%d 小时", work.recordedActiveHours), + note: L("有用量的整点数") + ) + TodayMetricTile( + label: L("模型请求"), + value: work.modelRequestCount.formatted(), + note: LFormat("工具调用 %@ 次", work.toolCallCount.formatted()) + ) + rankOrTotalTile + } + } + + @ViewBuilder + private var rankOrTotalTile: some View { + if appState.shouldShowAgentWorkRank, + let identity = appState.agentWorkRankIdentity, + let board = appState.tokenRank, + let mine = board.entry(matching: identity.id) { + TodayMetricTile( + label: L("今日排名"), + value: "#\(mine.rank)", + note: LFormat("共 %d 人", board.totalRankedUsers) + ) + } else { + TodayMetricTile( + label: L("累计 Token"), + value: TokenStepFormat.tokens(appState.snapshot.totals.tokens, compact: true), + note: LFormat("活跃 %d 天", appState.snapshot.totals.activeDays) + ) + } + } +} + +// MARK: - Recent weeks + +struct TodayRecentWeeksCard: View { + @EnvironmentObject private var appState: AppState + var openHistory: (() -> Void)? + + private let cell: CGFloat = 22 + private let gap: CGFloat = 5 + + var body: some View { + let calendar = TokenStepClock.calendar + let goal = appState.settings.dailyGoalTokens + let cells = TodayOverviewModel.recentWeeks( + daily: appState.snapshot.daily, + goal: goal, + today: Date(), + weeks: 5, + calendar: calendar + ) + let streak = TodayOverviewModel.goalStreak(daily: appState.snapshot.daily, goal: goal, today: Date(), calendar: calendar) + TokenCard { + VStack(alignment: .leading, spacing: 12) { + DashboardCardHeader(L("近 5 周")) { + if let openHistory { + Button(action: openHistory) { + Text(L("全部历史 →")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.tokenGreenDark) + } + .buttonStyle(.plain) + } + } + + HStack(alignment: .top, spacing: 8) { + Spacer(minLength: 0) + VStack(spacing: gap) { + ForEach(weekdayLabels.indices, id: \.self) { index in + Text(weekdayLabels[index]) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .frame(height: cell) + } + } + LazyHGrid(rows: Array(repeating: GridItem(.fixed(cell), spacing: gap), count: 7), spacing: gap) { + ForEach(cells) { item in + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(item.isFuture ? Color.clear : DashboardHeatColors.color(level: item.level)) + .overlay { + if item.isFuture { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(Color.tokenHairline, style: StrokeStyle(lineWidth: 1, dash: [2, 2])) + } + } + .overlay { + if item.isToday { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(Color.tokenInk, lineWidth: 1.5) + } + } + .frame(width: cell, height: cell) + .help("\(item.date) · \(TokenStepFormat.tokens(item.tokens, compact: true))") + } + } + .fixedSize() + Spacer(minLength: 0) + } + + HStack { + Text(LFormat("连续达标 %d 天", streak)) + .monospacedDigit() + Spacer() + HStack(spacing: 3) { + Text(L("少")) + ForEach(0..<5, id: \.self) { level in + RoundedRectangle(cornerRadius: 2.5) + .fill(DashboardHeatColors.color(level: level)) + .frame(width: 10, height: 10) + } + Text(L("多")) + } + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } + + private var weekdayLabels: [String] { + [L("一"), "", L("三"), "", L("五"), "", L("日")] + } +} + +// MARK: - Hourly + +struct TodayHourlyStackCard: View { + @EnvironmentObject private var appState: AppState + @State private var hoveredHour: Int? + private let chartHeight: CGFloat = 120 + private let barSpacing: CGFloat = 6 + + var body: some View { + let chart = TodayOverviewModel.hourlyChart(work: appState.todayAgentWork, currentHour: currentHour) + TokenCard { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("今日分时消耗")) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + if let peak = chart.peakHour { + Text(LFormat("峰值 %02d:00 · %@", peak, TokenStepFormat.tokens(chart.peakTokens, compact: true))) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Spacer() + ForEach(Array(chart.sourceOrder.enumerated()), id: \.offset) { _, source in + HStack(spacing: 6) { + Circle().fill(color(source)).frame(width: 8, height: 8) + Text(source.isEmpty ? L("其他") : source) + } + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.75)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .overlay(Capsule().stroke(Color.tokenHairline)) + } + } + + HStack(alignment: .bottom, spacing: barSpacing) { + ForEach(chart.hours) { hour in + VStack(spacing: 1) { + ForEach(Array(hour.parts.enumerated().reversed()), id: \.offset) { index, tokens in + if tokens > 0 { + Rectangle() + .fill(color(chart.sourceOrder[index])) + .frame(height: height(tokens, max: chart.maxTotal)) + } + } + if hour.total == 0 { + Rectangle() + .fill(Color.tokenTrack) + .frame(height: 3) + } + } + .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) + .opacity(hoveredHour == nil || hoveredHour == hour.hour ? 1 : 0.45) + // The whole column, not just the bar, takes the hover. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .contentShape(Rectangle()) + .onHover { inside in + if inside { + hoveredHour = hour.hour + } else if hoveredHour == hour.hour { + hoveredHour = nil + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(hourSummary(hour, chart: chart)) + } + } + .frame(height: chartHeight, alignment: .bottom) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.tokenHairline).frame(height: 1) + } + .overlay(alignment: .topLeading) { + if let hoveredHour, chart.hours.indices.contains(hoveredHour) { + GeometryReader { proxy in + let column = (proxy.size.width - barSpacing * 23) / 24 + let center = CGFloat(hoveredHour) * (column + barSpacing) + column / 2 + let calloutWidth: CGFloat = 176 + hourCallout(chart.hours[hoveredHour], chart: chart) + .frame(width: calloutWidth) + .offset(x: min(max(center - calloutWidth / 2, 0), proxy.size.width - calloutWidth), y: -8) + } + .allowsHitTesting(false) + } + } + + HStack { + Text("00"); Spacer(); Text("06"); Spacer(); Text("12"); Spacer(); Text("18"); Spacer(); Text("23") + } + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + + if appState.todayAgentWork.unbucketedTokens > 0 { + Text(LFormat("另有 %@ 没有时间戳,未计入分时", TokenStepFormat.tokens(appState.todayAgentWork.unbucketedTokens, compact: true))) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } + } + + private var currentHour: Int? { + let now = Date() + guard appState.today.date == DateFormatter.tokenStepDay.string(from: now) else { return nil } + return TokenStepClock.calendar.component(.hour, from: now) + } + + private func color(_ source: String) -> Color { + source.isEmpty ? Color.tokenInk.opacity(0.3) : tokenToolColor(source) + } + + private func height(_ tokens: Int, max maxTotal: Int) -> CGFloat { + guard maxTotal > 0 else { return 0 } + return Swift.max(2, (chartHeight - 4) * CGFloat(tokens) / CGFloat(maxTotal)) + } + + private func hourSummary(_ hour: TodayOverviewModel.HourStack, chart: TodayOverviewModel.HourlyChart) -> String { + String(format: "%02d:00–%02d:00 · %@", hour.hour, (hour.hour + 1) % 24, TokenStepFormat.tokens(hour.total, compact: true)) + } + + private func hourCallout(_ hour: TodayOverviewModel.HourStack, chart: TodayOverviewModel.HourlyChart) -> some View { + let parts = zip(chart.sourceOrder, hour.parts).filter { $0.1 > 0 } + return VStack(alignment: .leading, spacing: 5) { + HStack { + Text(String(format: "%02d:00–%02d:00", hour.hour, (hour.hour + 1) % 24)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Text(hour.isFuture ? L("还没到") : TokenStepFormat.tokens(hour.total, compact: true)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + .monospacedDigit() + } + ForEach(Array(parts.enumerated()), id: \.offset) { _, part in + HStack(spacing: 6) { + Circle().fill(color(part.0)).frame(width: 7, height: 7) + Text(part.0.isEmpty ? L("其他") : part.0) + .lineLimit(1) + Spacer(minLength: 6) + Text(TokenStepFormat.tokens(part.1, compact: true)) + .monospacedDigit() + } + .font(.system(size: 11)) + .foregroundStyle(Color.tokenInk.opacity(0.8)) + } + if parts.isEmpty && !hour.isFuture { + Text(L("这一小时没有用量")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).stroke(Color.tokenHairlineStrong)) + .shadow(color: Color.black.opacity(0.12), radius: 8, y: 3) + } +} + +// MARK: - Clients and models + +struct TodayClientsCard: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + let rows = TodaySourceRows.make(tools: appState.today.tools, maxNamed: 4) + TokenCard { + VStack(alignment: .leading, spacing: 12) { + DashboardCardHeader(L("客户端")) { Text(L("今日")) } + if rows.isEmpty { + Text(L("等待下一次同步")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading) + } else { + ForEach(rows) { row in + HStack(spacing: 12) { + Circle() + .fill(row.color ?? Color.tokenInk.opacity(0.35)) + .frame(width: 8, height: 8) + Text(row.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.tokenTrack) + Capsule() + .fill(row.color ?? Color.tokenInk.opacity(0.35)) + .frame(width: max(4, proxy.size.width * min(max(row.percent, 0), 100) / 100)) + } + } + .frame(height: 6) + Text(TokenStepFormat.tokens(row.tokens, compact: true)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + .monospacedDigit() + .frame(width: 72, alignment: .trailing) + Text(TokenStepFormat.percent(row.percent)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + .frame(height: 28) + } + } + } + } + } +} + +struct TodayModelRankCard: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + let rows = TodayModelUsageRows.make(from: appState.today) + let visible = Array(rows.prefix(TodayModelUsageRows.maximumVisibleRows)) + TokenCard { + VStack(alignment: .leading, spacing: 12) { + DashboardCardHeader(L("模型排行")) { Text(L("今日 · 金额为估算")) } + if appState.today.totalTokens <= 0 { + Text(L("今日暂无使用记录")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading) + } else if visible.isEmpty { + Text(L("已有总量,等待模型明细同步")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading) + } else { + ForEach(Array(visible.enumerated()), id: \.element.id) { index, row in + HStack(spacing: 12) { + Text("\(index + 1)") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 16, alignment: .leading) + Text(row.model) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Text(TokenStepFormat.tokens(row.tokens, compact: true)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + .monospacedDigit() + .frame(width: 72, alignment: .trailing) + Text(row.estimatedCost.map { TokenStepFormat.money($0) } ?? "—") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 72, alignment: .trailing) + } + .frame(height: 28) + } + if rows.count > visible.count { + Text(LFormat("还有 %d 个模型", rows.count - visible.count)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + } + } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift new file mode 100644 index 0000000..58b11bf --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift @@ -0,0 +1,247 @@ +import Foundation + +/// View-independent content of the Today dashboard: the day-over-day +/// comparison, token composition, the recent-weeks heatmap, the goal streak, +/// and the hourly chart stacked by source. Free of SwiftUI so fixture checks +/// can exercise it. +enum TodayOverviewModel { + // MARK: - Compared with yesterday + + /// Today's tokens minus yesterday's up to the same hour, both counted from + /// hourly buckets. Nil when yesterday has no hourly data to compare with. + static func deltaVersusYesterday( + today: DailyAgentWork, + yesterday: DailyAgentWork?, + throughHour hour: Int + ) -> Int? { + guard let yesterday, !yesterday.hourlyBuckets.isEmpty else { return nil } + let limit = min(max(hour, 0), 23) + func sum(_ work: DailyAgentWork) -> Int { + work.hourlyBuckets.filter { $0.hour <= limit }.map(\.totalTokens).reduce(0, +) + } + return sum(today) - sum(yesterday) + } + + // MARK: - Token composition + + struct Composition: Equatable { + /// Input not served from cache. + var freshInput: Int + var cacheRead: Int + var output: Int + + var total: Int { freshInput + cacheRead + output } + var isEmpty: Bool { total <= 0 } + + func share(_ part: Int) -> Double { + total > 0 ? Double(part) / Double(total) : 0 + } + } + + /// Cached input is part of `inputTokens`, so it is split out rather than added. + static func composition(_ work: DailyAgentWork) -> Composition { + let cached = min(max(work.cachedInputTokens, 0), max(work.inputTokens, 0)) + return Composition( + freshInput: max(work.inputTokens - cached, 0), + cacheRead: cached, + output: max(work.outputTokens, 0) + ) + } + + // MARK: - Heatmap and streak + + struct HeatCell: Equatable, Identifiable { + var id: String { date } + var date: String + var tokens: Int + /// 0 (none) to 4 (goal reached). + var level: Int + var isToday: Bool + var isFuture: Bool + } + + /// Levels are relative to the daily goal, so reaching it always reads as the + /// darkest cell. + static func heatLevel(tokens: Int, goal: Int) -> Int { + guard tokens > 0 else { return 0 } + guard goal > 0 else { return 4 } + let ratio = Double(tokens) / Double(goal) + if ratio >= 1 { return 4 } + if ratio >= 0.5 { return 3 } + if ratio >= 0.2 { return 2 } + return 1 + } + + /// `weeks` full weeks, Monday first, column by column, ending with the week + /// that holds `today`. Days after today are marked future. + static func recentWeeks( + daily: [DailyUsage], + goal: Int, + today: Date, + weeks: Int, + calendar: Calendar + ) -> [HeatCell] { + let tokensByDay = Dictionary(daily.map { ($0.date, $0.totalTokens) }, uniquingKeysWith: max) + let todayStart = calendar.startOfDay(for: today) + // Monday = 0 … Sunday = 6, independent of the calendar's firstWeekday. + let weekdayIndex = (calendar.component(.weekday, from: todayStart) + 5) % 7 + guard weeks > 0, + let thisMonday = calendar.date(byAdding: .day, value: -weekdayIndex, to: todayStart), + let start = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: thisMonday) + else { return [] } + let formatter = dayFormatter(calendar) + return (0..<(weeks * 7)).compactMap { offset in + guard let day = calendar.date(byAdding: .day, value: offset, to: start) else { return nil } + let key = formatter.string(from: day) + let tokens = tokensByDay[key] ?? 0 + let isFuture = day > todayStart + return HeatCell( + date: key, + tokens: isFuture ? 0 : tokens, + level: isFuture ? 0 : heatLevel(tokens: tokens, goal: goal), + isToday: day == todayStart, + isFuture: isFuture + ) + } + } + + /// Consecutive days that reached the goal, ending today when today already + /// has, else ending yesterday (today is still in progress). + static func goalStreak(daily: [DailyUsage], goal: Int, today: Date, calendar: Calendar) -> Int { + guard goal > 0 else { return 0 } + let tokensByDay = Dictionary(daily.map { ($0.date, $0.totalTokens) }, uniquingKeysWith: max) + let formatter = dayFormatter(calendar) + var day = calendar.startOfDay(for: today) + if (tokensByDay[formatter.string(from: day)] ?? 0) < goal, + let yesterday = calendar.date(byAdding: .day, value: -1, to: day) { + day = yesterday + } + var streak = 0 + while (tokensByDay[formatter.string(from: day)] ?? 0) >= goal { + streak += 1 + guard let previous = calendar.date(byAdding: .day, value: -1, to: day) else { break } + day = previous + } + return streak + } + + /// The longest run of consecutive goal days anywhere in `daily`. + static func longestGoalStreak(daily: [DailyUsage], goal: Int, calendar: Calendar) -> Int { + guard goal > 0 else { return 0 } + let formatter = dayFormatter(calendar) + let goalDays = daily + .filter { $0.totalTokens >= goal } + .compactMap { formatter.date(from: $0.date) } + .map { calendar.startOfDay(for: $0) } + .sorted() + var longest = 0 + var current = 0 + var previous: Date? + for day in goalDays { + if let previous, let next = calendar.date(byAdding: .day, value: 1, to: previous), next == day { + current += 1 + } else if previous != day { + current = 1 + } + longest = max(longest, current) + previous = day + } + return longest + } + + /// Tokens per hour of day summed over the given days' hourly buckets. + static func hourOfDayTotals(_ works: [DailyAgentWork]) -> [Int] { + var totals = Array(repeating: 0, count: 24) + for work in works { + for bucket in work.hourlyBuckets where (0..<24).contains(bucket.hour) { + totals[bucket.hour] += bucket.totalTokens + } + } + return totals + } + + /// The busiest contiguous 4-hour span in `totals`, as its start hour. + static func busiestSpan(_ totals: [Int], length: Int = 4) -> Int? { + guard totals.count == 24, totals.contains(where: { $0 > 0 }) else { return nil } + var best = 0 + var bestSum = -1 + for start in 0...(24 - length) { + let sum = totals[start..<(start + length)].reduce(0, +) + if sum > bestSum { + bestSum = sum + best = start + } + } + return best + } + + // MARK: - Hourly chart + + struct HourStack: Equatable, Identifiable { + var id: Int { hour } + var hour: Int + /// Tokens per source, in `sourceOrder`. + var parts: [Int] + var isFuture: Bool + + var total: Int { parts.reduce(0, +) } + } + + struct HourlyChart: Equatable { + /// Sources by today's total, largest first; the stack order bottom-up. + var sourceOrder: [String] + var hours: [HourStack] + var peakHour: Int? + var peakTokens: Int + + var maxTotal: Int { hours.map(\.total).max() ?? 0 } + } + + /// Up to `maxSources` named sources; the rest fold into the last one's slot + /// as "other" (an empty string). + static func hourlyChart(work: DailyAgentWork, currentHour: Int?, maxSources: Int = 3) -> HourlyChart { + var totals: [String: Int] = [:] + for bucket in work.hourlyBuckets { + for source in bucket.sources where source.tokens > 0 { + totals[source.source, default: 0] += source.tokens + } + } + let ranked = totals.sorted { + $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key + }.map(\.key) + var order = Array(ranked.prefix(maxSources)) + let folded = ranked.count > maxSources + if folded { order.append("") } + + var hours: [HourStack] = [] + for hour in 0..<24 { + let bucket = work.hourlyBuckets.first { $0.hour == hour } + var parts = Array(repeating: 0, count: order.count) + for source in bucket?.sources ?? [] where source.tokens > 0 { + if let index = order.firstIndex(of: source.source) { + parts[index] += source.tokens + } else if folded { + parts[order.count - 1] += source.tokens + } + } + let isFuture = currentHour.map { hour > $0 } ?? false + hours.append(HourStack(hour: hour, parts: parts, isFuture: isFuture)) + } + let peak = hours.max { $0.total < $1.total } + return HourlyChart( + sourceOrder: order, + hours: hours, + peakHour: (peak?.total ?? 0) > 0 ? peak?.hour : nil, + peakTokens: peak?.total ?? 0 + ) + } + + private static func dayFormatter(_ calendar: Calendar) -> DateFormatter { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift index 361111f..e28b995 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift @@ -1,148 +1,485 @@ +import AppKit import SwiftUI struct HistoryView: View { @EnvironmentObject private var appState: AppState + /// Screenshots pass a row limit and show every row up to it; the window + /// shows a few rows with an expand button. var historyLimit: Int? = nil var body: some View { - VStack(spacing: 22) { - TokenCard { - VStack(alignment: .leading, spacing: 22) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 5) { - Text(L("近 8 个月活动墙")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("颜色越深,用量越高;描边是今天")) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) + VStack(spacing: 16) { + HistorySummaryTiles() + HistoryHeatmapCard() + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + HistoryTrendCard() + .frame(minWidth: 520) + HistoryDistributionCard() + .frame(width: 340) + } + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 16) { + HistoryTrendCard() + HistoryDistributionCard() + } + } + HistoryDetailCard(historyLimit: historyLimit) + } + } +} + +// MARK: - Summary + +private struct HistorySummaryTiles: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + let snapshot = appState.snapshot + let goal = appState.settings.dailyGoalTokens + let calendar = TokenStepClock.calendar + let goalDays = snapshot.daily.filter { $0.totalTokens >= goal }.count + let longest = TodayOverviewModel.longestGoalStreak(daily: snapshot.daily, goal: goal, calendar: calendar) + let current = TodayOverviewModel.goalStreak(daily: snapshot.daily, goal: goal, today: Date(), calendar: calendar) + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { + TodayMetricTile( + label: L("累计 Token"), + value: TokenStepFormat.tokens(snapshot.totals.tokens, compact: true), + note: firstDay.map { LFormat("自 %@ 起", $0) } + ) + TodayMetricTile( + label: L("估算金额"), + value: TokenStepFormat.money(snapshot.totals.cost), + note: L("按官方单价估算") + ) + TodayMetricTile( + label: L("活跃天数"), + value: LFormat("%d 天", snapshot.totals.activeDays), + note: LFormat("达标 %d 天", goalDays) + ) + TodayMetricTile( + label: L("最长连续达标"), + value: LFormat("%d 天", longest), + note: LFormat("当前连续 %d 天", current) + ) + } + } + + private var firstDay: String? { + appState.snapshot.daily.first { $0.totalTokens > 0 }?.date + } +} + +// MARK: - Heatmap + +private struct HistoryHeatmapCard: View { + @EnvironmentObject private var appState: AppState + + private let cell: CGFloat = 12 + private let gap: CGFloat = 3 + private let hourPanelWidth: CGFloat = 250 + + var body: some View { + let recentWorks = Array(appState.snapshot.agentWork.suffix(30)) + let hourTotals = TodayOverviewModel.hourOfDayTotals(recentWorks) + TokenCard { + HStack(alignment: .top, spacing: 28) { + VStack(alignment: .leading, spacing: 10) { + DashboardCardHeader(L("消耗热力图")) { Text(L("按天 · 描边为今天")) } + GeometryReader { proxy in + heatmap(width: proxy.size.width) + } + .frame(height: 16 + 7 * cell + 6 * gap) + footer + } + .frame(maxWidth: .infinity) + + Rectangle().fill(Color.tokenHairline).frame(width: 1) + + hourPanel(hourTotals) + .frame(width: hourPanelWidth) + } + } + } + + private func heatmap(width: CGFloat) -> some View { + let labelWidth: CGFloat = 18 + let weeks = max(8, Int((width - labelWidth - 8 + gap) / (cell + gap))) + let cells = TodayOverviewModel.recentWeeks( + daily: appState.snapshot.daily, + goal: appState.settings.dailyGoalTokens, + today: Date(), + weeks: weeks, + calendar: TokenStepClock.calendar + ) + let columns = stride(from: 0, to: cells.count, by: 7).map { Array(cells[$0.. 0 ? columns[index - 1] : nil)) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .fixedSize() + .frame(width: cell, height: 13, alignment: .leading) + ForEach(column) { item in + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(item.isFuture ? Color.clear : DashboardHeatColors.color(level: item.level)) + .overlay { + if item.isToday { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(Color.tokenInk, lineWidth: 1.2) + } + } + .frame(width: cell, height: cell) + .help("\(item.date) · \(TokenStepFormat.tokens(item.tokens, compact: true))") } - Spacer() - Text(LFormat("%d 个活跃日", appState.snapshot.totals.activeDays)) - .font(.callout.weight(.bold)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.tokenMint.opacity(0.28), in: Capsule()) } + } + } + } + } + + /// The month's number above the first column that starts in it. + private func monthLabel(column: [TodayOverviewModel.HeatCell], previous: [TodayOverviewModel.HeatCell]?) -> String { + guard let first = column.first?.date, first.count >= 7 else { return "" } + let month = String(first.dropFirst(5).prefix(2)) + guard let prior = previous?.first?.date, prior.count >= 7 else { return "" } + guard String(prior.dropFirst(5).prefix(2)) != month, let number = Int(month) else { return "" } + return LFormat("%d月", number) + } - ContributionWallView( - rows: Array(appState.snapshot.daily.suffix(238)), - goal: appState.settings.dailyGoalTokens - ) + private var footer: some View { + let goal = appState.settings.dailyGoalTokens + let daily = appState.snapshot.daily + let peak = daily.map(\.totalTokens).max() ?? 0 + return HStack { + Text(LFormat( + "活跃 %d 天 · 达标 %d 天 · 单日最高 %@", + appState.snapshot.totals.activeDays, + daily.filter { $0.totalTokens >= goal }.count, + TokenStepFormat.tokens(peak, compact: true) + )) + .monospacedDigit() + Spacer() + HStack(spacing: 3) { + Text(L("少")) + ForEach(0..<5, id: \.self) { level in + RoundedRectangle(cornerRadius: 2.5) + .fill(DashboardHeatColors.color(level: level)) + .frame(width: 10, height: 10) } + Text(L("多")) } - .overlay(alignment: .topTrailing) { - if TokenStepThemeRuntime.isVoyage { - VoyageRouteWatermark() - .frame(width: 260, height: 116) - .padding(18) - .opacity(0.46) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarEventHorizonEmblem() - .frame(width: 260, height: 108) - .padding(18) - .opacity(0.38) + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + private func hourPanel(_ totals: [Int]) -> some View { + let maxValue = max(totals.max() ?? 0, 1) + return VStack(alignment: .leading, spacing: 10) { + DashboardCardHeader(L("常用时段")) { Text(L("近 30 天")) } + HStack(alignment: .bottom, spacing: 3) { + ForEach(0..<24, id: \.self) { hour in + let ratio = Double(totals[hour]) / Double(maxValue) + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(DashboardHeatColors.color(level: totals[hour] == 0 ? 0 : min(4, 1 + Int(ratio * 3.99)))) + .frame(maxWidth: .infinity) + .frame(height: max(4, 72 * ratio)) + .help(String(format: "%02d:00 · %@", hour, TokenStepFormat.tokens(totals[hour], compact: true))) } } + .frame(height: 72, alignment: .bottom) + HStack { + Text("00"); Spacer(); Text("12"); Spacer(); Text("23") + } + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + if let start = TodayOverviewModel.busiestSpan(totals) { + Text(LFormat("最常在 %02d:00–%02d:00 用 Agent", start, start + 4)) + .font(.system(size: 12)) + .foregroundStyle(Color.tokenInk.opacity(0.75)) + } + } + } +} - StatsView() +// MARK: - Trend and distribution - TokenCard { - VStack(alignment: .leading, spacing: 18) { - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(L("全部明细")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(historySummaryText) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) +private struct HistoryTrendCard: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + TokenCard { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("近 30 天")) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Text(LFormat("日均 %@", TokenStepFormat.tokens(appState.monthAverage, compact: true))) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + Spacer() + TokenToolLegend(tools: uniqueToolNames(in: Array(appState.snapshot.daily.suffix(30))), showsGoalLine: true) + } + StackedActivityBarsView(rows: appState.snapshot.daily, goal: appState.settings.dailyGoalTokens) + .frame(height: 120) + } + } + } +} + +private struct HistoryDistributionCard: View { + @EnvironmentObject private var appState: AppState + @State private var byModel = false + + var body: some View { + let rows = byModel ? modelRows : clientRows + TokenCard { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(L("累计分布")) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Spacer() + // Drawn rather than a system Picker, which screenshots + // cannot render. + HStack(spacing: 2) { + segment(L("客户端"), selected: !byModel) { byModel = false } + segment(L("模型"), selected: byModel) { byModel = true } + } + .padding(2) + .background(Color.tokenTrack.opacity(0.7), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + if rows.isEmpty { + Text(L("等待下一次同步")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80, alignment: .leading) + } else { + GeometryReader { proxy in + HStack(spacing: 2) { + ForEach(rows) { row in + Rectangle() + .fill(row.color) + .frame(width: max(2, (proxy.size.width - CGFloat(rows.count - 1) * 2) * row.percent / 100)) + } } - Spacer() - TokenToolLegend(tools: historyTools) } - - LazyVStack(spacing: 0) { - header - ForEach(historyRows) { row in - HistoryRow(row: row, goal: appState.settings.dailyGoalTokens) + .frame(height: 10) + .clipShape(Capsule()) + ForEach(rows.prefix(5)) { row in + HStack(spacing: 10) { + Circle().fill(row.color).frame(width: 8, height: 8) + Text(row.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Text(TokenStepFormat.tokens(row.tokens, compact: true)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + .monospacedDigit() + Text(TokenStepFormat.percent(row.percent)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) } + .frame(height: 24) } } } } } - private var historyRows: [DailyUsage] { - let rows = appState.visibleHistoryRows - guard let historyLimit else { return rows } - return Array(rows.prefix(historyLimit)) + private func segment(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? Color.tokenInk : Color.tokenInk.opacity(0.6)) + .padding(.horizontal, 10) + .frame(height: 24) + .background(selected ? Color.tokenSurface : Color.clear, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) } - private var historySummaryText: String { - if let historyLimit { - return LFormat("最近 %d 天,适合保存为截图", min(historyLimit, historyRows.count)) - } - return LFormat("%d 条记录,向下滚动查看完整历史", appState.visibleHistoryRows.count) + private struct Row: Identifiable { + var id: String { name } + var name: String + var tokens: Int + var percent: Double + var color: Color + } + + private var clientRows: [Row] { + appState.snapshot.tools + .filter { $0.tokens > 0 } + .sorted { $0.tokens > $1.tokens } + .map { Row(name: $0.tool, tokens: $0.tokens, percent: $0.percentValue, color: tokenToolColor($0.tool)) } + } + + private var modelRows: [Row] { + let palette = TokenStepThemeRuntime.palette + let colors = [palette.activity4.color, palette.activity3.color, palette.activity2.color, palette.activity1.color, Color.tokenInk.opacity(0.3)] + return appState.snapshot.models + .filter { $0.tokens > 0 } + .sorted { $0.tokens > $1.tokens } + .prefix(5) + .enumerated() + .map { index, model in + Row(name: model.model, tokens: model.tokens, percent: model.percentValue, color: colors[min(index, colors.count - 1)]) + } } +} + +// MARK: - Daily detail + +private struct HistoryDetailCard: View { + @EnvironmentObject private var appState: AppState + var historyLimit: Int? + @State private var showsAll = false + + private let collapsedCount = 7 + + var body: some View { + let all = appState.visibleHistoryRows + let rows: [DailyUsage] = { + if let historyLimit { return Array(all.prefix(historyLimit)) } + return showsAll ? all : Array(all.prefix(collapsedCount)) + }() + TokenCard { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("每日明细")) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Text(LFormat("%d 天", all.count)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Spacer() + if historyLimit == nil, !all.isEmpty { + Button(L("导出 CSV")) { exportCSV(all) } + .controlSize(.small) + } + } + .padding(.bottom, 10) + + columnHeader + ForEach(rows) { row in + HistoryDetailRow(row: row, goal: appState.settings.dailyGoalTokens, isToday: row.date == appState.today.date) + } - private var historyTools: [String] { - uniqueToolNames(in: historyRows) + if historyLimit == nil, all.count > collapsedCount { + Button { + withAnimation(.easeOut(duration: 0.2)) { showsAll.toggle() } + } label: { + Text(showsAll ? L("收起") : LFormat("展开全部 %d 天", all.count)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.tokenGreenDark) + .frame(maxWidth: .infinity) + .padding(.top, 12) + } + .buttonStyle(.plain) + } + } + } } - private var header: some View { - HStack(spacing: 16) { - Text(L("日期")).frame(width: 118, alignment: .leading) - Text(L("Token 消耗")).frame(width: 150, alignment: .leading) - Text(L("消耗金额")).frame(width: 126, alignment: .leading) - Text(L("主力工具")).frame(maxWidth: .infinity, alignment: .leading) + private var columnHeader: some View { + HStack(spacing: 12) { + Text(L("日期")).frame(width: 120, alignment: .leading) + Text("Token").frame(width: 120, alignment: .trailing) + Text(L("估算金额")).frame(width: 110, alignment: .trailing) + Text(L("主力客户端")).frame(maxWidth: .infinity, alignment: .leading).padding(.leading, 24) + Text(L("达标")).frame(width: 90, alignment: .trailing) } - .font(.caption.weight(.heavy)) + .font(.system(size: 11)) .foregroundStyle(.secondary) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.tokenTrack.opacity(0.62), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.vertical, 8) + .overlay(alignment: .bottom) { Rectangle().fill(Color.tokenHairline).frame(height: 1) } + } + + private func exportCSV(_ rows: [DailyUsage]) { + let panel = NSSavePanel() + panel.nameFieldStringValue = "TokenStep-history.csv" + panel.allowedContentTypes = [.commaSeparatedText] + guard panel.runModal() == .OK, let url = panel.url else { return } + var lines = ["date,total_tokens,estimated_cost_usd,top_client"] + for row in rows.reversed() { + let top = row.tools.max { $0.value < $1.value }?.key ?? "" + let client = top.contains(",") ? "\"\(top)\"" : top + lines.append("\(row.date),\(row.totalTokens),\(String(format: "%.2f", row.cost)),\(client)") + } + do { + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } catch { + appState.lastError = error.localizedDescription + } } } -private struct HistoryRow: View { +private struct HistoryDetailRow: View { var row: DailyUsage var goal: Int + var isToday: Bool var body: some View { - HStack(spacing: 16) { - Text(row.date) - .frame(width: 118, alignment: .leading) + HStack(spacing: 12) { + Text(isToday ? LFormat("%@ 今天", row.date) : row.date) + .font(.system(size: 12, design: .monospaced)) .foregroundStyle(Color.tokenInk.opacity(0.72)) - Text(TokenStepFormat.tokens(row.totalTokens)) - .fontWeight(.heavy) + .frame(width: 120, alignment: .leading) + Text(TokenStepFormat.tokens(row.totalTokens, compact: true)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.tokenInk) - .frame(width: 150, alignment: .leading) + .monospacedDigit() + .frame(width: 120, alignment: .trailing) Text(TokenStepFormat.money(row.cost)) - .frame(width: 126, alignment: .leading) + .font(.system(size: 13)) .foregroundStyle(Color.tokenInk.opacity(0.72)) + .monospacedDigit() + .frame(width: 110, alignment: .trailing) HStack(spacing: 8) { - Circle() - .fill(tokenToolColor(dominantTool)) - .frame(width: 8, height: 8) - Text(dominantTool) - .lineLimit(1) + Circle().fill(tokenToolColor(dominantTool)).frame(width: 8, height: 8) + Text(dominantTool).lineLimit(1) } + .font(.system(size: 13)) + .foregroundStyle(Color.tokenInk.opacity(0.8)) .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(Color.tokenInk.opacity(0.72)) - } - .font(.callout.weight(.semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(alignment: .bottom) { - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) + .padding(.leading, 24) + Text(goalText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(row.totalTokens >= goal && goal > 0 ? Color.tokenGreenDark : Color.secondary) + .monospacedDigit() + .frame(width: 90, alignment: .trailing) } + .frame(height: 38) + .overlay(alignment: .bottom) { Rectangle().fill(Color.tokenDivider).frame(height: 1) } } private var dominantTool: String { row.tools.max(by: { $0.value < $1.value })?.key ?? L("无") } + + private var goalText: String { + guard goal > 0, row.totalTokens >= goal else { return L("未达标") } + return LFormat("%d 圈", row.totalTokens / goal) + } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift index 17d0f6f..82fc9e2 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift @@ -230,7 +230,11 @@ struct MainWindowView: View { private var detailView: some View { switch navigation.section { case .today: - TodayView() + TodayView(openHistory: { + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + navigation.select(.history) + } + }) case .history: HistoryView() case .privacy: diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsAppearancePane.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsAppearancePane.swift new file mode 100644 index 0000000..f60b4c8 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsAppearancePane.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// Theme packs and their options, split out of the General pane. +struct SettingsAppearancePane: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + SettingsSectionCard( + title: L("主题皮肤包"), + subtitle: L("经典、奥德赛与引力边界可随时切换") + ) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ForEach(TokenStepThemePack.allCases) { pack in + ThemePackOptionButton( + pack: pack, + selected: appState.settings.themePack == pack, + classicTheme: appState.settings.classicTheme, + odysseyChapter: appState.settings.odysseyChapter + ) { + withAnimation(.easeOut(duration: 0.18)) { + appState.setThemePack(pack) + } + } + } + } + + Rectangle() + .fill(Color.tokenDivider) + .frame(height: 1) + + if appState.settings.themePack == .odyssey { + VStack(alignment: .leading, spacing: 9) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(L("奥德赛视觉篇章")) + .font(.callout.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Text(L("导演剪辑会为不同界面自动分配冷雾、火海与灰烬。")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + Spacer() + Text(appState.settings.odysseyChapter.title) + .font(.caption.weight(.heavy)) + .foregroundStyle(Color.tokenGreenDark) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.tokenGreen.opacity(0.12), in: Capsule()) + .overlay(Capsule().stroke(Color.tokenHairlineStrong)) + } + + HStack(spacing: 8) { + ForEach(TokenStepOdysseyChapter.allCases) { chapter in + OdysseyChapterButton( + chapter: chapter, + selected: appState.settings.odysseyChapter == chapter + ) { + withAnimation(.easeOut(duration: 0.18)) { + appState.setOdysseyChapter(chapter) + } + } + } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } else if appState.settings.themePack == .interstellar { + HStack(spacing: 14) { + InterstellarEventHorizonEmblem() + .frame(width: 126, height: 58) + + VStack(alignment: .leading, spacing: 3) { + Text(L("引力边界 · 近距事件视界")) + .font(.callout.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Text(L("巨大黑洞、象牙白吸积盘与克制电影色;低亮与减少动态时自动静止。")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Text(L("原创动态主题")) + .font(.caption.weight(.heavy)) + .foregroundStyle(Color.tokenGreenDark) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.tokenGreen.opacity(0.12), in: Capsule()) + .overlay(Capsule().stroke(Color.tokenHairlineStrong)) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(L("经典主题色")) + .font(.callout.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Text(L("原版 TokenStep 的五种明亮配色")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 8) { + ForEach(TokenStepTheme.classicCases) { theme in + Button { + appState.setClassicTheme(theme) + } label: { + Circle() + .fill( + LinearGradient( + colors: [theme.palette.accentSoft.color, theme.palette.accent.color], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 27, height: 27) + .overlay( + Circle().stroke( + theme == appState.settings.classicTheme ? Color.tokenInk : Color.clear, + lineWidth: 2 + ) + ) + .overlay { + if theme == appState.settings.classicTheme { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .black)) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + .help(theme.title) + } + } + } + .padding(.vertical, 7) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift index ee0f28a..629df9a 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift @@ -134,58 +134,43 @@ struct SettingsSectionCard: View { self.content = content() } + /// A grouped section as in System Settings: the title and note sit above + /// a plain rounded group that holds the rows. var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 8) { - Text(title) - .font(.system(size: 16, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - if let badge { - SettingsBadge(text: badge, style: badgeStyle) + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .center, spacing: 8) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.tokenInk.opacity(0.78)) + if let badge { + SettingsBadge(text: badge, style: badgeStyle) + } + Spacer(minLength: 0) + } + if let subtitle { + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - Spacer(minLength: 0) - } - if let subtitle { - Text(subtitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) } - content - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(sectionBackground, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(sectionBorder) - ) - } + .padding(.horizontal, 4) - private var sectionBackground: Color { - if TokenStepThemeRuntime.isCinematic { - return Color.tokenSurface - } - switch tint ?? badgeStyle { - case .l2: return Color(red: 0.97, green: 0.98, blue: 0.99) - case .l3: return Color(red: 0.98, green: 0.97, blue: 0.99) - default: return Color.tokenSurface - } - } - - private var sectionBorder: Color { - if TokenStepThemeRuntime.isCinematic { - switch tint ?? badgeStyle { - case .l2: return Color.tokenGreen.opacity(0.30) - case .l3: return Color.tokenGreenDark.opacity(0.34) - default: return Color.tokenHairline + // One stack so a multi-part body stays in one group. + VStack(alignment: .leading, spacing: 10) { + content } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.tokenHairline) + ) } - switch tint ?? badgeStyle { - case .l2: return Color(red: 0.81, green: 0.84, blue: 0.92) - case .l3: return Color(red: 0.87, green: 0.84, blue: 0.91) - default: return Color.black.opacity(0.06) - } + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -197,20 +182,22 @@ struct SettingsPickerChip: View { var body: some View { Button(action: action) { Text(title) - .font(.caption.weight(.heavy)) - .foregroundStyle(selected ? Color.tokenActionText : Color.tokenInk.opacity(0.68)) + .font(.system(size: 12, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? Color.tokenGreenDark : Color.tokenInk.opacity(0.72)) .padding(.horizontal, 10) .frame(height: 28) .background( - selected ? Color.tokenGreenDark : Color.tokenTrack.opacity(0.45), - in: RoundedRectangle(cornerRadius: 8, style: .continuous) + selected ? Color.tokenGreen.opacity(0.14) : Color.tokenSurface, + in: RoundedRectangle(cornerRadius: 7, style: .continuous) ) .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(selected ? Color.tokenHairlineStrong : Color.tokenHairline) + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(selected ? Color.tokenGreen.opacity(0.7) : Color.tokenHairline) ) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) } } @@ -615,7 +602,7 @@ struct TokenStepSwitchToggleStyle: ToggleStyle { : Color.tokenTrack.opacity(TokenStepThemeRuntime.isCinematic ? 0.82 : 0.72) ) Circle() - .fill(configuration.isOn ? Color.tokenInk : Color.tokenInk.opacity(0.62)) + .fill(Color.white) .padding(2) .shadow(color: Color.black.opacity(0.24), radius: 2, x: 0, y: 1) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift index 01a948f..56011cd 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift @@ -85,18 +85,27 @@ struct SettingsTokenRankCard: View { @EnvironmentObject private var appState: AppState var body: some View { - SettingsCard(title: L("Agent 消耗榜"), symbol: "list.number", height: 282) { + SettingsSectionCard( + title: L("Token Rank 排行榜"), + subtitle: L("只读取公开榜单和本机 Token Rank 账号,TokenStep 不上传任何用量") + ) { VStack(alignment: .leading, spacing: 13) { - Picker("", selection: Binding( - get: { appState.settings.agentWorkRankVisibility }, - set: { appState.setAgentWorkRankVisibility($0) } - )) { - Text(L("自动")).tag(AgentWorkRankVisibility.automatic) - Text(L("显示")).tag(AgentWorkRankVisibility.visible) - Text(L("隐藏")).tag(AgentWorkRankVisibility.hidden) + HStack { + Text(L("在面板里显示")) + .font(.system(size: 13, weight: .medium)) + Spacer() + HStack(spacing: 5) { + ForEach([ + (L("自动"), AgentWorkRankVisibility.automatic), + (L("显示"), AgentWorkRankVisibility.visible), + (L("隐藏"), AgentWorkRankVisibility.hidden) + ], id: \.0) { title, value in + SettingsPickerChip(title: title, selected: appState.settings.agentWorkRankVisibility == value) { + appState.setAgentWorkRankVisibility(value) + } + } + } } - .labelsHidden() - .pickerStyle(.segmented) StatusLine( symbol: statusSymbol, @@ -135,8 +144,6 @@ struct SettingsTokenRankCard: View { .buttonStyle(SettingsSecondaryButtonStyle()) } } - - Spacer(minLength: 0) } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift index 14550e8..b068dac 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift @@ -4,8 +4,8 @@ struct SettingsGeneralPane: View { @EnvironmentObject private var appState: AppState var body: some View { - VStack(spacing: 12) { - HStack(alignment: .top, spacing: 12) { + VStack(spacing: 16) { + VStack(spacing: 16) { SettingsSectionCard(title: L("每日目标"), subtitle: L("一圈等于多少 Token")) { HStack(spacing: 10) { GoalStepButton(symbol: "minus") { @@ -36,26 +36,18 @@ struct SettingsGeneralPane: View { SettingsSectionCard( title: L("历史范围"), - subtitle: L("采集与图表回溯天数(7–365)"), - badge: L("本版新增入口"), - badgeStyle: .ok + subtitle: L("采集与图表回溯天数(7–365)") ) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 5) { - ForEach([90, 180, 365], id: \.self) { days in + ForEach([30, 90, 180, 365], id: \.self) { days in SettingsPickerChip( - title: "\(days)", + title: LFormat("%d 天", days), selected: appState.settings.historyDays == days ) { appState.setHistoryDays(days) } } - SettingsPickerChip( - title: "30", - selected: appState.settings.historyDays == 30 - ) { - appState.setHistoryDays(30) - } } Text(L("调大会增加首次采集耗时。")) .font(.caption.weight(.semibold)) @@ -64,142 +56,7 @@ struct SettingsGeneralPane: View { } } - SettingsSectionCard( - title: L("主题皮肤包"), - subtitle: L("经典、奥德赛与引力边界可随时切换") - ) { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - ForEach(TokenStepThemePack.allCases) { pack in - ThemePackOptionButton( - pack: pack, - selected: appState.settings.themePack == pack, - classicTheme: appState.settings.classicTheme, - odysseyChapter: appState.settings.odysseyChapter - ) { - withAnimation(.easeOut(duration: 0.18)) { - appState.setThemePack(pack) - } - } - } - } - - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) - - if appState.settings.themePack == .odyssey { - VStack(alignment: .leading, spacing: 9) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(L("奥德赛视觉篇章")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("导演剪辑会为不同界面自动分配冷雾、火海与灰烬。")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - Spacer() - Text(appState.settings.odysseyChapter.title) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.tokenGreen.opacity(0.12), in: Capsule()) - .overlay(Capsule().stroke(Color.tokenHairlineStrong)) - } - - HStack(spacing: 8) { - ForEach(TokenStepOdysseyChapter.allCases) { chapter in - OdysseyChapterButton( - chapter: chapter, - selected: appState.settings.odysseyChapter == chapter - ) { - withAnimation(.easeOut(duration: 0.18)) { - appState.setOdysseyChapter(chapter) - } - } - } - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } else if appState.settings.themePack == .interstellar { - HStack(spacing: 14) { - InterstellarEventHorizonEmblem() - .frame(width: 126, height: 58) - - VStack(alignment: .leading, spacing: 3) { - Text(L("引力边界 · 近距事件视界")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("巨大黑洞、象牙白吸积盘与克制电影色;低亮与减少动态时自动静止。")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - Text(L("原创动态主题")) - .font(.caption.weight(.heavy)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(Color.tokenGreen.opacity(0.12), in: Capsule()) - .overlay(Capsule().stroke(Color.tokenHairlineStrong)) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } else { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(L("经典主题色")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("原版 TokenStep 的五种明亮配色")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - Spacer() - HStack(spacing: 8) { - ForEach(TokenStepTheme.classicCases) { theme in - Button { - appState.setClassicTheme(theme) - } label: { - Circle() - .fill( - LinearGradient( - colors: [theme.palette.accentSoft.color, theme.palette.accent.color], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 27, height: 27) - .overlay( - Circle().stroke( - theme == appState.settings.classicTheme ? Color.tokenInk : Color.clear, - lineWidth: 2 - ) - ) - .overlay { - if theme == appState.settings.classicTheme { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .black)) - .foregroundStyle(.white) - } - } - } - .buttonStyle(.plain) - .help(theme.title) - } - } - } - .padding(.vertical, 7) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - } - - HStack(alignment: .top, spacing: 12) { + VStack(spacing: 16) { SettingsSectionCard(title: L("语言"), subtitle: L("选择 TokenStep 的显示语言")) { HStack { Text(L("语言")) @@ -246,7 +103,7 @@ struct SettingsGeneralPane: View { } } - HStack(alignment: .top, spacing: 12) { + VStack(spacing: 16) { SettingsSectionCard(title: L("刷新"), subtitle: L("后台节奏 · 前台打开时总会即时刷")) { VStack(alignment: .leading, spacing: 10) { HStack { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsQuotaProvidersCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsQuotaProvidersCard.swift index 3972493..e666898 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsQuotaProvidersCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsQuotaProvidersCard.swift @@ -4,7 +4,7 @@ struct SettingsQuotaProvidersPane: View { @EnvironmentObject private var appState: AppState var body: some View { - VStack(spacing: 12) { + VStack(spacing: 16) { SettingsSectionCard( title: L("订阅额度提供商"), subtitle: L("本机登录态或手动填写 · 密钥只进钥匙串") @@ -20,7 +20,7 @@ struct SettingsQuotaProvidersPane: View { .padding(.top, 8) } - HStack(alignment: .top, spacing: 12) { + VStack(spacing: 16) { SettingsSectionCard( title: L("预警"), subtitle: L("额度紧张时的提示强度") @@ -47,7 +47,6 @@ struct SettingsQuotaProvidersPane: View { } } - SettingsTokenRankCard() } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift index 579615e..d85fa3c 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift @@ -2,17 +2,51 @@ import AppKit import SwiftUI enum SettingsPane: String, CaseIterable, Identifiable { + case general + case appearance case dataSources case quotas - case general + case tokenRank var id: String { rawValue } var title: String { switch self { + case .general: L("通用") + case .appearance: L("外观") case .dataSources: L("数据源") case .quotas: L("额度") - case .general: L("通用") + case .tokenRank: "Token Rank" + } + } + + var subtitle: String { + switch self { + case .general: L("改动立即生效,不需要保存。") + case .appearance: L("主题和配色,随时切换。") + case .dataSources: L("只读取本机日志里的用量数字,不读对话内容。") + case .quotas: L("读取订阅的剩余额度,显示在面板顶部。密钥只存进钥匙串。") + case .tokenRank: L("在面板里显示你在 Token Rank 公开榜单上的排名。") + } + } + + var symbol: String { + switch self { + case .general: "gearshape.fill" + case .appearance: "paintpalette.fill" + case .dataSources: "externaldrive.fill" + case .quotas: "gauge.with.dots.needle.50percent" + case .tokenRank: "trophy.fill" + } + } + + var tileColor: Color { + switch self { + case .general: Color(red: 0.54, green: 0.54, blue: 0.58) + case .appearance: Color(red: 0.48, green: 0.36, blue: 1.0) + case .dataSources: Color(red: 0.23, green: 0.44, blue: 0.85) + case .quotas: Color(red: 0.88, green: 0.54, blue: 0.18) + case .tokenRank: Color(red: 0.18, green: 0.62, blue: 0.36) } } } @@ -21,11 +55,15 @@ struct SettingsView: View { @EnvironmentObject private var appState: AppState @Environment(\.isScreenshotRendering) private var isScreenshotRendering var captureMode: Bool + /// In capture mode, render only this pane beside the sidebar; nil stacks + /// every pane (the full settings screenshot). + var capturePane: SettingsPane? @State private var pane: SettingsPane - init(captureMode: Bool = false, initialPane: SettingsPane = .dataSources) { + init(captureMode: Bool = false, initialPane: SettingsPane = .general, capturePane: SettingsPane? = nil) { self.captureMode = captureMode - _pane = State(initialValue: initialPane) + self.capturePane = capturePane + _pane = State(initialValue: capturePane ?? initialPane) } var body: some View { @@ -41,24 +79,21 @@ struct SettingsView: View { } private var windowBody: some View { - ZStack { - TokenStepBackdrop(role: .settings) - VStack(spacing: 0) { - header - .padding(.top, 28) - .padding(.horizontal, 22) - .padding(.bottom, 14) + HStack(spacing: 0) { + sidebar + ZStack { + TokenStepBackdrop(role: .settings) ScrollView(.vertical, showsIndicators: false) { - paneContent - .padding(.horizontal, 22) - .padding(.bottom, 16) + page(pane) + .padding(.horizontal, 36) + .padding(.top, 34) + .padding(.bottom, 28) + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity) } - footer - .padding(.horizontal, 22) - .padding(.vertical, 14) } } - .frame(width: 920, height: 760) + .frame(minWidth: 920, minHeight: 700) .overlay { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) @@ -69,88 +104,127 @@ struct SettingsView: View { } private var captureBody: some View { - ZStack { - TokenStepBackdrop(role: .settings) - VStack(alignment: .leading, spacing: 18) { - header - SettingsDataSourcesPane(openQuotaTab: {}) - SettingsQuotaProvidersPane() - SettingsGeneralPane() - footer + HStack(alignment: .top, spacing: 0) { + sidebar + ZStack(alignment: .top) { + TokenStepBackdrop(role: .settings) + VStack(alignment: .leading, spacing: 36) { + if let capturePane { + page(capturePane) + } else { + ForEach(SettingsPane.allCases) { item in + page(item) + } + } + } + .padding(.horizontal, 36) + .padding(.vertical, 34) } - .padding(.top, 28) - .padding(.horizontal, 22) - .padding(.bottom, 18) } .frame(width: 920) .fixedSize(horizontal: false, vertical: true) } - @ViewBuilder - private var paneContent: some View { - switch pane { - case .dataSources: - SettingsDataSourcesPane { - withAnimation(.easeOut(duration: 0.18)) { - pane = .quotas + // MARK: Sidebar + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 3) { + // Room for the window's traffic lights. + Color.clear.frame(height: 40) + ForEach(SettingsPane.allCases) { item in + SettingsSidebarItem(pane: item, selected: item == pane && (!captureMode || capturePane != nil)) { + withAnimation(.easeOut(duration: 0.16)) { + pane = item + } } } - case .quotas: - SettingsQuotaProvidersPane() - case .general: - SettingsGeneralPane() + Spacer(minLength: 16) + VStack(alignment: .leading, spacing: 3) { + Text(LFormat("TokenStep %@", UpdateService.currentVersion)) + Text(L("只在本机统计,不上传用量")) + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.top, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .top) { + Rectangle().fill(Color.tokenHairline).frame(height: 1) + } + .padding(.bottom, 16) + } + .padding(.horizontal, 12) + .frame(width: 214) + .frame(maxHeight: .infinity, alignment: .top) + .background( + TokenStepThemeRuntime.isCinematic + ? Color.tokenSurface.opacity(0.7) + : Color(nsColor: .windowBackgroundColor).opacity(0.94) + ) + .overlay(alignment: .trailing) { + Rectangle().fill(Color.tokenHairline).frame(width: 1) } } - private var header: some View { - HStack(spacing: 12) { - TokenStepBrandLockup(markSize: 28, titleSize: 17) - Rectangle() - .fill(Color.tokenDivider) - .frame(width: 1, height: 24) - Text(L("设置")) - .font(.system(size: 18, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) + // MARK: Pages - Spacer() - - HStack(spacing: 3) { - ForEach(SettingsPane.allCases) { item in - DashboardSettingsTab(title: item.title, selected: pane == item) { - withAnimation(.easeOut(duration: 0.16)) { - pane = item - } - } + private func page(_ item: SettingsPane) -> some View { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 6) { + Text(item.title) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Color.tokenInk) + Text(item.subtitle) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + if !isScreenshotRendering && !captureMode && item == pane { + ScreenshotMenuButton( + copyTitle: L("复制设置截图"), + saveTitle: L("保存设置 PNG"), + help: L("截取设置页"), + copyAction: copySettingsScreenshot, + saveAction: saveSettingsScreenshot + ) } } - .padding(3) - .background(Color.tokenTrack.opacity(0.55), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - - if !isScreenshotRendering && !captureMode { - ScreenshotMenuButton( - copyTitle: L("复制设置截图"), - saveTitle: L("保存设置 PNG"), - help: L("截取设置页"), - copyAction: copySettingsScreenshot, - saveAction: saveSettingsScreenshot - ) + paneContent(item) + if item == .general { + HStack { + Spacer() + Button(L("恢复默认设置")) { resetDefaults() } + .buttonStyle(.plain) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } } } - .background(alignment: .trailing) { - if TokenStepThemeRuntime.isVoyage { - OdysseySurfaceEmblem(role: .settings) - .frame(width: 124, height: 72) - .opacity(0.38) - .offset(x: -92, y: 3) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarEventHorizonEmblem() - .frame(width: 136, height: 58) - .opacity(0.42) - .offset(x: -92, y: 3) + } + + @ViewBuilder + private func paneContent(_ item: SettingsPane) -> some View { + switch item { + case .general: + SettingsGeneralPane() + case .appearance: + SettingsAppearancePane() + case .dataSources: + SettingsDataSourcesPane { + withAnimation(.easeOut(duration: 0.18)) { + pane = .quotas + } } + case .quotas: + SettingsQuotaProvidersPane() + case .tokenRank: + SettingsTokenRankCard() } } + // MARK: Screenshot and reset + private var settingsScreenshot: some View { SettingsView(captureMode: true) .environmentObject(appState) @@ -176,46 +250,6 @@ struct SettingsView: View { } } - private var footer: some View { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(L("TokenStep · Local usage tracker")) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Text(LFormat("当前版本 %@", UpdateService.currentVersion)) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary.opacity(0.82)) - } - - Spacer() - - Button { - resetDefaults() - } label: { - Text(L("恢复默认")) - .font(.callout.weight(.bold)) - .frame(width: 92, height: 36) - } - .buttonStyle(SettingsSecondaryButtonStyle()) - - Button { - SettingsWindowPresenter.shared.close() - NSApp.keyWindow?.close() - } label: { - Text(L("完成")) - .font(.callout.weight(.heavy)) - .frame(width: 82, height: 36) - } - .buttonStyle(SettingsPrimaryButtonStyle()) - } - .padding(.top, 12) - .overlay(alignment: .top) { - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) - } - } - private func resetDefaults() { appState.setGoal(TokenStepSettings.defaults.dailyGoalTokens) appState.setRefreshInterval(TokenStepSettings.defaults.refreshIntervalSeconds) @@ -239,30 +273,33 @@ struct SettingsView: View { } } -private struct DashboardSettingsTab: View { - var title: String +private struct SettingsSidebarItem: View { + var pane: SettingsPane var selected: Bool var action: () -> Void var body: some View { Button(action: action) { - Text(title) - .font(.system(size: 12, weight: .heavy)) - .foregroundStyle(selected ? Color.tokenInk : Color.tokenInk.opacity(0.55)) - .padding(.horizontal, 14) - .frame(height: 28) - .background( - selected - ? (TokenStepThemeRuntime.isCinematic ? Color.tokenGreen.opacity(0.16) : Color.tokenSurface) - : Color.clear, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(selected && TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color.clear) - ) - .shadow(color: selected ? Color.tokenShadow : .clear, radius: 3, y: 1) + HStack(spacing: 10) { + Image(systemName: pane.symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(pane.tileColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + Text(pane.title) + .font(.system(size: 13, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? Color.tokenGreenDark : Color.tokenInk.opacity(0.85)) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: 32) + .background( + selected ? Color.tokenGreen.opacity(0.13) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift deleted file mode 100644 index b31c977..0000000 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI - -struct StatsView: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - VStack(spacing: 22) { - HStack(spacing: 18) { - StatHeroMetric(label: L("累计 Token 消耗"), value: TokenStepFormat.tokens(appState.snapshot.totals.tokens), symbol: "figure.walk") - StatHeroMetric(label: L("消耗金额"), value: TokenStepFormat.money(appState.snapshot.totals.cost), symbol: "dollarsign.circle") - StatHeroMetric(label: L("活跃天数"), value: localizedDays(appState.snapshot.totals.activeDays), symbol: "flame") - } - - recentActivityCard - - HStack(alignment: .top, spacing: 22) { - usageList(title: L("按客户端"), subtitle: L("累计总量分布"), rows: appState.snapshot.tools.map { - UsageStatRow(name: $0.tool, value: $0.tokens, percent: $0.percentValue, color: $0.displayColor) - }) - usageList(title: L("按模型"), subtitle: "Top \(min(appState.snapshot.models.count, 10)) / \(appState.snapshot.models.count)", rows: appState.snapshot.models.prefix(10).map { - UsageStatRow(name: $0.model, value: $0.tokens, percent: $0.percentValue, color: $0.displayColor) - }) - } - } - } - - private var recentActivityCard: some View { - TokenCard { - VStack(alignment: .leading, spacing: 18) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(L("最近 30 天")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("柱越高,用量越多;颜色代表客户端")) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - } - Spacer() - TokenToolLegend(tools: recentTools, showsGoalLine: true) - Text(LFormat("今天 %@", TokenStepFormat.tokens(appState.today.totalTokens, compact: true))) - .font(.callout.weight(.bold)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.tokenMint.opacity(0.28), in: Capsule()) - } - - StackedActivityBarsView(rows: appState.snapshot.daily, goal: appState.settings.dailyGoalTokens) - .frame(height: 96) - } - } - } - - private func usageList(title: String, subtitle: String, rows: [UsageStatRow]) -> some View { - TokenCard { - VStack(alignment: .leading, spacing: 18) { - HStack(alignment: .firstTextBaseline) { - Text(title) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(subtitle) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - } - - if rows.isEmpty { - Text(L("等待下一次同步")) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) - } else { - ForEach(rows) { row in - UsageProgressRow( - name: row.name, - value: "\(TokenStepFormat.tokens(row.value, compact: true)) · \(TokenStepFormat.percent(row.percent))", - percent: row.percent, - color: row.color - ) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func localizedDays(_ count: Int) -> String { - TokenStepLocalization.language == .en ? "\(count)d" : "\(count) 天" - } - - private var recentTools: [String] { - uniqueToolNames(in: Array(appState.snapshot.daily.suffix(30))) - } -} - -private struct StatHeroMetric: View { - var label: String - var value: String - var symbol: String - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 15) { - ZStack { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.tokenMint.opacity(0.24)) - Image(systemName: symbol) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(Color.tokenGreenDark) - } - .frame(width: 42, height: 42) - - VStack(alignment: .leading, spacing: 7) { - Text(value) - .font(.system(size: 29, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.62) - .lineLimit(1) - Text(label) - .font(.callout.weight(.bold)) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} - -private struct UsageStatRow: Identifiable { - var id: String { name } - var name: String - var value: Int - var percent: Double - var color: Color -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayBreakdownCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayBreakdownCards.swift index fa6371d..852056e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayBreakdownCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayBreakdownCards.swift @@ -1,56 +1,5 @@ import SwiftUI -struct TodayBreakdownCard: View { - var title: String - var rows: [TodayBreakdownRow] - var maxRows: Int = 4 - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .firstTextBaseline) { - Text(title) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(L("今日")) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.tokenMint.opacity(0.24), in: Capsule()) - } - - if rows.isEmpty { - Text(L("等待下一次同步")) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 84, alignment: .leading) - } else { - VStack(spacing: 12) { - ForEach(Array(rows.prefix(maxRows).enumerated()), id: \.element.id) { index, row in - TodayBreakdownRowView( - row: row, - color: row.color ?? rowColor(index: index) - ) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func rowColor(index: Int) -> Color { - switch index { - case 0: return .tokenGreenDark - case 1: return .tokenGreen - case 2: return TokenStepThemeRuntime.palette.activity3.color - default: return TokenStepThemeRuntime.palette.activity2.color - } - } -} - struct TodayBreakdownRow: Identifiable { var id: String { name } var name: String @@ -58,38 +7,3 @@ struct TodayBreakdownRow: Identifiable { var percent: Double var color: Color? } - -private struct TodayBreakdownRowView: View { - var row: TodayBreakdownRow - var color: Color - - var body: some View { - HStack(spacing: 12) { - Text(row.name) - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .lineLimit(1) - .frame(width: 138, alignment: .leading) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack) - if row.percent > 0 { - Capsule() - .fill(color) - .frame(width: max(5, proxy.size.width * min(max(row.percent, 0), 100) / 100)) - } - } - } - .frame(height: 8) - - Text("\(TokenStepFormat.tokens(row.tokens, compact: true)) · \(TokenStepFormat.percent(row.percent))") - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - .monospacedDigit() - .frame(width: 126, alignment: .trailing) - } - .frame(height: 24) - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift index 4fb0087..e89c3c5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift @@ -1,264 +1,5 @@ import SwiftUI -struct TodayHeroCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - let lap = appState.todayLap - TokenCard { - HStack(alignment: .center, spacing: 20) { - ZStack { - if TokenStepThemeRuntime.isVoyage { - VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) - } else if TokenStepThemeRuntime.isInterstellar { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) - .overlay { - InterstellarEventHorizonEmblem() - .padding(34) - .opacity(0.50) - } - } else { - ProgressRingView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) - } - VStack(spacing: 4) { - Text(TokenStepFormat.tokens(appState.today.totalTokens)) - .font(.system(size: 22, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .minimumScaleFactor(0.5) - .lineLimit(1) - Text(LFormat("/ %@ 每圈", TokenStepFormat.tokens(appState.settings.dailyGoalTokens, compact: true))) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - } - .frame(width: 100) - } - .frame(width: 132, height: 132) - - VStack(alignment: .leading, spacing: 10) { - Text(lap.lapStatusText) - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - Text(heroSubtitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - TodayMetricChip(label: L("消耗金额"), value: TokenStepFormat.money(appState.today.cost)) - TodayMetricChip(label: L("活跃小时"), value: "\(appState.todayAgentWork.recordedActiveHours) h") - TodayMetricChip(label: L("累计"), value: TokenStepFormat.tokens(appState.snapshot.totals.tokens, compact: true)) - TodayMetricChip(label: L("达标天"), value: LFormat("%d 天", appState.goalDays)) - } - } - - Spacer(minLength: 0) - } - .frame(minHeight: 166) - } - } - - private var heroSubtitle: String { - let today = DateFormatter.tokenStepDay.string(from: Date()) - return LFormat("今日 %@ · 已达标 %d 圈", today, appState.todayLap.completedLaps) - } -} - -struct TodayAgentIntensityCard: View { - @EnvironmentObject private var appState: AppState - - private var work: DailyAgentWork { - appState.todayAgentWork - } - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(L("Agent 工作强度")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("本版补全:请求数 / 工具调用 / 输出")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - - HStack(spacing: 8) { - TodayBigStat(label: L("模型请求"), value: compactCount(work.modelRequestCount)) - TodayBigStat(label: L("工具调用"), value: compactCount(work.toolCallCount)) - TodayBigStat(label: L("缓存命中"), value: cacheRateText) - } - - VStack(spacing: 0) { - TodayKVRow(label: L("输入"), value: TokenStepFormat.tokens(work.inputTokens, compact: true)) - TodayKVRow(label: L("缓存读取"), value: TokenStepFormat.tokens(work.cachedInputTokens, compact: true)) - TodayKVRow(label: L("输出"), value: TokenStepFormat.tokens(work.outputTokens, compact: true)) - } - } - } - .overlay(alignment: .topTrailing) { - if TokenStepThemeRuntime.isVoyage { - OdysseySurfaceEmblem(role: .dashboard) - .frame(width: 94, height: 76) - .padding(14) - .opacity(0.54) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarEventHorizonEmblem() - .frame(width: 116, height: 58) - .padding(14) - .opacity(0.44) - } - } - } - - private var cacheRateText: String { - if let rate = work.cacheHitRate { - return TokenStepFormat.percent(rate * 100) - } - return "--" - } - - private func compactCount(_ value: Int) -> String { - TokenStepFormat.tokens(value, compact: true) - } -} - -struct TodayHourlyCard: View { - @EnvironmentObject private var appState: AppState - - private var work: DailyAgentWork { - appState.todayAgentWork - } - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - Text(L("今日分时")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("来自 hourlyBuckets · 活跃小时由此重算")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - - HStack(alignment: .bottom, spacing: 3) { - ForEach(0..<24, id: \.self) { hour in - let tokens = work.bucket(hour: hour).totalTokens - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill( - LinearGradient( - colors: [Color.tokenGreen, Color.tokenGreenDark], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame(maxWidth: .infinity, minHeight: 3, maxHeight: barHeight(tokens)) - .opacity(tokens > 0 ? 1 : 0.18) - } - } - .frame(height: 78, alignment: .bottom) - - HStack { - Text("0") - Spacer() - Text("6") - Spacer() - Text("12") - Spacer() - Text("18") - Spacer() - Text("23") - } - .font(.system(size: 10, weight: .bold, design: .rounded)) - .foregroundStyle(.secondary) - - if work.unbucketedTokens > 0 { - Text(LFormat("无时间戳 %@ 已单列,不摊进任何小时。", TokenStepFormat.tokens(work.unbucketedTokens, compact: true))) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - .overlay(alignment: .bottomTrailing) { - if TokenStepThemeRuntime.isVoyage { - VoyageRouteWatermark() - .frame(width: 180, height: 86) - .padding(12) - .opacity(0.46) - } else if TokenStepThemeRuntime.isInterstellar { - InterstellarEventHorizonEmblem() - .frame(width: 180, height: 70) - .padding(12) - .opacity(0.34) - } - } - } - - private func barHeight(_ tokens: Int) -> CGFloat { - let maxTokens = max(1, work.hourlyBuckets.map(\.totalTokens).max() ?? 0) - guard tokens > 0 else { return 3 } - return max(6, 78 * CGFloat(tokens) / CGFloat(maxTokens)) - } -} - -struct TodaySourcesCard: View { - @EnvironmentObject private var appState: AppState - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .leading, spacing: 3) { - Text(L("今日来源")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("本地账本 + Cursor 官方用量 · 计入圆环")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - - if rows.isEmpty { - Text(L("等待下一次同步")) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) - } else { - VStack(spacing: 8) { - ForEach(rows) { row in - VStack(alignment: .leading, spacing: 5) { - HStack { - Circle() - .fill(row.color ?? Color.tokenInk.opacity(0.35)) - .frame(width: 8, height: 8) - Text(row.name) - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - } - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack) - Capsule() - .fill(row.color ?? Color.tokenInk.opacity(0.35)) - .frame(width: max(4, proxy.size.width * min(max(row.percent, 0), 100) / 100)) - } - } - .frame(height: 5) - } - } - } - } - } - } - } - - private var rows: [TodayBreakdownRow] { - TodaySourceRows.make(tools: appState.today.tools) - } -} - enum TodaySourceRows { static func make(tools: [String: Int], maxNamed: Int = 3) -> [TodayBreakdownRow] { let total = tools.values.reduce(0, +) @@ -341,206 +82,3 @@ enum TodayModelUsageRows { return usage.models.values.filter { $0 > 0 }.reduce(0, +) != usage.totalTokens } } - -struct TodayModelUsageCard: View { - @EnvironmentObject private var appState: AppState - - private var usage: DailyUsage { appState.today } - private var rows: [TodayModelUsageRow] { TodayModelUsageRows.make(from: usage) } - private var visibleRows: ArraySlice { rows.prefix(TodayModelUsageRows.maximumVisibleRows) } - private var hiddenCount: Int { max(0, rows.count - TodayModelUsageRows.maximumVisibleRows) } - - var body: some View { - TokenCard { - VStack(alignment: .leading, spacing: 11) { - header - - if usage.totalTokens <= 0 { - emptyState(L("今日暂无使用记录")) - } else if rows.isEmpty { - emptyState(L("已有总量,等待模型明细同步")) - } else { - columnHeader - VStack(spacing: 0) { - ForEach(Array(visibleRows.enumerated()), id: \.element.id) { index, row in - modelRow(row) - if index < visibleRows.count - 1 { - Divider().opacity(0.55) - } - } - } - if hiddenCount > 0 { - Text(LFormat("还有 %d 个模型", hiddenCount)) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - if TodayModelUsageRows.hasTokenTotalMismatch(usage) { - Label( - L("模型明细合计与今日总量不一致;占比按今日总量计算。"), - systemImage: "info.circle" - ) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - } - } - } - } - - private var header: some View { - HStack(alignment: .firstTextBaseline, spacing: 16) { - VStack(alignment: .leading, spacing: 3) { - Text(L("今日模型消耗")) - .font(.title3.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - Text(L("按模型查看今日 Token、占比与金额估算")) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - } - Spacer() - Text(LFormat("今日 · %d 个模型", rows.count)) - .font(.caption.weight(.bold)) - .foregroundStyle(Color.tokenGreenDark) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.tokenMint.opacity(0.24), in: Capsule()) - } - } - - private var columnHeader: some View { - HStack(spacing: 12) { - Text(L("模型")).frame(width: 190, alignment: .leading) - Spacer(minLength: 60) - Text("Token").frame(width: 104, alignment: .trailing) - Text(L("占比")).frame(width: 72, alignment: .trailing) - Text(L("金额估算")).frame(width: 96, alignment: .trailing) - } - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.secondary) - } - - private func modelRow(_ row: TodayModelUsageRow) -> some View { - let color = rowColor(model: row.model) - return HStack(spacing: 12) { - HStack(spacing: 9) { - Circle().fill(color).frame(width: 9, height: 9) - Text(displayName(row.model)) - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.tokenInk) - .lineLimit(1) - .truncationMode(.middle) - .help(row.model) - } - .frame(width: 190, alignment: .leading) - - GeometryReader { proxy in - ZStack(alignment: .leading) { - Capsule().fill(Color.tokenTrack) - Capsule() - .fill(color) - .frame(width: max(4, proxy.size.width * row.percent / 100)) - } - } - .frame(height: 6) - - Text(TokenStepFormat.tokens(row.tokens, compact: true)) - .frame(width: 104, alignment: .trailing) - Text(TokenStepFormat.percent(row.percent)) - .frame(width: 72, alignment: .trailing) - Text(row.estimatedCost.map { TokenStepFormat.money($0) } ?? "—") - .frame(width: 96, alignment: .trailing) - .help(L("按 API 列表价估算,不代表订阅或实际账单。")) - } - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.tokenInk.opacity(0.78)) - .monospacedDigit() - .frame(height: 34) - } - - private func emptyState(_ message: String) -> some View { - Text(message) - .font(.callout.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 52, alignment: .leading) - } - - private func displayName(_ model: String) -> String { - model.lowercased() == "unknown" ? L("未知模型") : model - } - - private func rowColor(model: String) -> Color { - switch TodayModelUsageRows.colorSlot(for: model) { - case 0: return .tokenGreenDark - case 1: return TokenStepThemeRuntime.palette.activity3.color - case 2: return TokenStepThemeRuntime.palette.activity2.color - default: return .tokenGreen - } - } -} - -private struct TodayMetricChip: View { - var label: String - var value: String - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 15, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.7) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(Color.tokenTrack.opacity(0.42), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).stroke(Color.tokenHairline)) - } -} - -private struct TodayBigStat: View { - var label: String - var value: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 24, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - .minimumScaleFactor(0.6) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private struct TodayKVRow: View { - var label: String - var value: String - - var body: some View { - HStack { - Text(label) - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.tokenInk) - Spacer() - Text(value) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenInk) - .monospacedDigit() - } - .padding(.vertical, 7) - .overlay(alignment: .top) { - Rectangle() - .fill(Color.tokenDivider) - .frame(height: 1) - } - } -} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift index 60be1cb..47b3f68 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift @@ -2,20 +2,53 @@ import SwiftUI struct TodayView: View { @EnvironmentObject private var appState: AppState + /// Opens the History tab from the recent-weeks card; nil hides the link + /// (screenshots). + var openHistory: (() -> Void)? = nil var body: some View { - VStack(spacing: 12) { - HStack(alignment: .top, spacing: 13) { - TodayHeroCard() - TodayAgentIntensityCard() - } + VStack(spacing: 16) { + // Wide: ring, metrics and recent weeks in one row. Narrow: the + // metrics drop below as a single row of four. + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + TodayRingCard() + .frame(width: 380) + TodayMetricsGrid(columns: 2) + .frame(minWidth: 260, maxWidth: .infinity) + TodayRecentWeeksCard(openHistory: openHistory) + .frame(width: 260) + } + .fixedSize(horizontal: false, vertical: true) - HStack(alignment: .top, spacing: 13) { - TodayHourlyCard() - TodaySourcesCard() + VStack(spacing: 16) { + HStack(alignment: .top, spacing: 16) { + TodayRingCard() + TodayRecentWeeksCard(openHistory: openHistory) + .frame(width: 260) + } + .fixedSize(horizontal: false, vertical: true) + TodayMetricsGrid(columns: 4) + .fixedSize(horizontal: false, vertical: true) + } } - TodayModelUsageCard() + TodayHourlyStackCard() + + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + TodayClientsCard() + .frame(minWidth: 420) + TodayModelRankCard() + .frame(minWidth: 420) + } + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 16) { + TodayClientsCard() + TodayModelRankCard() + } + } if appState.settings.cursorCodeSignalEnabled { CursorCodeSignalCard() diff --git a/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift index b600d0f..1a50244 100644 --- a/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift +++ b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift @@ -22,7 +22,14 @@ struct TodayDashboardRender { let isNarrow = scenario == "zh-narrow" let content = Group { - if isNarrow { + if let section = ProcessInfo.processInfo.environment["TOKENSTEP_TODAY_SECTION"], + section.hasPrefix("settings") { + SettingsView( + captureMode: true, + capturePane: SettingsPane(rawValue: String(section.dropFirst("settings-".count))) + ) + .environmentObject(appState) + } else if isNarrow { ZStack { TokenStepBackdrop() TodayView().environmentObject(appState).padding(16) @@ -30,7 +37,9 @@ struct TodayDashboardRender { .frame(width: 720) .fixedSize(horizontal: false, vertical: true) } else { - DashboardScreenshotView(section: .today) + DashboardScreenshotView( + section: ProcessInfo.processInfo.environment["TOKENSTEP_TODAY_SECTION"] == "history" ? .history : .today + ) .environmentObject(appState) } } @@ -47,7 +56,9 @@ struct TodayDashboardRender { throw NSError(domain: "TodayDashboardRender", code: 1) } try png.write(to: output, options: .atomic) - guard bitmap.pixelsWide == (isNarrow ? 1_440 : 2_000), bitmap.pixelsHigh > 900 else { + let isSettings = ProcessInfo.processInfo.environment["TOKENSTEP_TODAY_SECTION"]?.hasPrefix("settings") == true + let expectedWidth = isSettings ? 1_840 : (isNarrow ? 1_440 : 2_000) + guard bitmap.pixelsWide == expectedWidth, bitmap.pixelsHigh > (isSettings ? 400 : 900) else { throw NSError(domain: "TodayDashboardRender", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Unexpected render size: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh)" ]) @@ -98,22 +109,33 @@ struct TodayDashboardRender { let tools = total > 0 ? ["Codex": total * 87 / 100, "Cursor": total * 8 / 100, "Hermes Agent": total - total * 95 / 100] : [:] let daily = DailyUsage(date: today, tools: tools, models: models, modelCosts: costs, totalTokens: total, cost: costs.values.reduce(0, +)) - let hourlySources = total > 0 ? (0..<13).map { hour in - AgentWorkHourBucket(hour: hour, sources: [ - AgentWorkHourlySource( - source: "Codex", - tokens: total / 13, - inputTokens: total / 14, - cachedInputTokens: total / 15, - outputTokens: total / 182, - cacheCoverageComplete: true - ) - ]) - } : [] + // Three clients over the hours so far, weighted like a working day. + let weights = [2, 1, 0, 0, 0, 0, 0, 0, 1, 4, 7, 9, 8] + let weightSum = weights.reduce(0, +) + func hourly(_ total: Int) -> [AgentWorkHourBucket] { + guard total > 0 else { return [] } + return weights.enumerated().compactMap { hour, weight in + guard weight > 0 else { return nil } + let tokens = total * weight / weightSum + return AgentWorkHourBucket(hour: hour, sources: [ + ("Codex", 87), ("Cursor", 8), ("Hermes Agent", 5) + ].map { source, share in + AgentWorkHourlySource( + source: source, + tokens: tokens * share / 100, + inputTokens: tokens * share / 100, + cachedInputTokens: tokens * share * 97 / 10_000, + outputTokens: tokens * share / 26_000, + cacheCoverageComplete: true + ) + }) + } + } + let hourlySources = hourly(total) let work = DailyAgentWork( date: today, totalTokens: total, - activeHours: total > 0 ? 13 : 0, + activeHours: total > 0 ? 10 : 0, modelRequestCount: total > 0 ? 3_324 : 0, toolCallCount: total > 0 ? 312 : 0, sources: [], @@ -124,14 +146,51 @@ struct TodayDashboardRender { hourlyBuckets: hourlySources, unbucketedTokens: 0 ) + + // Five weeks of history for the heatmap and the goal streak; empty + // scenarios keep none. + var history: [DailyUsage] = [] + var yesterdayWork: DailyAgentWork? + if total > 0 { + let pattern = [1.3, 0.9, 1.1, 0.4, 0.0, 1.6, 1.2, 0.7, 1.05, 0.2, 1.4, 0.95, 1.1, 1.25] + for offset in stride(from: 34, through: 1, by: -1) { + guard let day = TokenStepClock.calendar.date(byAdding: .day, value: -offset, to: Date()) else { continue } + let tokens = Int(500_000_000 * pattern[offset % pattern.count]) + let key = DateFormatter.tokenStepDay.string(from: day) + history.append(DailyUsage(date: key, tools: ["Codex": tokens], totalTokens: tokens, cost: Double(tokens) / 1_000_000)) + if offset == 1 { + yesterdayWork = DailyAgentWork( + date: key, + totalTokens: tokens, + activeHours: 9, + modelRequestCount: 2_900, + toolCallCount: 280, + sources: [], + hourlyBuckets: hourly(tokens * 3 / 4) + ) + } + } + } let snapshot = UsageSnapshot( generatedAt: ISO8601DateFormatter().string(from: Date()), timezone: "Asia/Shanghai", - totals: UsageTotals(tokens: total, cost: daily.cost, activeDays: total > 0 ? 1 : 0), - daily: [daily], - agentWork: [work], - tools: [], - models: [], + totals: UsageTotals( + tokens: total + history.map(\.totalTokens).reduce(0, +), + cost: daily.cost, + activeDays: (total > 0 ? 1 : 0) + history.filter { $0.totalTokens > 0 }.count + ), + daily: history + [daily], + agentWork: [yesterdayWork, work].compactMap { $0 }, + tools: total > 0 ? [ + ToolUsage(tool: "Codex", tokens: 14_000_000_000, percent: 85.5), + ToolUsage(tool: "Cursor", tokens: 1_600_000_000, percent: 9.8), + ToolUsage(tool: "Hermes Agent", tokens: 765_000_000, percent: 4.7) + ] : [], + models: total > 0 ? [ + ModelUsage(model: "gpt-5.2-codex", tool: "Codex", tokens: 12_100_000_000, percent: 73.9), + ModelUsage(model: "claude-opus-4.1", tool: "Claude Code", tokens: 2_900_000_000, percent: 17.7), + ModelUsage(model: "gemini-2.5-pro", tool: "Cursor", tokens: 1_365_000_000, percent: 8.4) + ] : [], sources: [:] ) let settings = TokenStepSettings( @@ -154,6 +213,12 @@ struct TodayDashboardRender { ) try writeJSON(snapshot, to: AppPaths.usageJSON) try writeJSON(settings, to: AppPaths.settingsJSON) + // Without these markers the theme migrations switch a classic-theme + // fixture to Odyssey. + for marker in [AppPaths.voyageThemeMigrationMarker, AppPaths.odysseyThemePackMigrationMarker] { + try FileManager.default.createDirectory(at: marker.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: marker) + } } private static func writeJSON(_ value: T, to url: URL) throws { diff --git a/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift new file mode 100644 index 0000000..c3cae36 --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift @@ -0,0 +1,184 @@ +import Darwin +import Foundation + +@main +struct TodayOverviewFixtureCheck { + static var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! + return calendar + }() + /// Sunday 2026-09-27 15:00 in Shanghai. + static let now = ISO8601DateFormatter().date(from: "2026-09-27T07:00:00Z")! + + static func main() { + do { + try checkComposition() + try checkHeatLevels() + try checkRecentWeeks() + try checkGoalStreak() + try checkDeltaVersusYesterday() + try checkHourlyChart() + try checkLongestStreakAndBusySpan() + print("Today overview fixture checks passed") + } catch { + fputs("Today overview fixture failed: \(error)\n", stderr) + exit(1) + } + } + + private static func checkComposition() throws { + let work = agentWork(date: "2026-09-27", input: 612, cached: 594, output: 3) + let composition = TodayOverviewModel.composition(work) + try expect(composition.freshInput == 18, "cached input is split out of input, got \(composition.freshInput)") + try expect(composition.cacheRead == 594 && composition.output == 3, "cache read and output kept") + try expect(composition.total == 615, "parts add up to input + output") + let odd = TodayOverviewModel.composition(agentWork(date: "x", input: 10, cached: 50, output: 0)) + try expect(odd.cacheRead == 10 && odd.freshInput == 0, "cached never exceeds input") + } + + private static func checkHeatLevels() throws { + let goal = 100 + try expect(TodayOverviewModel.heatLevel(tokens: 0, goal: goal) == 0, "no usage is level 0") + try expect(TodayOverviewModel.heatLevel(tokens: 10, goal: goal) == 1, "under 20% is level 1") + try expect(TodayOverviewModel.heatLevel(tokens: 20, goal: goal) == 2, "20% is level 2") + try expect(TodayOverviewModel.heatLevel(tokens: 50, goal: goal) == 3, "50% is level 3") + try expect(TodayOverviewModel.heatLevel(tokens: 100, goal: goal) == 4, "the goal is level 4") + try expect(TodayOverviewModel.heatLevel(tokens: 1, goal: 0) == 4, "no goal counts any usage as full") + } + + private static func checkRecentWeeks() throws { + let daily = [ + usage("2026-09-27", 40), + usage("2026-09-21", 100), + usage("2026-08-24", 60) + ] + let cells = TodayOverviewModel.recentWeeks(daily: daily, goal: 100, today: now, weeks: 5, calendar: calendar) + try expect(cells.count == 35, "five weeks of seven days, got \(cells.count)") + try expect(cells.first?.date == "2026-08-24", "starts on the Monday four weeks back, got \(cells.first?.date ?? "-")") + try expect(cells.first?.level == 3, "first Monday carries its usage") + try expect(cells.last?.date == "2026-09-27" && cells.last?.isToday == true, "today (Sunday) closes the grid") + try expect(cells.filter(\.isFuture).isEmpty, "a Sunday has no future days") + try expect(cells.first { $0.date == "2026-09-21" }?.level == 4, "a goal day is level 4") + + let wednesday = ISO8601DateFormatter().date(from: "2026-09-23T07:00:00Z")! + let midweek = TodayOverviewModel.recentWeeks(daily: daily, goal: 100, today: wednesday, weeks: 5, calendar: calendar) + try expect(midweek.suffix(4).allSatisfy(\.isFuture), "Thursday to Sunday are future on a Wednesday") + try expect(midweek[midweek.count - 5].isToday, "Wednesday is today") + } + + private static func checkGoalStreak() throws { + let daily = [ + usage("2026-09-23", 50), + usage("2026-09-24", 120), + usage("2026-09-25", 100), + usage("2026-09-26", 300), + usage("2026-09-27", 40) + ] + try expect(TodayOverviewModel.goalStreak(daily: daily, goal: 100, today: now, calendar: calendar) == 3, + "an unfinished today keeps yesterday's streak") + var reached = daily + reached[4] = usage("2026-09-27", 100) + try expect(TodayOverviewModel.goalStreak(daily: reached, goal: 100, today: now, calendar: calendar) == 4, + "reaching the goal today extends the streak") + try expect(TodayOverviewModel.goalStreak(daily: [usage("2026-09-25", 500)], goal: 100, today: now, calendar: calendar) == 0, + "a gap yesterday breaks the streak") + } + + private static func checkDeltaVersusYesterday() throws { + let today = agentWork(date: "2026-09-27", hours: [(9, 50), (14, 30), (15, 20)]) + let yesterday = agentWork(date: "2026-09-26", hours: [(9, 10), (14, 10), (20, 500)]) + try expect(TodayOverviewModel.deltaVersusYesterday(today: today, yesterday: yesterday, throughHour: 14) == 60, + "compares only up to the same hour") + try expect(TodayOverviewModel.deltaVersusYesterday(today: today, yesterday: nil, throughHour: 14) == nil, + "no yesterday, no comparison") + } + + private static func checkHourlyChart() throws { + let work = DailyAgentWork( + date: "2026-09-27", + totalTokens: 0, + activeHours: 0, + modelRequestCount: 0, + toolCallCount: 0, + sources: [], + hourlyBuckets: [ + AgentWorkHourBucket(hour: 10, sources: [source("Codex", 50), source("Cursor", 5), source("Hermes", 3), source("Kimi", 2)]), + AgentWorkHourBucket(hour: 11, sources: [source("Codex", 20), source("Claude Code", 90)]) + ] + ) + let chart = TodayOverviewModel.hourlyChart(work: work, currentHour: 11, maxSources: 3) + try expect(chart.sourceOrder == ["Claude Code", "Codex", "Cursor", ""], "top three by total plus other, got \(chart.sourceOrder)") + try expect(chart.hours[10].parts == [0, 50, 5, 5], "the rest folds into other, got \(chart.hours[10].parts)") + try expect(chart.peakHour == 11 && chart.peakTokens == 110, "peak is the busiest hour") + try expect(chart.hours[12].isFuture && !chart.hours[11].isFuture, "hours after now are future") + let empty = TodayOverviewModel.hourlyChart(work: agentWork(date: "x", input: 0, cached: 0, output: 0), currentHour: nil) + try expect(empty.peakHour == nil && empty.sourceOrder.isEmpty, "no usage has no peak") + } + + private static func checkLongestStreakAndBusySpan() throws { + let daily = [ + usage("2026-09-01", 100), usage("2026-09-02", 150), usage("2026-09-03", 120), + usage("2026-09-04", 10), + usage("2026-09-05", 100), usage("2026-09-06", 100) + ] + try expect(TodayOverviewModel.longestGoalStreak(daily: daily, goal: 100, calendar: calendar) == 3, + "longest run of goal days") + try expect(TodayOverviewModel.longestGoalStreak(daily: [usage("2026-09-01", 100), usage("2026-09-03", 100)], goal: 100, calendar: calendar) == 1, + "a missing day breaks a run") + var totals = Array(repeating: 0, count: 24) + totals[14] = 5; totals[15] = 9; totals[16] = 8; totals[17] = 6; totals[2] = 20 + try expect(TodayOverviewModel.busiestSpan(totals) == 14, "busiest four hours start at 14") + try expect(TodayOverviewModel.busiestSpan(Array(repeating: 0, count: 24)) == nil, "no usage, no span") + let hours = TodayOverviewModel.hourOfDayTotals([ + agentWork(date: "a", hours: [(9, 10), (10, 5)]), + agentWork(date: "b", hours: [(9, 1)]) + ]) + try expect(hours[9] == 11 && hours[10] == 5, "sums per hour across days") + } + + // MARK: Helpers + + private static func usage(_ date: String, _ tokens: Int) -> DailyUsage { + DailyUsage(date: date, tools: [:], totalTokens: tokens, cost: 0) + } + + private static func source(_ name: String, _ tokens: Int) -> AgentWorkHourlySource { + AgentWorkHourlySource(source: name, tokens: tokens, inputTokens: tokens, cachedInputTokens: 0, outputTokens: 0, cacheCoverageComplete: false) + } + + private static func agentWork(date: String, input: Int, cached: Int, output: Int) -> DailyAgentWork { + DailyAgentWork( + date: date, + totalTokens: input + output, + activeHours: 0, + modelRequestCount: 0, + toolCallCount: 0, + sources: [], + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output + ) + } + + private static func agentWork(date: String, hours: [(Int, Int)]) -> DailyAgentWork { + DailyAgentWork( + date: date, + totalTokens: hours.map(\.1).reduce(0, +), + activeHours: hours.count, + modelRequestCount: 0, + toolCallCount: 0, + sources: [], + hourlyBuckets: hours.map { AgentWorkHourBucket(hour: $0.0, sources: [source("Codex", $0.1)]) } + ) + } + + private static func expect(_ condition: Bool, _ message: String) throws { + if !condition { throw FixtureError(message: message) } + } + + struct FixtureError: Error, CustomStringConvertible { + var message: String + var description: String { message } + } +} diff --git a/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift b/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift index 62ce4bd..f898496 100644 --- a/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift +++ b/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift @@ -78,7 +78,7 @@ struct VoyageInterfaceRender { for pane in SettingsPane.allCases { try render( - VoyageSettingsPaneRenderView(pane: pane) + SettingsView(captureMode: true, capturePane: pane) .environmentObject(appState), named: "settings-\(pane.rawValue)", in: outputDirectory @@ -403,112 +403,3 @@ struct VoyageInterfaceRender { try encoder.encode(value).write(to: url, options: .atomic) } } - -private struct VoyageSettingsPaneRenderView: View { - @EnvironmentObject private var appState: AppState - var pane: SettingsPane - - var body: some View { - ZStack { - TokenStepBackdrop(role: .settings) - - VStack(alignment: .leading, spacing: 16) { - header - paneContent - footer - } - .padding(.top, 28) - .padding(.horizontal, 22) - .padding(.bottom, 18) - - if TokenStepThemeRuntime.isVoyage { - VoyageWindowFrame(inset: 8) - } - } - .frame(width: 920) - .fixedSize(horizontal: false, vertical: true) - } - - private var header: some View { - HStack(spacing: 12) { - TokenStepBrandLockup(markSize: 28, titleSize: 17) - Rectangle() - .fill(Color.tokenDivider) - .frame(width: 1, height: 24) - Text(L("设置")) - .font(.system(size: 18, weight: .heavy, design: .rounded)) - .foregroundStyle(Color.tokenInk) - - Spacer() - - HStack(spacing: 3) { - ForEach(SettingsPane.allCases) { item in - Text(item.title) - .font(.system(size: 12, weight: .heavy)) - .foregroundStyle(item == pane ? Color.tokenInk : Color.tokenInk.opacity(0.55)) - .padding(.horizontal, 14) - .frame(height: 28) - .background( - item == pane ? Color.tokenGreen.opacity(0.16) : Color.clear, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(item == pane ? Color.tokenHairlineStrong : Color.clear) - ) - } - } - .padding(3) - .background(Color.tokenTrack.opacity(0.55), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - } - .background(alignment: .trailing) { - if TokenStepThemeRuntime.isVoyage { - OdysseySurfaceEmblem(role: .settings) - .frame(width: 124, height: 72) - .opacity(0.38) - .offset(x: -92, y: 3) - } - } - } - - @ViewBuilder - private var paneContent: some View { - switch pane { - case .dataSources: - SettingsDataSourcesPane(openQuotaTab: {}) - case .quotas: - SettingsQuotaProvidersPane() - case .general: - SettingsGeneralPane() - } - } - - private var footer: some View { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(L("TokenStep · Local usage tracker")) - .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) - Text(LFormat("当前版本 %@", "0.2.4")) - .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary.opacity(0.82)) - } - Spacer() - Text(L("恢复默认")) - .font(.callout.weight(.bold)) - .foregroundStyle(Color.tokenInk.opacity(0.72)) - .frame(width: 92, height: 36) - .background(Color.tokenTrack.opacity(0.62), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.tokenHairline)) - Text(L("完成")) - .font(.callout.weight(.heavy)) - .foregroundStyle(Color.tokenActionText) - .frame(width: 82, height: 36) - .background(Color.tokenGreen, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .padding(.top, 12) - .overlay(alignment: .top) { - Rectangle().fill(Color.tokenDivider).frame(height: 1) - } - } -} diff --git a/docs/RELEASE_NOTES_0.2.21.md b/docs/RELEASE_NOTES_0.2.21.md new file mode 100644 index 0000000..95f47da --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.21.md @@ -0,0 +1,10 @@ +# TokenStep 0.2.21 + +仪表盘和设置改版,参考 Token Rank 个人页,更简洁、信息更集中。 + +- **今日**:圆环旁显示与昨天同一时间相比多了还是少了,以及 Token 构成(输入、缓存读取、输出)和缓存命中率。四张指标卡显示估算金额、活跃时长、模型请求与今日排名。新增「近 5 周」热力图和连续达标天数,可一键跳到历史。分时图按客户端堆叠,鼠标移到任意一小时即可看到当时的总量和各客户端用量。 +- **历史**:顶部是累计 Token、估算金额、活跃天数和最长连续达标。热力图随窗口宽度显示近一年,旁边是近 30 天的常用时段。近 30 天趋势旁可切换查看各客户端或各模型的累计占比。每日明细默认显示 7 天,可展开全部,新增「达标」一列,并可导出 CSV。 +- **设置**:改为左侧栏导航(通用、外观、数据源、额度、Token Rank),每页的选项一行一个、按组排列。改动立即生效,不再需要点「完成」。主题皮肤移到「外观」,Token Rank 有了单独的页面。 +- 去掉了界面上面向开发者的说明文字。 + +Token、金额与额度的统计口径不变。 diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index b69323d..751a178 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar" -VERSION="${TOKENSTEP_VERSION:-0.2.20}" +VERSION="${TOKENSTEP_VERSION:-0.2.21}" LAUNCH=true VERIFY=false diff --git a/script/render_today_dashboard.sh b/script/render_today_dashboard.sh index 2497b45..eb2a291 100755 --- a/script/render_today_dashboard.sh +++ b/script/render_today_dashboard.sh @@ -32,5 +32,11 @@ done test_root="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-today-voyage.XXXXXX")" TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_THEME="voyage" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/voyage.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="history" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/history.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="settings-dataSources" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/settings-dataSources.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="settings-appearance" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/settings-appearance.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="settings-tokenRank" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/settings-tokenRank.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="settings-general" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/settings-general.png" "$EXECUTABLE" +TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" TOKENSTEP_TODAY_SCENARIO="zh-normal" TOKENSTEP_TODAY_SECTION="settings-quotas" TOKENSTEP_TODAY_RENDER_PATH="$OUTPUT_DIR/settings-quotas.png" "$EXECUTABLE" test -s "$OUTPUT_DIR/voyage.png" rm -rf "$test_root" diff --git a/script/test_all.sh b/script/test_all.sh index 52d64d8..213860f 100755 --- a/script/test_all.sh +++ b/script/test_all.sh @@ -19,6 +19,7 @@ FIXTURE_CHECKS=( test_claude_incremental_collector test_settings_codable test_compact_popover + test_today_overview ) failed=() diff --git a/script/test_today_overview.sh b/script/test_today_overview.sh new file mode 100755 index 0000000..bd7c8ec --- /dev/null +++ b/script/test_today_overview.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Checks the Today dashboard calculations: token mix, heatmap weeks and levels, +# goal streak, the comparison with yesterday, and the hourly chart. + +# Fixture expectations are written in this zone. +export TOKENSTEP_TIMEZONE="Asia/Shanghai" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="/tmp/tokenstep-today-overview-fixture-$UID-$$" +OVERLAY_DIR="$BUILD_DIR/vfs-overlay" +OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" +EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" +EXECUTABLE="$BUILD_DIR/today-overview-fixture-check" + +cleanup() { + rm -rf "$BUILD_DIR" +} +trap cleanup EXIT + +mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" +cat > "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" <