import ServiceManagement import SwiftUI struct SettingsView: View { @State private var config: AppConfig @State private var isTestingConnection = false @State private var connectionResult: String? @State private var showAdvanced = false init() { _config = State(initialValue: AppStateService.shared.config ?? AppConfig()) } var body: some View { ScrollView { Form { Section("Feishu Bitable") { TextField("App Token (from Bitable URL)", text: $config.appToken) TextField("Table ID", text: $config.tableId) TextField("Your Name (as in Assignee column, optional)", text: $config.assigneeName) 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) } } } Section("Polling") { Picker("Check every", selection: $config.pollIntervalSeconds) { Text("1 minute").tag(60) Text("5 minutes").tag(300) Text("10 minutes").tag(600) Text("30 minutes").tag(1_800) Text("1 hour").tag(3_600) Text("2 hours").tag(7_200) Text("4 hours").tag(14_400) Text("8 hours").tag(28_800) Text("12 hours").tag(43_200) Text("1 day").tag(86_400) Divider() Text("Daily schedule").tag(-1) } if config.pollIntervalSeconds == -1 { VStack(alignment: .leading, spacing: 4) { Text("Refresh at these times (comma-separated, 24h):") .font(.caption) .foregroundStyle(.secondary) TextField("08:00, 14:00", text: dailyTimesBinding) .font(.caption) .textFieldStyle(.roundedBorder) Text("Example: 08:00, 14:00, 20:00") .font(.caption2) .foregroundStyle(.secondary) } } } 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("Field Mappings") { TextField("Title field", text: $config.fieldMappings.titleField) TextField("Priority field", text: $config.fieldMappings.priorityField) TextField("Status field", text: $config.fieldMappings.statusField) TextField("Assignee field", text: $config.fieldMappings.assigneeField) TextField("Reporter field", text: $config.fieldMappings.reporterField) TextField("Customer field", text: $config.fieldMappings.customerField) TextField("Created At field", text: $config.fieldMappings.createdAtField) TextField("Updated At field", text: $config.fieldMappings.updatedAtField) } Section("Status Mappings") { 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) } } HStack { Button("Save") { save() } .keyboardShortcut(.return) Button("Disconnect Feishu") { TokenManager.shared.clearTokens() PollerService.shared.stop() } .foregroundStyle(.red) } } .padding() } .frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 800) } // MARK: - Status Mapping Row private func statusMappingRow(for status: BugStatus) -> some View { let binding = Binding( get: { // Collect all Feishu values that map to this status config.fieldMappings.statusMappings .filter { $0.value == status } .keys .sorted() .joined(separator: ", ") }, set: { newValue in // Remove old mappings for this status config.fieldMappings.statusMappings = config.fieldMappings.statusMappings .filter { $0.value != status } // Add new mappings from comma-separated input let parts = newValue .components(separatedBy: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } for part in parts { config.fieldMappings.statusMappings[part] = status } } ) return VStack(alignment: .leading, spacing: 2) { HStack { StatusPill(status: status) Text(status.label) .font(.caption) .foregroundStyle(.secondary) } TextField(status.description, text: binding) .font(.caption) .textFieldStyle(.roundedBorder) } .padding(.vertical, 2) } // MARK: - Daily times binding private var dailyTimesBinding: Binding { Binding( get: { config.dailyRefreshTimes.joined(separator: ", ") }, set: { newValue in config.dailyRefreshTimes = newValue .components(separatedBy: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } ) } // MARK: - Actions private func save() { AppStateService.shared.saveConfig(config) connectionResult = "Saved ✓" if config.launchAtLogin { try? SMAppService.mainApp.register() } else { try? SMAppService.mainApp.unregister() } if config.showFloatingWidget { AppDelegate.shared?.showFloatingWidget() } else { AppDelegate.shared?.hideFloatingWidget() } Task { PollerService.shared.restart(interval: TimeInterval(config.pollIntervalSeconds)) } } private func testConnection() { isTestingConnection = true connectionResult = nil Task { do { let token = try await TokenManager.shared.getAccessToken() let count = try await FeishuService().fetchRecordCount( appToken: config.appToken, tableId: config.tableId, accessToken: token ) connectionResult = "Connected ✓ (\(count) records in table)" } catch { connectionResult = "Failed: \(error.localizedDescription)" } isTestingConnection = false } } } // MARK: - BugStatus display helpers extension BugStatus { var label: String { switch self { case .open: "Open" case .inProgress: "In Progress" case .inReview: "In Review" case .resolved: "Resolved" case .closed: "Closed" case .unknown: "Unknown" } } var description: String { switch self { case .open: "New / Triage (e.g. Bug Triage, Open, 待处理)" case .inProgress: "Being worked on (e.g. 开发进行中)" case .inReview: "Under review / testing (e.g. 验收中)" case .resolved: "Fixed & verified (e.g. 验收完毕, Fixed)" case .closed: "Done / cancelled (e.g. Closed, 取消, Won't fix)" case .unknown: "Unmapped" } } }