refactor: 重构设置页面布局,用自定义分区和标签行替代 Form

This commit is contained in:
tigerenwork 2026-07-11 01:32:47 +08:00
parent 7a2508a449
commit b5ad9c1ec3
2 changed files with 255 additions and 144 deletions

View File

@ -8,6 +8,6 @@ struct BuggerApp: App {
Settings { Settings {
SettingsView() SettingsView()
} }
.windowResizability(.contentSize) .windowResizability(.contentMinSize)
} }
} }

View File

@ -1,6 +1,10 @@
import ServiceManagement import ServiceManagement
import SwiftUI import SwiftUI
// MARK: - Constants
private let kLabelWidth: CGFloat = 130
struct SettingsView: View { struct SettingsView: View {
@State private var config: AppConfig @State private var config: AppConfig
@State private var isTestingConnection = false @State private var isTestingConnection = false
@ -13,96 +17,50 @@ struct SettingsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 12) { VStack(alignment: .leading, spacing: 20) {
// Highlighted "Start here" card lives outside the Form so its // MARK: Your Name card
// Spacer + multi-element HStack don't disturb the Form's yourNameCard
// two-column label/control layout (which was pushing the
// Feishu Bitable fields off the right edge of the window).
HStack(spacing: 12) {
Image(systemName: "person.fill")
.font(.title2)
.foregroundStyle(.tint)
Text("Your Name")
.font(.headline)
TextField("e.g. Alice", text: $config.assigneeName)
.textFieldStyle(.roundedBorder)
Spacer()
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
Form { // MARK: Feishu Bitable
Section("Feishu Bitable") { settingsSection("Feishu Bitable") {
TextField("App Token (from Bitable URL)", text: $config.appToken) labeledRow("App Token") {
TextField("Table ID", text: $config.tableId) TextField("from Bitable URL", text: $config.appToken)
TextField("Feishu domain", text: $config.feishuBaseDomain) .textFieldStyle(.roundedBorder)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Button("Test Connection") {
testConnection()
}
.disabled(isTestingConnection || !config.isConfigured)
if isTestingConnection {
ProgressView()
.scaleEffect(0.7)
}
}
if let connectionResult {
Text(connectionResult)
.font(.caption)
.foregroundStyle(connectionResult.contains("") ? .green : .red)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
} }
labeledRow("Table ID") {
TextField("", text: $config.tableId)
.textFieldStyle(.roundedBorder)
}
labeledRow("Feishu domain") {
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
.textFieldStyle(.roundedBorder)
}
connectionTestRow
} }
Section("Polling") { // MARK: Polling
Picker("Check every", selection: $config.pollIntervalSeconds) { settingsSection("Polling") {
Text("1 minute").tag(60) labeledRow("Check every") {
Text("5 minutes").tag(300) Picker("", selection: $config.pollIntervalSeconds) {
Text("10 minutes").tag(600) Text("1 minute").tag(60)
Text("30 minutes").tag(1_800) Text("5 minutes").tag(300)
Text("1 hour").tag(3_600) Text("10 minutes").tag(600)
Text("2 hours").tag(7_200) Text("30 minutes").tag(1_800)
Text("4 hours").tag(14_400) Text("1 hour").tag(3_600)
Text("8 hours").tag(28_800) Text("2 hours").tag(7_200)
Text("12 hours").tag(43_200) Text("4 hours").tag(14_400)
Text("1 day").tag(86_400) Text("8 hours").tag(28_800)
Divider() Text("12 hours").tag(43_200)
Text("Daily schedule").tag(-1) Text("1 day").tag(86_400)
Divider()
Text("Daily schedule").tag(-1)
}
.labelsHidden()
.fixedSize()
} }
if config.pollIntervalSeconds == -1 { if config.pollIntervalSeconds == -1 {
VStack(alignment: .leading, spacing: 6) { dailyScheduleEditor
Text("Refresh at these times:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(dailyTimeBindings.indices, id: \.self) { index in
HStack {
DatePicker("", selection: dailyTimeBindings[index],
displayedComponents: .hourAndMinute)
.labelsHidden()
Button(action: { removeDailyTime(at: index) }) {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
.disabled(config.dailyRefreshTimes.count <= 1)
}
}
Button(action: addDailyTime) {
Label("Add time", systemImage: "plus.circle")
.font(.caption)
}
.buttonStyle(.plain)
}
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -113,19 +71,21 @@ struct SettingsView: View {
} }
} }
Section("Real-time updates") { // MARK: Real-time updates
VStack(alignment: .leading, spacing: 4) { settingsSection("Real-time updates") {
TextField("Subscribe base URL", text: $config.feishuAppBaseURL) labeledRow("Subscribe URL") {
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.autocorrectionDisabled() .autocorrectionDisabled()
Text("Base URL of the change-notification service, e.g. http://localhost:8000. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
Text("Base URL of the change-notification service. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} }
Section("Calibration") { // MARK: Calibration
settingsSection("Calibration") {
Toggle("Enable calibration check", isOn: $config.calibrationEnabled) Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.") Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
.font(.caption) .font(.caption)
@ -133,7 +93,8 @@ struct SettingsView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Section("Display") { // MARK: Display
settingsSection("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget) Toggle("Show floating widget", isOn: $config.showFloatingWidget)
.onChange(of: config.showFloatingWidget) { _, enabled in .onChange(of: config.showFloatingWidget) { _, enabled in
if enabled { if enabled {
@ -145,57 +106,207 @@ struct SettingsView: View {
Toggle("Launch at login", isOn: $config.launchAtLogin) Toggle("Launch at login", isOn: $config.launchAtLogin)
} }
Section { // MARK: Advanced
DisclosureGroup(isExpanded: $showAdvanced) { advancedSection
Text("Field Mappings")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
TextField("Title field", text: $config.fieldMappings.titleField) // MARK: Actions
TextField("Priority field", text: $config.fieldMappings.priorityField) actionButtons
TextField("Status field", text: $config.fieldMappings.statusField) }
TextField("Assignee field", text: $config.fieldMappings.assigneeField) .frame(maxWidth: 700, alignment: .leading)
TextField("Reporter field", text: $config.fieldMappings.reporterField) .frame(maxWidth: .infinity, alignment: .center)
TextField("Customer field", text: $config.fieldMappings.customerField) .padding(20)
TextField("Created At field", text: $config.fieldMappings.createdAtField) }
TextField("Updated At field", text: $config.fieldMappings.updatedAtField) .frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
Text("Status Mappings") }
.font(.caption)
.fontWeight(.semibold) // MARK: - Your Name Card
.foregroundStyle(.secondary)
Text("Map your Feishu status values to standard Bugger statuses.") private var yourNameCard: some View {
.font(.caption) HStack(spacing: 12) {
.foregroundStyle(.secondary) Image(systemName: "person.fill")
.font(.title2)
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in .foregroundStyle(.tint)
statusMappingRow(for: status) Text("Your Name")
} .font(.headline)
} label: { TextField("e.g. Alice", text: $config.assigneeName)
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3") .textFieldStyle(.roundedBorder)
} }
} .padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
HStack { .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
Button("Save") { }
save()
} // MARK: - Connection Test
.keyboardShortcut(.return)
private var connectionTestRow: some View {
Button("Disconnect Feishu") { HStack(spacing: 8) {
TokenManager.shared.clearTokens() Button("Test Connection") {
PollerService.shared.stop() testConnection()
BitableEventService.shared.disconnect() }
CalibrationService.shared.stop() .disabled(isTestingConnection || !config.isConfigured)
}
.foregroundStyle(.red) if isTestingConnection {
} ProgressView()
} // Form .scaleEffect(0.7)
} // VStack }
.padding()
if let connectionResult {
Text(connectionResult)
.font(.caption)
.foregroundStyle(connectionResult.contains("") ? .green : .red)
.lineLimit(3)
}
}
}
// MARK: - Daily Schedule Editor
private var dailyScheduleEditor: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Refresh at these times:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(dailyTimeBindings.indices, id: \.self) { index in
HStack {
DatePicker("", selection: dailyTimeBindings[index],
displayedComponents: .hourAndMinute)
.labelsHidden()
Button(action: { removeDailyTime(at: index) }) {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
.disabled(config.dailyRefreshTimes.count <= 1)
}
}
Button(action: addDailyTime) {
Label("Add time", systemImage: "plus.circle")
.font(.caption)
}
.buttonStyle(.plain)
}
.padding(.leading, kLabelWidth + 8)
}
// MARK: - Advanced Section
private var advancedSection: some View {
DisclosureGroup(isExpanded: $showAdvanced) {
VStack(alignment: .leading, spacing: 16) {
// Field Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Field Mappings")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
labeledRow("Title field") {
TextField("", text: $config.fieldMappings.titleField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Priority field") {
TextField("", text: $config.fieldMappings.priorityField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Status field") {
TextField("", text: $config.fieldMappings.statusField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Assignee field") {
TextField("", text: $config.fieldMappings.assigneeField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Reporter field") {
TextField("", text: $config.fieldMappings.reporterField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Customer field") {
TextField("", text: $config.fieldMappings.customerField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Created At field") {
TextField("", text: $config.fieldMappings.createdAtField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Updated At field") {
TextField("", text: $config.fieldMappings.updatedAtField)
.textFieldStyle(.roundedBorder)
}
}
Divider()
// Status Mappings
VStack(alignment: .leading, spacing: 8) {
Text("Status Mappings")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
Text("Map your Feishu status values to standard Bugger statuses.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
statusMappingRow(for: status)
}
}
}
.padding(.top, 8)
} label: {
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 12) {
Button("Save") {
save()
}
.keyboardShortcut(.return)
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
BitableEventService.shared.disconnect()
CalibrationService.shared.stop()
}
.foregroundStyle(.red)
}
}
// MARK: - Layout Helpers
/// A consistently-aligned labelcontrol row.
private func labeledRow<Content: View>(
_ label: String,
@ViewBuilder content: () -> Content
) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(label)
.frame(width: kLabelWidth, alignment: .trailing)
.foregroundStyle(.secondary)
content()
}
}
/// A section with a left-aligned header and divider.
private func settingsSection<Content: View>(
_ title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Divider()
content()
} }
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 1200)
} }
// MARK: - Status Mapping Row // MARK: - Status Mapping Row
@ -225,8 +336,8 @@ struct SettingsView: View {
} }
) )
return VStack(alignment: .leading, spacing: 2) { return VStack(alignment: .leading, spacing: 4) {
HStack { HStack(spacing: 6) {
StatusPill(status: status) StatusPill(status: status)
Text(status.label) Text(status.label)
.font(.caption) .font(.caption)