From 723c50fc9d9180e58511d3062f8144c59754b23b Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Mon, 29 Jun 2026 00:51:03 +0800 Subject: [PATCH] feat: configurable status mappings, scrollable settings, case-insensitive assignee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added FieldMappings.statusMappings: [FeishuValue: BugStatus] dict mapping Feishu select-option text to standard Bugger statuses. Pre-populated with common Chinese/English values: 开发进行中→inProgress, 验收完毕→resolved, Bug Triage→open, etc. - BugMapper.mapStatus() looks up Feishu value in the mapping, falling back to .unknown (shown as active bug). - Settings: status mapping section with one TextField per status showing comma-separated Feishu values. Editable and saved to config. - Settings: wrapped Form in ScrollView, removed fixed height, use minHeight/maxHeight — no more truncated buttons. - Assignee matching now uses case-insensitive contains instead of exact match, handling display name variations from Feishu API. Co-Authored-By: Claude --- Sources/Models/AppConfig.swift | 22 +++ Sources/Services/Feishu/FeishuModels.swift | 8 +- Sources/Services/Feishu/FeishuService.swift | 4 +- Sources/Views/Settings/SettingsView.swift | 204 ++++++++++++++------ 4 files changed, 175 insertions(+), 63 deletions(-) diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 70e0479..278acb5 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -18,6 +18,28 @@ struct AppConfig: Codable, Equatable { var reporterField: String = "Reporter" var createdAtField: String = "Created At" var updatedAtField: String = "Updated At" + + /// Map Feishu display values to standard BugStatus cases. + /// Keys are the Feishu select-option text; values are the standard status. + /// Unknown values default to .unknown (shown as active). + var statusMappings: [String: BugStatus] = [ + "Bug Triage": .open, + "Open": .open, + "New": .open, + "待处理": .open, + "开发进行中": .inProgress, + "In Progress": .inProgress, + "验收中": .inReview, + "In Review": .inReview, + "验收完毕": .resolved, + "Resolved": .resolved, + "Fixed": .resolved, + "取消": .closed, + "Closed": .closed, + "Won't fix": .closed, + "转需求": .closed, + "验收不通过": .closed, + ] } var isConfigured: Bool { diff --git a/Sources/Services/Feishu/FeishuModels.swift b/Sources/Services/Feishu/FeishuModels.swift index c6c38bd..b7ea633 100644 --- a/Sources/Services/Feishu/FeishuModels.swift +++ b/Sources/Services/Feishu/FeishuModels.swift @@ -117,7 +117,7 @@ enum BugMapper { id: record.recordId, title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled", priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown, - status: BugStatus(rawValue: stringValue(in: fields, key: mappings.statusField) ?? "") ?? .unknown, + status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings), assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown", reporter: userName(in: fields, key: mappings.reporterField), createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast, @@ -158,4 +158,10 @@ enum BugMapper { private static func userName(in fields: [String: JSONValue], key: String) -> String? { fields[key]?.firstUserName ?? fields[key]?.stringValue } + + private static func mapStatus(_ feishuValue: String?, mappings: AppConfig.FieldMappings) -> BugStatus { + guard let value = feishuValue else { return .unknown } + // Look up in the user-configured mapping; fall back to .unknown (shown as active) + return mappings.statusMappings[value] ?? .unknown + } } diff --git a/Sources/Services/Feishu/FeishuService.swift b/Sources/Services/Feishu/FeishuService.swift index bbd3471..debe2e3 100644 --- a/Sources/Services/Feishu/FeishuService.swift +++ b/Sources/Services/Feishu/FeishuService.swift @@ -33,7 +33,9 @@ final class FeishuService { return allRecords .map { BugMapper.map($0, config: config) } - .filter { $0.assignee == assigneeName } + .filter { bug in + bug.assignee.localizedCaseInsensitiveContains(assigneeName) + } } func fetchRecordCount( diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift index 3c4e1d8..1384d4c 100644 --- a/Sources/Views/Settings/SettingsView.swift +++ b/Sources/Views/Settings/SettingsView.swift @@ -5,89 +5,145 @@ 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 { - 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) + 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() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Button("Test Connection") { + testConnection() + } + .disabled(isTestingConnection || !config.isConfigured) + + if isTestingConnection { + ProgressView() + .scaleEffect(0.7) + } } - .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) } } - 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(1800) } } - } - 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(1800) - } - } - - Section("Display") { - Toggle("Show floating widget", isOn: $config.showFloatingWidget) - .onChange(of: config.showFloatingWidget) { _, enabled in - if enabled { - AppDelegate.shared?.showFloatingWidget() - } else { - AppDelegate.shared?.hideFloatingWidget() + 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("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) } - 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("Created At field", text: $config.fieldMappings.createdAtField) - TextField("Updated At field", text: $config.fieldMappings.updatedAtField) - } - - HStack { - Button("Save") { - save() } - .keyboardShortcut(.return) - Button("Disconnect Feishu") { - TokenManager.shared.clearTokens() - PollerService.shared.stop() + HStack { + Button("Save") { + save() + } + .keyboardShortcut(.return) + + Button("Disconnect Feishu") { + TokenManager.shared.clearTokens() + PollerService.shared.stop() + } + .foregroundStyle(.red) } - .foregroundStyle(.red) } + .padding() } - .padding() - .frame(minWidth: 500, idealWidth: 520, minHeight: 560) + .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: - Actions + private func save() { AppStateService.shared.saveConfig(config) connectionResult = "Saved ✓" @@ -128,3 +184,29 @@ struct SettingsView: View { } } } + +// 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" + } + } +}