按用户的"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 红线下。
271 lines
10 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|