refactor: 重构设置页面布局,用自定义分区和标签行替代 Form
This commit is contained in:
parent
7a2508a449
commit
b5ad9c1ec3
|
|
@ -8,6 +8,6 @@ struct BuggerApp: App {
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentMinSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,55 +17,31 @@ 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).
|
// MARK: Feishu Bitable
|
||||||
HStack(spacing: 12) {
|
settingsSection("Feishu Bitable") {
|
||||||
Image(systemName: "person.fill")
|
labeledRow("App Token") {
|
||||||
.font(.title2)
|
TextField("from Bitable URL", text: $config.appToken)
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Your Name")
|
|
||||||
.font(.headline)
|
|
||||||
TextField("e.g. Alice", text: $config.assigneeName)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding(10)
|
labeledRow("Table ID") {
|
||||||
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
|
TextField("", text: $config.tableId)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
Form {
|
|
||||||
Section("Feishu Bitable") {
|
|
||||||
TextField("App Token (from Bitable URL)", text: $config.appToken)
|
|
||||||
TextField("Table ID", text: $config.tableId)
|
|
||||||
TextField("Feishu domain", text: $config.feishuBaseDomain)
|
|
||||||
|
|
||||||
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("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") {
|
||||||
|
labeledRow("Check every") {
|
||||||
|
Picker("", selection: $config.pollIntervalSeconds) {
|
||||||
Text("1 minute").tag(60)
|
Text("1 minute").tag(60)
|
||||||
Text("5 minutes").tag(300)
|
Text("5 minutes").tag(300)
|
||||||
Text("10 minutes").tag(600)
|
Text("10 minutes").tag(600)
|
||||||
|
|
@ -75,8 +55,114 @@ struct SettingsView: View {
|
||||||
Divider()
|
Divider()
|
||||||
Text("Daily schedule").tag(-1)
|
Text("Daily schedule").tag(-1)
|
||||||
}
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
|
||||||
if config.pollIntervalSeconds == -1 {
|
if config.pollIntervalSeconds == -1 {
|
||||||
|
dailyScheduleEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
|
||||||
|
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Real-time updates
|
||||||
|
settingsSection("Real-time updates") {
|
||||||
|
labeledRow("Subscribe URL") {
|
||||||
|
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Calibration
|
||||||
|
settingsSection("Calibration") {
|
||||||
|
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.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Display
|
||||||
|
settingsSection("Display") {
|
||||||
|
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
||||||
|
.onChange(of: config.showFloatingWidget) { _, enabled in
|
||||||
|
if enabled {
|
||||||
|
AppDelegate.shared?.showFloatingWidget()
|
||||||
|
} else {
|
||||||
|
AppDelegate.shared?.hideFloatingWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Advanced
|
||||||
|
advancedSection
|
||||||
|
|
||||||
|
// MARK: Actions
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 700, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
|
||||||
|
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Your Name Card
|
||||||
|
|
||||||
|
private var yourNameCard: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connection Test
|
||||||
|
|
||||||
|
private var connectionTestRow: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Schedule Editor
|
||||||
|
|
||||||
|
private var dailyScheduleEditor: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Refresh at these times:")
|
Text("Refresh at these times:")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -103,66 +189,61 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, kLabelWidth + 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
// MARK: - Advanced Section
|
||||||
Toggle("Refresh on launch", isOn: $config.refreshOnStart)
|
|
||||||
Text("Fetch bugs immediately when Bugger starts. Off = wait for the next scheduled poll.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Real-time updates") {
|
private var advancedSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Calibration") {
|
|
||||||
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.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Display") {
|
|
||||||
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
|
||||||
.onChange(of: config.showFloatingWidget) { _, enabled in
|
|
||||||
if enabled {
|
|
||||||
AppDelegate.shared?.showFloatingWidget()
|
|
||||||
} else {
|
|
||||||
AppDelegate.shared?.hideFloatingWidget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
DisclosureGroup(isExpanded: $showAdvanced) {
|
DisclosureGroup(isExpanded: $showAdvanced) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Field Mappings
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Field Mappings")
|
Text("Field Mappings")
|
||||||
.font(.caption)
|
.font(.subheadline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
TextField("Title field", text: $config.fieldMappings.titleField)
|
labeledRow("Title field") {
|
||||||
TextField("Priority field", text: $config.fieldMappings.priorityField)
|
TextField("", text: $config.fieldMappings.titleField)
|
||||||
TextField("Status field", text: $config.fieldMappings.statusField)
|
.textFieldStyle(.roundedBorder)
|
||||||
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
|
}
|
||||||
TextField("Reporter field", text: $config.fieldMappings.reporterField)
|
labeledRow("Priority field") {
|
||||||
TextField("Customer field", text: $config.fieldMappings.customerField)
|
TextField("", text: $config.fieldMappings.priorityField)
|
||||||
TextField("Created At field", text: $config.fieldMappings.createdAtField)
|
.textFieldStyle(.roundedBorder)
|
||||||
TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
|
}
|
||||||
|
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")
|
Text("Status Mappings")
|
||||||
.font(.caption)
|
.font(.subheadline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("Map your Feishu status values to standard Bugger statuses.")
|
Text("Map your Feishu status values to standard Bugger statuses.")
|
||||||
|
|
@ -172,12 +253,18 @@ struct SettingsView: View {
|
||||||
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
|
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
|
||||||
statusMappingRow(for: status)
|
statusMappingRow(for: status)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
|
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
// MARK: - Action Buttons
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
@ -191,11 +278,35 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
} // Form
|
|
||||||
} // VStack
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 1200)
|
|
||||||
|
// MARK: - Layout Helpers
|
||||||
|
|
||||||
|
/// A consistently-aligned label–control 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue