From 43c09276a902e54c85b2ce9f25fc7f9aa1c209b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 27 Sep 2026 07:10:39 -0700 Subject: [PATCH 1/6] feat(dashboard): redesign Today after the Token Rank profile page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today now reads like the Token Rank profile: small-radius cards with a hairline, one glance for the day. - Ring card: today's tokens, lap, goal, laps done, the change versus yesterday at the same hour, and the token mix (fresh input, cache reads, output) with the cache hit rate. - Four metric tiles: estimated cost, active hours, model requests with tool calls, and today's rank (or total tokens when the board is off). - Last 5 weeks heatmap with the goal streak and a link to History. - Hourly chart stacked by client, with the peak hour and future hours drawn as a flat track. - Clients and top models side by side; models show estimated cost. - Developer captions ("来自 hourlyBuckets", "本版补全") are gone. On narrow widths the metrics move below the ring as one row and the two bottom cards stack. TokenCard drops to a 16pt radius with a faint shadow on the classic theme; cinematic themes keep their ornaments and shadow. The calculations live in TodayOverviewModel with a new fixture check. The Today render fixture now writes the theme migration markers (without them the classic theme rendered as Odyssey) and seeds five weeks of history, three clients and yesterday's hours. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 + .../TokenStepSwift/Support/Localization.swift | 54 ++ .../TokenStepSwift/Views/Components.swift | 20 +- .../Views/Dashboard/TodayOverviewCards.swift | 577 ++++++++++++++++++ .../Views/Dashboard/TodayOverviewModel.swift | 197 ++++++ .../TokenStepSwift/Views/MainWindowView.swift | 6 +- .../TokenStepSwift/Views/TodayView.swift | 51 +- .../Tests/Fixtures/TodayDashboardRender.swift | 78 ++- .../Fixtures/TodayOverviewFixtureCheck.swift | 162 +++++ script/test_all.sh | 1 + script/test_today_overview.sh | 64 ++ 11 files changed, 1181 insertions(+), 31 deletions(-) create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift create mode 100644 TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift create mode 100755 script/test_today_overview.sh 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/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 1cca0da..3420041 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -30,6 +30,33 @@ enum TokenStepLocalization { private static let translations: [TokenStepLanguage: [String: String]] = [ .en: [ + "今日已跑满 %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 +751,33 @@ enum TokenStepLocalization { "榜": "Rank", ], .zhHant: [ + "今日已跑满 %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..18f9213 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 + ) } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift new file mode 100644 index 0000000..d575fc5 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift @@ -0,0 +1,577 @@ +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 + private let chartHeight: CGFloat = 120 + + 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: 6) { + 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) + } + } + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) + .help(hourHelp(hour, chart: chart)) + } + } + .frame(height: chartHeight, alignment: .bottom) + .overlay(alignment: .bottom) { + Rectangle().fill(Color.tokenHairline).frame(height: 1) + } + + 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 hourHelp(_ hour: TodayOverviewModel.HourStack, chart: TodayOverviewModel.HourlyChart) -> String { + let parts = zip(chart.sourceOrder, hour.parts) + .filter { $0.1 > 0 } + .map { "\($0.0.isEmpty ? L("其他") : $0.0) \(TokenStepFormat.tokens($0.1, compact: true))" } + let head = String(format: "%02d:00 · %@", hour.hour, TokenStepFormat.tokens(hour.total, compact: true)) + return parts.isEmpty ? head : head + "\n" + parts.joined(separator: "\n") + } +} + +// 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..5428992 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift @@ -0,0 +1,197 @@ +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 + } + + // 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/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/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..d3867af 100644 --- a/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift +++ b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift @@ -98,22 +98,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,12 +135,41 @@ 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], + 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: [], models: [], sources: [:] @@ -154,6 +194,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..e1bd972 --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift @@ -0,0 +1,162 @@ +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() + 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") + } + + // 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/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" < Date: Sun, 27 Sep 2026 07:15:29 -0700 Subject: [PATCH 2/6] feat(dashboard): redesign History in the same style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Four summary tiles: total tokens since the first day, estimated cost, active days with goal days, and the longest goal streak with the current one. - A GitHub-style heatmap sized to the card's width, with month labels, next to a busy-hours chart for the last 30 days. - The 30-day trend beside an all-time split that switches between clients and models. - Daily detail shows 7 days with a "展开全部" toggle, a goal column and CSV export. Screenshots still list up to their row limit. StatsView is folded in and removed. The split switch is drawn rather than a system Picker, which screenshots cannot render. The Today render fixture can render History (TOKENSTEP_TODAY_SECTION=history) and seeds cumulative clients and models. Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/Support/Localization.swift | 38 ++ .../Views/Dashboard/TodayOverviewModel.swift | 50 ++ .../TokenStepSwift/Views/HistoryView.swift | 525 ++++++++++++++---- .../TokenStepSwift/Views/StatsView.swift | 136 ----- .../Tests/Fixtures/TodayDashboardRender.swift | 16 +- .../Fixtures/TodayOverviewFixtureCheck.swift | 22 + script/render_today_dashboard.sh | 1 + 7 files changed, 555 insertions(+), 233 deletions(-) delete mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/StatsView.swift diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 3420041..87e29d4 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -30,6 +30,25 @@ enum TokenStepLocalization { private static let translations: [TokenStepLanguage: [String: String]] = [ .en: [ + "自 %@ 起": "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", @@ -751,6 +770,25 @@ enum TokenStepLocalization { "榜": "Rank", ], .zhHant: [ + "自 %@ 起": "自 %@ 起", + "达标 %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 圈", "比昨天同一时间": "比昨天同一時間", "多 %@": "多 %@", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift index 5428992..58b11bf 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewModel.swift @@ -125,6 +125,56 @@ enum TodayOverviewModel { 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 { 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/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/Tests/Fixtures/TodayDashboardRender.swift b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift index d3867af..b288520 100644 --- a/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift +++ b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift @@ -30,7 +30,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) } } @@ -170,8 +172,16 @@ struct TodayDashboardRender { ), daily: history + [daily], agentWork: [yesterdayWork, work].compactMap { $0 }, - tools: [], - models: [], + 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( diff --git a/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift index e1bd972..c3cae36 100644 --- a/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/TodayOverviewFixtureCheck.swift @@ -19,6 +19,7 @@ struct TodayOverviewFixtureCheck { try checkGoalStreak() try checkDeltaVersusYesterday() try checkHourlyChart() + try checkLongestStreakAndBusySpan() print("Today overview fixture checks passed") } catch { fputs("Today overview fixture failed: \(error)\n", stderr) @@ -115,6 +116,27 @@ struct TodayOverviewFixtureCheck { 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 { diff --git a/script/render_today_dashboard.sh b/script/render_today_dashboard.sh index 2497b45..82b8c16 100755 --- a/script/render_today_dashboard.sh +++ b/script/render_today_dashboard.sh @@ -32,5 +32,6 @@ 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" test -s "$OUTPUT_DIR/voyage.png" rm -rf "$test_root" From d2bd12695bb1df01111a95a8b317c78697d371d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 27 Sep 2026 07:23:22 -0700 Subject: [PATCH 3/6] feat(settings): sidebar navigation and grouped sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings follows the approved design: a System Settings style sidebar (通用, 外观, 数据源, 额度, Token Rank) in place of the top tabs, each page with a title and one-line note, and sections as plain rounded groups with the title above. - Theme packs move to their own 外观 page; the Token Rank card to its own page, restyled as a group with chips instead of a system segmented Picker (screenshots could not render it). - The General page is one column. History range reads 30/90/180/365 天 and drops the stale "本版新增入口" badge. - Changes apply live, so the 完成 button is gone; 恢复默认设置 sits quietly at the bottom of the General page. - Option chips are lighter (green tint when selected), switch knobs are white, and a multi-part section body stays in one group. The Voyage render fixture renders the real SettingsView per pane instead of its own copy of the old header. The Today render fixture can render settings panes on the classic theme. Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/Support/Localization.swift | 24 ++ .../Settings/SettingsAppearancePane.swift | 143 ++++++++ .../Views/Settings/SettingsControls.swift | 93 +++--- .../SettingsDisplayRefreshCards.swift | 31 +- .../Views/Settings/SettingsGeneralPane.swift | 157 +-------- .../Settings/SettingsQuotaProvidersCard.swift | 5 +- .../TokenStepSwift/Views/SettingsView.swift | 313 ++++++++++-------- .../Tests/Fixtures/TodayDashboardRender.swift | 13 +- .../Fixtures/VoyageInterfaceRender.swift | 111 +------ script/render_today_dashboard.sh | 5 + 10 files changed, 427 insertions(+), 468 deletions(-) create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsAppearancePane.swift diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 87e29d4..87e9836 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -30,6 +30,18 @@ enum TokenStepLocalization { private static let translations: [TokenStepLanguage: [String: String]] = [ .en: [ + "改动立即生效,不需要保存。": "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", @@ -770,6 +782,18 @@ enum TokenStepLocalization { "榜": "Rank", ], .zhHant: [ + "改动立即生效,不需要保存。": "改動立即生效,不需要儲存。", + "主题和配色,随时切换。": "主題和配色,隨時切換。", + "只读取本机日志里的用量数字,不读对话内容。": "只讀取本機日誌裡的用量數字,不讀對話內容。", + "读取订阅的剩余额度,显示在面板顶部。密钥只存进钥匙串。": "讀取訂閱的剩餘額度,顯示在面板頂部。密鑰只存進鑰匙圈。", + "在面板里显示你在 Token Rank 公开榜单上的排名。": "在面板裡顯示你在 Token Rank 公開榜單上的排名。", + "只在本机统计,不上传用量": "只在本機統計,不上傳用量", + "恢复默认设置": "恢復預設設定", + "各自本机登录态": "各自本機登入狀態", + "Token Rank 排行榜": "Token Rank 排行榜", + "只读取公开榜单和本机 Token Rank 账号,TokenStep 不上传任何用量": "只讀取公開榜單和本機 Token Rank 帳號,TokenStep 不上傳任何用量", + "在面板里显示": "在面板裡顯示", + "等待关联": "等待關聯", "自 %@ 起": "自 %@ 起", "达标 %d 天": "達標 %d 天", "最长连续达标": "最長連續達標", 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/Tests/Fixtures/TodayDashboardRender.swift b/TokenStepSwift/Tests/Fixtures/TodayDashboardRender.swift index b288520..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) @@ -49,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)" ]) 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/script/render_today_dashboard.sh b/script/render_today_dashboard.sh index 82b8c16..eb2a291 100755 --- a/script/render_today_dashboard.sh +++ b/script/render_today_dashboard.sh @@ -33,5 +33,10 @@ 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" From 6235aa3f9e2e4456569167bdf38c7f5f3659b9f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 27 Sep 2026 07:25:36 -0700 Subject: [PATCH 4/6] chore(dashboard): remove the Today cards and views the redesign replaced TodayHeroCard, the agent intensity, hourly, sources and model usage cards, TodayBreakdownCard and ContributionWallView have no callers left. The row builders the new cards and tests use stay. Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/Views/Components.swift | 96 ---- .../Views/TodayBreakdownCards.swift | 86 ---- .../Views/TodayDashboardCards.swift | 462 ------------------ 3 files changed, 644 deletions(-) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index 18f9213..1a30417 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -629,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/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) - } - } -} From 383c0925e02f6b405c5da3f2581e6351c424bd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 27 Sep 2026 07:36:53 -0700 Subject: [PATCH 5/6] fix(dashboard): show the hourly breakdown on hover right away The hourly chart relied on .help tooltips, which appear late or not at all over a chart. Each hour's column now takes the hover across its full height, dims the other hours, and shows a callout with the hour's total and its per-client split (or that the hour has not come yet). Co-Authored-By: Claude Opus 5.5 --- .../TokenStepSwift/Support/Localization.swift | 4 + .../Views/Dashboard/TodayOverviewCards.swift | 78 ++++++++++++++++--- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 87e9836..4ff8817 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -30,6 +30,8 @@ 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.", @@ -782,6 +784,8 @@ enum TokenStepLocalization { "榜": "Rank", ], .zhHant: [ + "还没到": "還沒到", + "这一小时没有用量": "這一小時沒有用量", "改动立即生效,不需要保存。": "改動立即生效,不需要儲存。", "主题和配色,随时切换。": "主題和配色,隨時切換。", "只读取本机日志里的用量数字,不读对话内容。": "只讀取本機日誌裡的用量數字,不讀對話內容。", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift index d575fc5..e33dd9f 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Dashboard/TodayOverviewCards.swift @@ -370,7 +370,9 @@ struct TodayRecentWeeksCard: View { 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) @@ -400,7 +402,7 @@ struct TodayHourlyStackCard: View { } } - HStack(alignment: .bottom, spacing: 6) { + HStack(alignment: .bottom, spacing: barSpacing) { ForEach(chart.hours) { hour in VStack(spacing: 1) { ForEach(Array(hour.parts.enumerated().reversed()), id: \.offset) { index, tokens in @@ -416,15 +418,39 @@ struct TodayHourlyStackCard: View { .frame(height: 3) } } - .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) - .help(hourHelp(hour, chart: chart)) + .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") @@ -456,12 +482,46 @@ struct TodayHourlyStackCard: View { return Swift.max(2, (chartHeight - 4) * CGFloat(tokens) / CGFloat(maxTotal)) } - private func hourHelp(_ hour: TodayOverviewModel.HourStack, chart: TodayOverviewModel.HourlyChart) -> String { - let parts = zip(chart.sourceOrder, hour.parts) - .filter { $0.1 > 0 } - .map { "\($0.0.isEmpty ? L("其他") : $0.0) \(TokenStepFormat.tokens($0.1, compact: true))" } - let head = String(format: "%02d:00 · %@", hour.hour, TokenStepFormat.tokens(hour.total, compact: true)) - return parts.isEmpty ? head : head + "\n" + parts.joined(separator: "\n") + 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) } } From 27356dc6ed50823b169c4686ce4634361b1d130b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AI=E4=BA=A7=E5=93=81=E9=BB=84=E5=8F=94?= Date: Sun, 27 Sep 2026 07:38:56 -0700 Subject: [PATCH 6/6] chore: prepare TokenStep 0.2.21 Release notes, CHANGELOG, README and the build default, via script/prepare_release.sh. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 10 +++++----- docs/RELEASE_NOTES_0.2.21.md | 10 ++++++++++ script/build_swiftui_and_run.sh | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.2.21.md 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/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