Files
openless/Sources/OpenLessApp/Settings/Tabs/HomeTab.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

162 lines
7.4 KiB
Swift

import SwiftUI
import OpenLessCore
import OpenLessPersistence
struct HomeTab: View {
@State private var sessions: [DictationSession] = []
@State private var dictionaryEntries: [DictionaryEntry] = []
private let history = HistoryStore()
private let dictionary = DictionaryStore()
var body: some View {
SettingsPage(
title: "首页",
subtitle: "用个人输入记录展示口述时长、总字数、平均每分钟字数和节省时间。"
) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 2), spacing: 14) {
MetricTile(title: "口述时长", value: formattedDuration(totalSpeakingSeconds), symbol: "waveform")
MetricTile(title: "总字数", value: "\(totalCharacters) 字", symbol: "number")
MetricTile(title: "平均每分钟", value: "\(Int(spokenCharsPerMinute.rounded())) 字", symbol: "speedometer")
MetricTile(title: "估算节省", value: formattedDuration(savedTypingSeconds), symbol: "keyboard.badge.clock")
MetricTile(title: "速度提升", value: String(format: "%.1fx", speedLift), symbol: "bolt.fill")
MetricTile(title: "启用词条", value: "\(enabledDictionaryCount) 个", symbol: "text.book.closed")
}
GlassSection(title: "最近效果", symbol: "sparkles") {
if sessions.isEmpty {
Text("完成几次语音输入后,这里会展示口述速度、节省打字时间和词汇表建议记录。")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
ForEach(Array(sessions.prefix(4).enumerated()), id: \.element.id) { index, session in
if index > 0 { DividerLine() }
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(session.createdAt, style: .time)
Text(session.mode.displayName)
.foregroundStyle(.secondary)
Spacer()
if let durationMs = session.durationMs {
Text(formattedDuration(Double(durationMs) / 1000))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Text(session.finalText)
.lineLimit(2)
if (session.dictionaryEntryCount ?? 0) > 0 {
Text("后期模型已参考 \(session.dictionaryEntryCount ?? 0) 个词汇表词条进行语义判断")
.font(.footnote)
.foregroundStyle(.blue)
}
}
.padding(.vertical, 9)
}
}
}
GlassSection(title: "词汇表展示", symbol: "text.book.closed") {
if dictionaryEntries.isEmpty {
Text("添加 Claude、OpenLess、内部项目名等正确词后,OpenLess 会把它们注入 ASR 热词和后期模型上下文,由模型根据整句语义自动判断是否需要修正。")
.foregroundStyle(.secondary)
.padding(.vertical, 8)
} else {
ForEach(Array(dictionaryEntries.prefix(5).enumerated()), id: \.element.id) { index, entry in
if index > 0 { DividerLine() }
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(entry.phrase)
.font(.headline)
Text(entry.source.displayName)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Text(entry.category.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 9)
}
}
}
GlassSection(title: "今日概览", symbol: "chart.bar.xaxis") {
MiniUsageChart(values: chartValues)
.padding(.vertical, 6)
}
GlassSection(title: "风格", symbol: "paintpalette") {
HStack(spacing: 12) {
Image(systemName: UserPreferences.shared.polishEnabled ? "checkmark.circle.fill" : "pause.circle")
.font(.system(size: 18))
.foregroundStyle(UserPreferences.shared.polishEnabled ? .green : .orange)
VStack(alignment: .leading, spacing: 3) {
Text(UserPreferences.shared.polishEnabled ? UserPreferences.shared.polishMode.displayName : "已关闭")
.font(.system(size: 14, weight: .semibold))
Text(UserPreferences.shared.polishEnabled
? polishModeHint(UserPreferences.shared.polishMode)
: "识别后会直接插入原文,不调用 Ark 润色。")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.onAppear { reload() }
}
private var totalCharacters: Int {
sessions.reduce(0) { $0 + $1.finalText.count }
}
private var totalSpeakingSeconds: Double {
let actual = sessions.compactMap(\.durationMs).reduce(0, +)
if actual > 0 { return Double(actual) / 1000 }
return Double(totalCharacters) / 240 * 60
}
private var savedTypingSeconds: Double {
max(0, estimatedTypingSeconds - totalSpeakingSeconds)
}
private var estimatedTypingSeconds: Double {
Double(totalCharacters) / 90 * 60
}
private var spokenCharsPerMinute: Double {
guard totalSpeakingSeconds > 0 else { return 0 }
return Double(totalCharacters) / totalSpeakingSeconds * 60
}
private var speedLift: Double {
guard totalSpeakingSeconds > 0 else { return 0 }
return estimatedTypingSeconds / totalSpeakingSeconds
}
private var chartValues: [Double] {
let recent = Array(sessions.prefix(7).reversed()).map { Double(max($0.finalText.count, 1)) }
let maxCount = recent.max() ?? 1
return recent.isEmpty ? Array(repeating: 0.18, count: 7) : recent.map { $0 / maxCount }
}
private var enabledDictionaryCount: Int {
dictionaryEntries.filter(\.enabled).count
}
private func reload() {
sessions = history.recent(limit: 100)
dictionaryEntries = dictionary.all()
}
private func formattedDuration(_ seconds: Double) -> String {
if seconds < 60 {
return "\(Int(seconds.rounded())) 秒"
}
return String(format: "%.1f 分钟", seconds / 60)
}
}