按用户的"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 红线下。
219 lines
6.7 KiB
Swift
219 lines
6.7 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import OpenLessCore
|
|
import OpenLessPersistence
|
|
|
|
// 共享 UI:所有 Tab 都用到的容器、行、字段、修饰符。
|
|
// 拆出来是为了让 SettingsView.swift / 各 Tab 文件保持单一职责。
|
|
|
|
struct SettingsPage<Content: View>: View {
|
|
let title: String
|
|
let subtitle: String
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(title)
|
|
.font(.system(size: 30, weight: .semibold))
|
|
Text(subtitle)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.bottom, 2)
|
|
|
|
content
|
|
}
|
|
.frame(maxWidth: 780, alignment: .leading)
|
|
.padding(.horizontal, 34)
|
|
.padding(.vertical, 30)
|
|
}
|
|
.background(Color.clear)
|
|
}
|
|
}
|
|
|
|
struct GlassSection<Content: View>: View {
|
|
let title: String
|
|
let symbol: String
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Label(title, systemImage: symbol)
|
|
.font(.headline)
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(.primary)
|
|
|
|
VStack(spacing: 0) {
|
|
content
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.glassPanel(cornerRadius: 24)
|
|
}
|
|
}
|
|
|
|
struct SettingsRow<Content: View>: View {
|
|
let title: String
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
|
Text(title)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 138, alignment: .leading)
|
|
content
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.vertical, 9)
|
|
}
|
|
}
|
|
|
|
struct DividerLine: View {
|
|
var body: some View {
|
|
Divider()
|
|
.padding(.leading, 154)
|
|
}
|
|
}
|
|
|
|
struct StatusLine: View {
|
|
let title: String
|
|
let detail: String
|
|
let ok: Bool
|
|
|
|
var body: some View {
|
|
SettingsRow(title: title) {
|
|
Label(detail, systemImage: ok ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
|
.foregroundStyle(ok ? .green : .orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PasteableCredentialField: View {
|
|
let placeholder: String
|
|
let secure: Bool
|
|
@Binding var text: String
|
|
@State private var revealed = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Group {
|
|
if secure && !revealed {
|
|
SecureField(placeholder, text: $text)
|
|
} else {
|
|
TextField(placeholder, text: $text)
|
|
}
|
|
}
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: 390)
|
|
|
|
if secure {
|
|
Button {
|
|
revealed.toggle()
|
|
} label: {
|
|
Image(systemName: revealed ? "eye.slash" : "eye")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.help(revealed ? "隐藏密钥" : "显示密钥")
|
|
}
|
|
|
|
Button {
|
|
if let value = NSPasteboard.general.string(forType: .string) {
|
|
text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
} label: {
|
|
Label("粘贴", systemImage: "doc.on.clipboard")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.help("从剪贴板粘贴")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PrimaryActionRow<Content: View>: View {
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Spacer()
|
|
content
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
struct MetricTile: View {
|
|
let title: String
|
|
let value: String
|
|
let symbol: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Label(title, systemImage: symbol)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.symbolRenderingMode(.hierarchical)
|
|
Text(value)
|
|
.font(.system(size: 28, weight: .semibold, design: .rounded))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
.padding(18)
|
|
.frame(maxWidth: .infinity, minHeight: 104, alignment: .leading)
|
|
.glassPanel(cornerRadius: 22)
|
|
}
|
|
}
|
|
|
|
func isFilled(_ value: String?) -> Bool {
|
|
guard let value else { return false }
|
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
func polishModeHint(_ mode: PolishMode) -> String {
|
|
switch mode {
|
|
case .raw: return "尽量忠实转写,只做基础标点和必要分句。"
|
|
case .light: return "去掉明显口癖和重复,尽量保留原句式和语气。"
|
|
case .structured: return "整理句子、段落和列表,适合 prompt 与笔记。"
|
|
case .formal: return "适合邮件、工作沟通和正式文档。"
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func glassPanel(cornerRadius: CGFloat) -> some View {
|
|
modifier(GlassPanelModifier(cornerRadius: cornerRadius))
|
|
}
|
|
}
|
|
|
|
struct GlassPanelModifier: ViewModifier {
|
|
let cornerRadius: CGFloat
|
|
|
|
func body(content: Content) -> some View {
|
|
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
if #available(macOS 26.0, *) {
|
|
content
|
|
.glassEffect(.regular.interactive(), in: shape)
|
|
// 边线半透明压到 0.04 + lineWidth 0.5:避免侧边栏外圈出现明显灰带。
|
|
.overlay(shape.strokeBorder(Color.primary.opacity(0.04), lineWidth: 0.5))
|
|
} else {
|
|
content
|
|
.background(.regularMaterial, in: shape)
|
|
.overlay(shape.strokeBorder(Color.primary.opacity(0.04), lineWidth: 0.5))
|
|
// 阴影 radius 从 16 → 6:侧边栏顶 padding=0 时不会再被裁出黑色矩形带。
|
|
.shadow(color: .black.opacity(0.05), radius: 6, y: 3)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let openLessHistoryChanged = Notification.Name("openless.history_changed")
|
|
static let openLessHotkeyChanged = Notification.Name("openless.hotkey_changed")
|
|
static let openLessCredentialsChanged = Notification.Name("openless.credentials_changed")
|
|
static let openLessDictionaryChanged = Notification.Name("openless.dictionary_changed")
|
|
}
|