feat: configurable status mappings, scrollable settings, case-insensitive assignee

- 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 <noreply@anthropic.com>
This commit is contained in:
tigerenwork 2026-06-29 00:51:03 +08:00
parent 0f8565f65d
commit 723c50fc9d
4 changed files with 175 additions and 63 deletions

View File

@ -18,6 +18,28 @@ struct AppConfig: Codable, Equatable {
var reporterField: String = "Reporter" var reporterField: String = "Reporter"
var createdAtField: String = "Created At" var createdAtField: String = "Created At"
var updatedAtField: String = "Updated 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 { var isConfigured: Bool {

View File

@ -117,7 +117,7 @@ enum BugMapper {
id: record.recordId, id: record.recordId,
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled", title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown, 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", assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
reporter: userName(in: fields, key: mappings.reporterField), reporter: userName(in: fields, key: mappings.reporterField),
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast, 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? { private static func userName(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.firstUserName ?? fields[key]?.stringValue 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
}
} }

View File

@ -33,7 +33,9 @@ final class FeishuService {
return allRecords return allRecords
.map { BugMapper.map($0, config: config) } .map { BugMapper.map($0, config: config) }
.filter { $0.assignee == assigneeName } .filter { bug in
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
}
} }
func fetchRecordCount( func fetchRecordCount(

View File

@ -5,89 +5,145 @@ struct SettingsView: View {
@State private var config: AppConfig @State private var config: AppConfig
@State private var isTestingConnection = false @State private var isTestingConnection = false
@State private var connectionResult: String? @State private var connectionResult: String?
@State private var showAdvanced = false
init() { init() {
_config = State(initialValue: AppStateService.shared.config ?? AppConfig()) _config = State(initialValue: AppStateService.shared.config ?? AppConfig())
} }
var body: some View { var body: some View {
Form { ScrollView {
Section("Feishu Bitable") { Form {
TextField("App Token (from Bitable URL)", text: $config.appToken) Section("Feishu Bitable") {
TextField("Table ID", text: $config.tableId) TextField("App Token (from Bitable URL)", text: $config.appToken)
TextField("Your Name (as in Assignee column, optional)", text: $config.assigneeName) TextField("Table ID", text: $config.tableId)
TextField("Feishu domain", text: $config.feishuBaseDomain) TextField("Your Name (as in Assignee column, optional)", text: $config.assigneeName)
TextField("Feishu domain", text: $config.feishuBaseDomain)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) { HStack(spacing: 8) {
Button("Test Connection") { Button("Test Connection") {
testConnection() testConnection()
}
.disabled(isTestingConnection || !config.isConfigured)
if isTestingConnection {
ProgressView()
.scaleEffect(0.7)
}
} }
.disabled(isTestingConnection || !config.isConfigured) if let connectionResult {
Text(connectionResult)
if isTestingConnection { .font(.caption)
ProgressView() .foregroundStyle(connectionResult.contains("") ? .green : .red)
.scaleEffect(0.7) .lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
} }
} }
if let connectionResult { }
Text(connectionResult)
.font(.caption) Section("Polling") {
.foregroundStyle(connectionResult.contains("") ? .green : .red) Picker("Check every", selection: $config.pollIntervalSeconds) {
.lineLimit(3) Text("1 minute").tag(60)
.fixedSize(horizontal: false, vertical: true) Text("5 minutes").tag(300)
Text("10 minutes").tag(600)
Text("30 minutes").tag(1800)
} }
} }
}
Section("Polling") { Section("Display") {
Picker("Check every", selection: $config.pollIntervalSeconds) { Toggle("Show floating widget", isOn: $config.showFloatingWidget)
Text("1 minute").tag(60) .onChange(of: config.showFloatingWidget) { _, enabled in
Text("5 minutes").tag(300) if enabled {
Text("10 minutes").tag(600) AppDelegate.shared?.showFloatingWidget()
Text("30 minutes").tag(1800) } 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") { HStack {
TokenManager.shared.clearTokens() Button("Save") {
PollerService.shared.stop() save()
}
.keyboardShortcut(.return)
Button("Disconnect Feishu") {
TokenManager.shared.clearTokens()
PollerService.shared.stop()
}
.foregroundStyle(.red)
} }
.foregroundStyle(.red)
} }
.padding()
} }
.padding() .frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 800)
.frame(minWidth: 500, idealWidth: 520, minHeight: 560)
} }
// MARK: - Status Mapping Row
private func statusMappingRow(for status: BugStatus) -> some View {
let binding = Binding<String>(
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() { private func save() {
AppStateService.shared.saveConfig(config) AppStateService.shared.saveConfig(config)
connectionResult = "Saved ✓" 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"
}
}
}