Files
openless/Sources/OpenLessApp/Settings/Sidebar.swift
T
baiqing d94be8080d refactor(settings): split 1697-line SettingsView.swift into single-responsibility files
按用户的"800 行红线、单一职责"约束拆分。零行为变化,只是物理文件结构。

- SettingsView.swift  87 (root: 枚举 + 导航模型 + 主分发)
- SettingsComponents.swift  218 (SettingsPage / GlassSection / GlassPanel /
  MetricTile / Notification 名 / polishModeHint / isFilled)
- Sidebar.swift  270 (FixedSidebar + 状态卡 + stats + WindowCanvasBackground)
- Tabs/HomeTab.swift  161
- Tabs/HistoryTab.swift  50
- Tabs/DictionaryTab.swift  349 (含 Chip + ChipFlow + EditorSheet)
- Tabs/StyleTab.swift  123 (含 StyleCard)
- Tabs/HelpTab.swift  100
- Tabs/SettingsHubTab.swift  184
- Tabs/LegacyTabs.swift  172 (Overview/Hotkey/Modes/Privacy 旧版孤儿,原样搬)

private 改成默认 internal 让跨文件可见,封装边界仍止于 OpenLessApp 模块内部。
项目最长文件从 1697 行 → 566 行 (DictationCoordinator),全部在 800 红线下。
2026-04-28 13:13:37 +08:00

271 lines
10 KiB
Swift

import AppKit
import SwiftUI
import OpenLessCore
import OpenLessPersistence
// 侧边栏:固定列表 + 今日概览 + 连接状态 + 键盘快捷键脚注。
// 容器圆角 22pt 与系统窗口外角 ~10pt 形成"放大同心"。
struct FixedSidebar: View {
@Binding var selection: OpenLessMainTab
@State private var stats = SidebarStatsSnapshot.load()
private let sidebarShape = RoundedRectangle(cornerRadius: 22, style: .continuous)
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 7) {
Text("OpenLess")
.font(.system(size: 23, weight: .semibold))
.foregroundStyle(.primary)
Text("自然说话,完美书写")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
// 顶部内卡片预留 36pt:让 OpenLess 标题落在红绿灯下方。
.padding(.top, 36)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, 10)
.padding(.top, 10)
.padding(.bottom, 8)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 12) {
VStack(spacing: 8) {
ForEach(OpenLessMainTab.allCases) { tab in
Button {
selection = tab
} label: {
HStack(spacing: 11) {
Image(systemName: tab.symbol)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(selection == tab ? Color.blue : .secondary)
.frame(width: 22)
Text(tab.title)
.font(.system(size: 14, weight: selection == tab ? .semibold : .regular))
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(selection == tab ? .primary : .secondary)
.background(
selection == tab ? Color.blue.opacity(0.10) : Color.primary.opacity(0.035),
in: RoundedRectangle(cornerRadius: 15, style: .continuous)
)
}
.buttonStyle(.plain)
.focusable(false)
.contentShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.help(tab.title)
}
}
SidebarUsageCard(stats: stats)
SidebarConnectionCard(stats: stats)
VStack(alignment: .leading, spacing: 7) {
Label("右 Option 开始录音", systemImage: "keyboard")
Label("Esc 取消", systemImage: "escape")
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
.scrollIndicators(.hidden)
}
.frame(width: 264)
.frame(maxHeight: .infinity, alignment: .top)
.clipShape(sidebarShape)
.glassPanel(cornerRadius: 22)
.contentShape(sidebarShape)
.onAppear { refresh() }
.onReceive(NotificationCenter.default.publisher(for: .openLessHistoryChanged)) { _ in refresh() }
.onReceive(NotificationCenter.default.publisher(for: .openLessDictionaryChanged)) { _ in refresh() }
.onReceive(NotificationCenter.default.publisher(for: .openLessCredentialsChanged)) { _ in refresh() }
}
private func refresh() {
stats = SidebarStatsSnapshot.load()
}
}
struct WindowCanvasBackground: View {
var body: some View {
ZStack {
Color(nsColor: .windowBackgroundColor)
LinearGradient(
colors: [
Color.primary.opacity(0.035),
Color.clear,
Color.accentColor.opacity(0.035),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
.ignoresSafeArea()
}
}
struct SidebarUsageCard: View {
let stats: SidebarStatsSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 11) {
HStack {
Label("今日概览", systemImage: "chart.bar.xaxis")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(stats.sessionCountText)
.font(.caption2)
.foregroundStyle(.secondary)
}
MiniUsageChart(values: stats.chartValues)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
SidebarMetricBox(title: "时长", value: stats.durationText, symbol: "waveform")
SidebarMetricBox(title: "总字数", value: "\(stats.totalCharacters)", symbol: "number")
SidebarMetricBox(title: "每分钟", value: "\(stats.charactersPerMinute)", symbol: "speedometer")
SidebarMetricBox(title: "词条", value: "\(stats.dictionaryCount)", symbol: "text.book.closed")
}
}
.padding(13)
.glassPanel(cornerRadius: 20)
}
}
struct SidebarConnectionCard: View {
let stats: SidebarStatsSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 9) {
SidebarConnectionRow(title: "ASR", detail: stats.hasVolcCredentials ? "已配置" : "待配置", ok: stats.hasVolcCredentials)
SidebarConnectionRow(title: "润色", detail: stats.hasArkCredentials ? "已配置" : "原文兜底", ok: stats.hasArkCredentials)
}
.padding(13)
.glassPanel(cornerRadius: 20)
}
}
struct SidebarMetricBox: View {
let title: String
let value: String
let symbol: String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Image(systemName: symbol)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 17, weight: .semibold, design: .rounded))
.lineLimit(1)
.minimumScaleFactor(0.75)
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(10)
.frame(maxWidth: .infinity, minHeight: 82, alignment: .leading)
.glassPanel(cornerRadius: 15)
}
}
struct SidebarConnectionRow: View {
let title: String
let detail: String
let ok: Bool
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(ok ? Color.green : Color.orange)
.frame(width: 7, height: 7)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(detail)
.font(.caption)
.foregroundStyle(ok ? .primary : .secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.glassPanel(cornerRadius: 13)
}
}
struct MiniUsageChart: View {
let values: [Double]
var body: some View {
HStack(alignment: .bottom, spacing: 5) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Color.accentColor.opacity(0.72))
.frame(height: max(8, 44 * value))
.frame(maxWidth: .infinity)
}
}
.padding(10)
.frame(height: 66)
.glassPanel(cornerRadius: 15)
}
}
struct SidebarStatsSnapshot {
let totalSeconds: Double
let totalCharacters: Int
let charactersPerMinute: Int
let dictionaryCount: Int
let hasVolcCredentials: Bool
let hasArkCredentials: Bool
let chartValues: [Double]
let sessionCount: Int
var durationText: String {
if totalSeconds < 60 {
return "\(Int(totalSeconds.rounded())) 秒"
}
return String(format: "%.1f 分", totalSeconds / 60)
}
var sessionCountText: String {
"\(sessionCount) 次"
}
static func load() -> SidebarStatsSnapshot {
let sessions = HistoryStore().recent(limit: 100)
let totalCharacters = sessions.reduce(0) { $0 + $1.finalText.count }
let actualMs = sessions.compactMap(\.durationMs).reduce(0, +)
let totalSeconds = actualMs > 0 ? Double(actualMs) / 1000 : Double(totalCharacters) / 240 * 60
let charactersPerMinute = totalSeconds > 0 ? Int((Double(totalCharacters) / totalSeconds * 60).rounded()) : 0
let recentCounts = Array(sessions.prefix(7).reversed()).map { Double(max($0.finalText.count, 1)) }
let maxCount = recentCounts.max() ?? 1
let chartValues = recentCounts.isEmpty ? Array(repeating: 0.18, count: 7) : recentCounts.map { $0 / maxCount }
let vault = CredentialsVault.shared
return SidebarStatsSnapshot(
totalSeconds: totalSeconds,
totalCharacters: totalCharacters,
charactersPerMinute: charactersPerMinute,
dictionaryCount: DictionaryStore().enabledEntries().count,
hasVolcCredentials: isFilled(vault.get(CredentialAccount.volcengineAppKey)) && isFilled(vault.get(CredentialAccount.volcengineAccessKey)),
hasArkCredentials: isFilled(vault.get(CredentialAccount.arkApiKey)),
chartValues: chartValues,
sessionCount: sessions.count
)
}
}