Add main window with bug detail view and in-app editing

Phase 1 — read-only detail experience:
- New main window (NavigationSplitView): searchable bug list sidebar with
  Active/All filter, detail pane with full metadata, description, and
  per-bug Open in Feishu / Copy Link
- Popover row click now opens the in-app detail (Open in Feishu kept via
  context menu and footer)
- Display extra table fields: module, source, flow status, fix notes
  (new field mappings with Chinese defaults), and screenshot attachments
  with in-app thumbnails (media download with bitablePerm extra auth)
- FieldMappings now decodes tolerantly so older persisted configs survive

Phase 2 — editing (status, fix notes, screenshots):
- OAuth scope upgraded from bitable:app:readonly to bitable:app
  (requires re-auth; permission errors prompt to reconnect)
- Status picker driven by live field metadata (option names + colors)
- Fix notes text editor with Save/Cancel
- Add screenshots: image picker -> media upload (upload_all) -> append
  file tokens to the record
- AttachmentLoader and BugEditService share one field-metadata cache

Note: flow status (流转状态) is a Bitable workflow/Stage field and cannot
be written via the OpenAPI; it stays read-only.
This commit is contained in:
tigerenwork 2026-09-08 11:11:16 +08:00
parent 44e53e373d
commit 72cff00f72
17 changed files with 1525 additions and 134 deletions

View File

@ -7,72 +7,84 @@
objects = {
/* Begin PBXBuildFile section */
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */; };
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEABFC668F7B082847F65D38 /* BugListPopover.swift */; };
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2 /* URL+Feishu.swift */; };
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */; };
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */; };
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4 /* BugRow.swift */; };
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */; };
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */; };
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */; };
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A /* Bug.swift */; };
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9 /* PollerService.swift */; };
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */; };
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33 /* NotificationService.swift */; };
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */; };
7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D /* SettingsView.swift */; };
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718 /* BugPriority.swift */; };
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670 /* BugStore.swift */; };
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */; };
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C /* TokenManager.swift */; };
C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89 /* Assets.xcassets */; };
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */; };
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */; };
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */; };
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE /* AppDelegate.swift */; };
F6750BB1FBEE5BC90DD6BDBC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CA4381D0CFEC95263A2DE; };
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB7AD270E93EA343975AC6; };
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBEF4F77A6A2E2D041EF581; };
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846CA001267896C54A78441A; };
7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C488C534B87C0560AF960F; };
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85; };
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5359156BD70B765D2BF4E718; };
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5; };
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4; };
BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF76E668F6EFF64241248E3; };
A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E834D262A6EE1D5406686761; };
660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CA07056AACE5378CF9D421; };
BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52C52B8EDA29A06D9DAA6E5D; };
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A2C50E47966E8ADB0446A0C; };
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; };
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; };
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6; };
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33; };
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; };
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C; };
C5D9B1FACE813CD43236BB77 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F99094B0F609809EBDB5F4A; };
29AE37B117A1422EA21570CA /* DateFormatter+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87C935E6B4D1813AB01186A0; };
64F1EE978536D1CB02B8F0D3 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF06E1030AA21DA8190169A8; };
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995; };
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED0A90DFCB8D4968E4E3CA1; };
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F888AA5F655343B19839B4C2; };
AECED74595B57A400437B9FD /* BugStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 344F52FB34B285D91A3E4670; };
AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45A7E013FA764EFAD7C9463; };
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FE32B404AB4388A066991; };
F5855822732C5D79A6BFF1A6 /* BugDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD8CEC0D176C83BC46992923; };
899C203A1CCFE8532BB228A2 /* MainWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9AB6328F122ACEDACC9A2FB; };
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEABFC668F7B082847F65D38; };
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1129D6F630904F03D06B5DC4; };
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0A744D4F5DB88B1496176B; };
7B0EAA6D83D40A593E36EE31 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3693AA93F823859D15256D; };
C3F7FEB179B079BDDD2AA212 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09B9A4D84F3F604C49171A89; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = "<group>"; };
0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = "<group>"; };
207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = "<group>"; };
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = "<group>"; };
4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = "<group>"; };
59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = "<group>"; };
6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = "<group>"; };
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = "<group>"; };
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = "<group>"; };
DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = "<group>"; };
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = "<group>"; };
EF0182DF85A7CD0319333F24 /* Bugger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bugger.app; sourceTree = BUILT_PRODUCTS_DIR; };
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
207CA4381D0CFEC95263A2DE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
DDDB7AD270E93EA343975AC6 /* BuggerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuggerApp.swift; sourceTree = "<group>"; };
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = "<group>"; };
846CA001267896C54A78441A /* Bug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bug.swift; sourceTree = "<group>"; };
08C488C534B87C0560AF960F /* BugAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugAttachment.swift; sourceTree = "<group>"; };
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugChange.swift; sourceTree = "<group>"; };
5359156BD70B765D2BF4E718 /* BugPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugPriority.swift; sourceTree = "<group>"; };
646C676356A4125B8E8D5FA5 /* BugStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStatus.swift; sourceTree = "<group>"; };
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
CCF76E668F6EFF64241248E3 /* AttachmentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentLoader.swift; sourceTree = "<group>"; };
E834D262A6EE1D5406686761 /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
D3CA07056AACE5378CF9D421 /* BugEditService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugEditService.swift; sourceTree = "<group>"; };
52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
6A2C50E47966E8ADB0446A0C /* FeishuAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuAuthService.swift; sourceTree = "<group>"; };
2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
0F99094B0F609809EBDB5F4A /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
87C935E6B4D1813AB01186A0 /* DateFormatter+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extensions.swift"; sourceTree = "<group>"; };
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOAuthServer.swift; sourceTree = "<group>"; };
0ED0A90DFCB8D4968E4E3CA1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
344F52FB34B285D91A3E4670 /* BugStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugStore.swift; sourceTree = "<group>"; };
A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowState.swift; sourceTree = "<group>"; };
6E2FE32B404AB4388A066991 /* FloatingWidgetWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingWidgetWindow.swift; sourceTree = "<group>"; };
AD8CEC0D176C83BC46992923 /* BugDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugDetailView.swift; sourceTree = "<group>"; };
C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowView.swift; sourceTree = "<group>"; };
CEABFC668F7B082847F65D38 /* BugListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugListPopover.swift; sourceTree = "<group>"; };
1129D6F630904F03D06B5DC4 /* BugRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugRow.swift; sourceTree = "<group>"; };
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthSetupView.swift; sourceTree = "<group>"; };
2D3693AA93F823859D15256D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
09B9A4D84F3F604C49171A89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
73AB234B54673C838E33AC7F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Bugger.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -86,7 +98,7 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
14ED1DECD6D906F498B9ECB5 = {
14ED1DECD6D906F498B9ECB5 /* Root */ = {
isa = PBXGroup;
children = (
287BE755981C092238309A9A /* Sources */,
@ -109,22 +121,12 @@
path = Sources;
sourceTree = "<group>";
};
31DF436612EC1C3CD1194AA1 /* Views */ = {
isa = PBXGroup;
children = (
E7E4AD241EEE594C1EEB9724 /* FloatingWidget */,
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */,
74AD95EF49A7D01C83865FAC /* OAuth */,
B70D2E54AD6B19DD455A8F82 /* Settings */,
);
path = Views;
sourceTree = "<group>";
};
65997CD9BD6FFEF8851BF59E /* Models */ = {
isa = PBXGroup;
children = (
9FBEF4F77A6A2E2D041EF581 /* AppConfig.swift */,
846CA001267896C54A78441A /* Bug.swift */,
08C488C534B87C0560AF960F /* BugAttachment.swift */,
0B24E1E1D32F7B774D12DD85 /* BugChange.swift */,
5359156BD70B765D2BF4E718 /* BugPriority.swift */,
646C676356A4125B8E8D5FA5 /* BugStatus.swift */,
@ -132,30 +134,20 @@
path = Models;
sourceTree = "<group>";
};
6AB9730AA11690EFC8F21473 /* ViewModels */ = {
A8C46168E4DFCE29B056A4E2 /* Services */ = {
isa = PBXGroup;
children = (
344F52FB34B285D91A3E4670 /* BugStore.swift */,
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
CCF76E668F6EFF64241248E3 /* AttachmentLoader.swift */,
E834D262A6EE1D5406686761 /* BitableEventService.swift */,
D3CA07056AACE5378CF9D421 /* BugEditService.swift */,
52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
E0E23D3899D89889546FB428 /* Feishu */,
);
path = ViewModels;
sourceTree = "<group>";
};
74AD95EF49A7D01C83865FAC /* OAuth */ = {
isa = PBXGroup;
children = (
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */,
);
path = OAuth;
sourceTree = "<group>";
};
8AD6A16612A2DB7B2F899787 /* Resources */ = {
isa = PBXGroup;
children = (
09B9A4D84F3F604C49171A89 /* Assets.xcassets */,
73AB234B54673C838E33AC7F /* Info.plist */,
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */,
);
path = Resources;
path = Services;
sourceTree = "<group>";
};
A6646A2065F53ED50AD2CCA7 /* Utils */ = {
@ -171,34 +163,25 @@
path = Utils;
sourceTree = "<group>";
};
A8C46168E4DFCE29B056A4E2 /* Services */ = {
6AB9730AA11690EFC8F21473 /* ViewModels */ = {
isa = PBXGroup;
children = (
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
E0E23D3899D89889546FB428 /* Feishu */,
344F52FB34B285D91A3E4670 /* BugStore.swift */,
A45A7E013FA764EFAD7C9463 /* MainWindowState.swift */,
);
path = Services;
path = ViewModels;
sourceTree = "<group>";
};
B23D89B2D2DF3745A003AEB1 /* Products */ = {
31DF436612EC1C3CD1194AA1 /* Views */ = {
isa = PBXGroup;
children = (
EF0182DF85A7CD0319333F24 /* Bugger.app */,
E7E4AD241EEE594C1EEB9724 /* FloatingWidget */,
5C01CC809DFAF826A51F2AEF /* MainWindow */,
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */,
74AD95EF49A7D01C83865FAC /* OAuth */,
B70D2E54AD6B19DD455A8F82 /* Settings */,
);
name = Products;
sourceTree = "<group>";
};
B70D2E54AD6B19DD455A8F82 /* Settings */ = {
isa = PBXGroup;
children = (
2D3693AA93F823859D15256D /* SettingsView.swift */,
);
path = Settings;
path = Views;
sourceTree = "<group>";
};
E0E23D3899D89889546FB428 /* Feishu */ = {
@ -220,6 +203,15 @@
path = FloatingWidget;
sourceTree = "<group>";
};
5C01CC809DFAF826A51F2AEF /* MainWindow */ = {
isa = PBXGroup;
children = (
AD8CEC0D176C83BC46992923 /* BugDetailView.swift */,
C9AB6328F122ACEDACC9A2FB /* MainWindowView.swift */,
);
path = MainWindow;
sourceTree = "<group>";
};
EA9ED591BB8A18FEBD1B58A3 /* MenuBar */ = {
isa = PBXGroup;
children = (
@ -229,6 +221,40 @@
path = MenuBar;
sourceTree = "<group>";
};
74AD95EF49A7D01C83865FAC /* OAuth */ = {
isa = PBXGroup;
children = (
3C0A744D4F5DB88B1496176B /* OAuthSetupView.swift */,
);
path = OAuth;
sourceTree = "<group>";
};
B70D2E54AD6B19DD455A8F82 /* Settings */ = {
isa = PBXGroup;
children = (
2D3693AA93F823859D15256D /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
8AD6A16612A2DB7B2F899787 /* Resources */ = {
isa = PBXGroup;
children = (
09B9A4D84F3F604C49171A89 /* Assets.xcassets */,
73AB234B54673C838E33AC7F /* Info.plist */,
E9B6FE6E06295095649B24A7 /* Bugger.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
B23D89B2D2DF3745A003AEB1 /* Products */ = {
isa = PBXGroup;
children = (
EF0182DF85A7CD0319333F24 /* Bugger.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -302,12 +328,15 @@
15550246A9E8DAF25E690478 /* BuggerApp.swift in Sources */,
58F16D20CC26D8248055CC2C /* AppConfig.swift in Sources */,
4EA9BDBA3F98AF4A41712A18 /* Bug.swift in Sources */,
7AD8DF0FC133A2AE26F3E3C2 /* BugAttachment.swift in Sources */,
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */,
A33561284CA44A0045902BCC /* BugPriority.swift in Sources */,
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */,
BD123E3404B92204E7CB96DF /* AttachmentLoader.swift in Sources */,
A31640CA64AEAD8FEC551137 /* BitableEventService.swift in Sources */,
660734535DE7B5840B0DD24B /* BugEditService.swift in Sources */,
BCF7698E979D7452AAC4612A /* CalibrationService.swift in Sources */,
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
@ -322,7 +351,10 @@
47ED652FA0AC818086F85DF3 /* Logger.swift in Sources */,
1FB7E762C0B6568C47B48EF0 /* URL+Feishu.swift in Sources */,
AECED74595B57A400437B9FD /* BugStore.swift in Sources */,
AD91D07ED264D88DA92E49DA /* MainWindowState.swift in Sources */,
485A69E15EF1D805F50DF364 /* FloatingWidgetWindow.swift in Sources */,
F5855822732C5D79A6BFF1A6 /* BugDetailView.swift in Sources */,
899C203A1CCFE8532BB228A2 /* MainWindowView.swift in Sources */,
1E3C1F40E2FF64A041856909 /* BugListPopover.swift in Sources */,
3467A50268399ABE9804D4D4 /* BugRow.swift in Sources */,
B066D78DA6F57A882A61C0B2 /* OAuthSetupView.swift in Sources */,
@ -362,15 +394,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
@ -386,15 +415,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 5;
FEISHU_APP_ID = cli_aacb82a1c0b8dcc0;
FEISHU_APP_SECRET = jUkU9ygRuQ0KaZFV2BSPfe33iXCahzNM;
FEISHU_BASE_DOMAIN = xmi5p6bgsnf8.feishu.cn;
FEISHU_APP_ID = "$(FEISHU_APP_ID)";
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
@ -426,6 +452,7 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 46F86FAA6BBF9AC94A7E4595 /* Project object */;
}

View File

@ -75,6 +75,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
popover.performClose(nil)
}
/// Opens the popover programmatically (e.g. to show the OAuth setup view
/// when a write fails due to a missing scope).
func openPopover() {
guard let button = statusItem?.button, !popover.isShown else { return }
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
func showFloatingWidget() {
guard floatingWidget == nil else { return }
floatingWidget = FloatingWidgetWindow()

View File

@ -5,6 +5,11 @@ struct BuggerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
Window("Bugger", id: "main") {
MainWindowView()
}
.defaultSize(width: 900, height: 600)
Settings {
SettingsView()
}

View File

@ -37,6 +37,19 @@ struct AppConfig: Codable, Equatable {
var customerField: String = "客户名称"
var createdAtField: String = "提出时间"
var updatedAtField: String = "最后更新时间"
/// Field shown as the description in the bug detail window.
/// Empty = not mapped (description section hidden).
var descriptionField: String = ""
/// 对应模块 — module (multi-select) shown in the bug detail window.
var moduleField: String = "对应模块"
/// 来源 — where the bug was reported.
var sourceField: String = "来源"
/// 流转状态 — workflow status shown as raw text in the detail window.
var flowStatusField: String = "流转状态"
/// 修复备注 — fix notes shown in the bug detail window.
var fixNotesField: String = "修复备注"
/// 相关截图 — attachment field whose images are shown in the detail window.
var screenshotsField: String = "相关截图"
/// Map Feishu display values to standard BugStatus cases.
/// Keys are the Feishu select-option text; values are the standard status.
@ -59,6 +72,37 @@ struct AppConfig: Codable, Equatable {
"转需求": .closed,
"验收不通过": .closed,
]
// Tolerant decoding: configs persisted before a field was added
// (missing key) must not fail to decode and wipe settings.
enum CodingKeys: String, CodingKey {
case titleField, priorityField, statusField, assigneeField
case reporterField, customerField, createdAtField, updatedAtField
case descriptionField, statusMappings
case moduleField, sourceField, flowStatusField, fixNotesField, screenshotsField
}
init() {}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let defaults = FieldMappings()
titleField = try c.decodeIfPresent(String.self, forKey: .titleField) ?? defaults.titleField
priorityField = try c.decodeIfPresent(String.self, forKey: .priorityField) ?? defaults.priorityField
statusField = try c.decodeIfPresent(String.self, forKey: .statusField) ?? defaults.statusField
assigneeField = try c.decodeIfPresent(String.self, forKey: .assigneeField) ?? defaults.assigneeField
reporterField = try c.decodeIfPresent(String.self, forKey: .reporterField) ?? defaults.reporterField
customerField = try c.decodeIfPresent(String.self, forKey: .customerField) ?? defaults.customerField
createdAtField = try c.decodeIfPresent(String.self, forKey: .createdAtField) ?? defaults.createdAtField
updatedAtField = try c.decodeIfPresent(String.self, forKey: .updatedAtField) ?? defaults.updatedAtField
descriptionField = try c.decodeIfPresent(String.self, forKey: .descriptionField) ?? defaults.descriptionField
moduleField = try c.decodeIfPresent(String.self, forKey: .moduleField) ?? defaults.moduleField
sourceField = try c.decodeIfPresent(String.self, forKey: .sourceField) ?? defaults.sourceField
flowStatusField = try c.decodeIfPresent(String.self, forKey: .flowStatusField) ?? defaults.flowStatusField
fixNotesField = try c.decodeIfPresent(String.self, forKey: .fixNotesField) ?? defaults.fixNotesField
screenshotsField = try c.decodeIfPresent(String.self, forKey: .screenshotsField) ?? defaults.screenshotsField
statusMappings = try c.decodeIfPresent([String: BugStatus].self, forKey: .statusMappings) ?? defaults.statusMappings
}
}
var isConfigured: Bool {

View File

@ -3,11 +3,25 @@ import Foundation
struct Bug: Identifiable, Equatable, Hashable {
let id: String
let title: String
let description: String?
let priority: BugPriority
let status: BugStatus
/// Raw Feishu status option text (e.g. "验收完毕") — needed to preselect
/// the editable status picker; `status` alone loses it via enum mapping.
let statusRaw: String?
let assignee: String
let reporter: String?
let customer: String?
/// 对应模块 — module tags, joined into a single display string.
let module: String?
/// 来源 — where the bug was reported (e.g. 运营发现 / 客户提出).
let source: String?
/// 流转状态 — raw workflow status text (e.g. Verify / 已结束).
let flowStatus: String?
/// 修复备注 — fix notes.
let fixNotes: String?
/// 相关截图 — attached screenshots.
let screenshots: [BugAttachment]
let createdAt: Date
let updatedAt: Date
let feishuURL: URL?

View File

@ -0,0 +1,18 @@
import Foundation
/// A file attached to a Bitable attachment field (e.g. 相关截图).
struct BugAttachment: Equatable, Hashable {
let fileToken: String
let name: String
let size: Int
let mimeType: String?
/// Record the attachment belongs to — required to build the `extra`
/// auth parameter when downloading from permissioned Bitables.
let recordId: String
var isImage: Bool {
if let mimeType, mimeType.hasPrefix("image/") { return true }
let ext = (name as NSString).pathExtension.lowercased()
return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic"].contains(ext)
}
}

View File

@ -0,0 +1,141 @@
import AppKit
import Foundation
/// Downloads and caches Bitable attachments (screenshots) for the detail window.
@MainActor
@Observable
final class AttachmentLoader {
static let shared = AttachmentLoader()
enum State: Equatable {
case loading
case loaded
case failed
}
private(set) var states: [String: State] = [:]
private var images: [String: NSImage] = [:]
private var fileData: [String: Data] = [:]
/// Set after the first 400 so later downloads skip the plain attempt.
private var needsExtraParam = false
/// Field IDs come from BugEditService's shared field-metadata cache.
private func screenshotsFieldID(named name: String) async -> String? {
await BugEditService.shared.ensureFieldsLoaded()
return BugEditService.shared.fieldID(named: name)
}
private init() {}
func image(for attachment: BugAttachment) -> NSImage? {
images[attachment.fileToken]
}
func state(for attachment: BugAttachment) -> State? {
states[attachment.fileToken]
}
/// Downloads the attachment unless already loaded/loading. Safe to call repeatedly.
func load(_ attachment: BugAttachment) {
guard states[attachment.fileToken] == nil else { return }
performLoad(attachment, openAfter: false)
}
func retry(_ attachment: BugAttachment) {
states[attachment.fileToken] = nil
performLoad(attachment, openAfter: false)
}
/// Opens the attachment in the system viewer, downloading first if needed.
func loadAndOpen(_ attachment: BugAttachment) {
if fileData[attachment.fileToken] != nil {
open(attachment)
} else {
performLoad(attachment, openAfter: true)
}
}
private func performLoad(_ attachment: BugAttachment, openAfter: Bool) {
let token = attachment.fileToken
states[token] = .loading
Task {
do {
let accessToken = try await TokenManager.shared.getAccessToken()
let data = try await download(attachment, accessToken: accessToken)
fileData[token] = data
images[token] = NSImage(data: data)
states[token] = .loaded
if openAfter { open(attachment) }
} catch {
BuggerLog.error("AttachmentLoader: \(attachment.name) failed: \(error.localizedDescription)")
states[token] = .failed
}
}
}
/// Plain download first; Bitables with advanced permissions answer 400 and
/// need an `extra` query param carrying table ID, field ID, record ID.
private func download(_ attachment: BugAttachment, accessToken: String) async throws -> Data {
let service = FeishuService()
if needsExtraParam {
return try await service.downloadAttachment(
fileToken: attachment.fileToken,
accessToken: accessToken,
extra: try buildExtra(for: attachment)
)
}
do {
return try await service.downloadAttachment(
fileToken: attachment.fileToken,
accessToken: accessToken
)
} catch FeishuError.apiError(let code, _) where code == 400 {
needsExtraParam = true
return try await service.downloadAttachment(
fileToken: attachment.fileToken,
accessToken: accessToken,
extra: try buildExtra(for: attachment)
)
}
}
private func buildExtra(for attachment: BugAttachment) async throws -> String {
guard let config = AppStateService.shared.config else {
throw FeishuError.notConfigured
}
let fieldName = config.fieldMappings.screenshotsField
guard let fieldId = await screenshotsFieldID(named: fieldName) else {
throw FeishuError.invalidConfiguration(
"Field \"\(fieldName)\" not found in table; check the Screenshots field mapping."
)
}
let extra: [String: Any] = [
"bitablePerm": [
"tableId": config.tableId,
"attachments": [fieldId: [attachment.recordId: [attachment.fileToken]]]
]
]
guard let data = try? JSONSerialization.data(withJSONObject: extra),
let string = String(data: data, encoding: .utf8) else {
throw FeishuError.invalidConfiguration("Could not build attachment extra parameter.")
}
return string
}
private func open(_ attachment: BugAttachment) {
guard let data = fileData[attachment.fileToken] else { return }
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("bugger-attachments", isDirectory: true)
// Prefix with the token so same-named attachments don't overwrite each other.
let safeName = attachment.name.replacingOccurrences(of: "/", with: "_")
let url = directory.appendingPathComponent("\(attachment.fileToken)-\(safeName)")
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: url)
NSWorkspace.shared.open(url)
} catch {
BuggerLog.error("AttachmentLoader: could not write \(url.path): \(error.localizedDescription)")
}
}
}

View File

@ -0,0 +1,147 @@
import Foundation
enum EditError: Error, LocalizedError {
case needsReauth
case notConfigured
var errorDescription: String? {
switch self {
case .needsReauth:
return "Missing write permission. Re-authorize Feishu to enable editing (and make sure you have edit rights on the Bitable)."
case .notConfigured:
return "Feishu table not configured. Open Settings."
}
}
}
/// Writes bug fields back to the Bitable. No optimistic local mutation — after a
/// successful write it triggers the normal fetch pipeline, which is the source
/// of truth (and drives change notifications).
@MainActor
@Observable
final class BugEditService {
static let shared = BugEditService()
private(set) var fields: [FieldItem] = []
private var fieldsLoadedFor: String?
private init() {}
// MARK: - Field metadata
/// Loads table field metadata once per appToken/tableId (status options,
/// field IDs for attachment downloads).
func ensureFieldsLoaded() async {
guard let config = AppStateService.shared.config, config.isConfigured else { return }
let key = "\(config.appToken)/\(config.tableId)"
guard fieldsLoadedFor != key else { return }
do {
let token = try await TokenManager.shared.getAccessToken()
fields = try await FeishuService().fetchFields(
appToken: config.appToken,
tableId: config.tableId,
accessToken: token
)
fieldsLoadedFor = key
} catch {
BuggerLog.error("BugEditService: fetchFields failed: \(error.localizedDescription)")
}
}
func fieldID(named name: String) -> String? {
fields.first { $0.fieldName == name }?.fieldId
}
var statusOptions: [SelectOption] {
guard let config = AppStateService.shared.config else { return [] }
return fields
.first { $0.fieldName == config.fieldMappings.statusField }?
.property?.options ?? []
}
// MARK: - Edits
func updateStatus(_ bug: Bug, to optionName: String) async throws {
guard let field = AppStateService.shared.config?.fieldMappings.statusField else {
throw EditError.notConfigured
}
try await update(bug, fields: [field: optionName])
}
func updateFixNotes(_ bug: Bug, text: String) async throws {
guard let field = AppStateService.shared.config?.fieldMappings.fixNotesField else {
throw EditError.notConfigured
}
try await update(bug, fields: [field: text])
}
/// Uploads each image (sequentially — the API rejects concurrent uploads)
/// and appends all new file tokens to the attachment field in one update.
func addScreenshots(_ bug: Bug, images: [(name: String, data: Data, mimeType: String)]) async throws {
guard let config = AppStateService.shared.config, config.isConfigured else {
throw EditError.notConfigured
}
let maxBytes = 20 * 1024 * 1024
if let oversized = images.first(where: { $0.data.count > maxBytes }) {
throw FeishuError.invalidConfiguration(
"\"\(oversized.name)\" exceeds the 20 MB upload limit."
)
}
let service = FeishuService()
let accessToken = try await TokenManager.shared.getAccessToken()
var newTokens: [String] = []
for image in images {
do {
let token = try await service.uploadMedia(
appToken: config.appToken,
fileName: image.name,
data: image.data,
mimeType: image.mimeType,
accessToken: accessToken
)
newTokens.append(token)
} catch {
throw Self.mapError(error)
}
}
// Attachment fields are replaced wholesale, so resend existing tokens.
let allTokens = (bug.screenshots.map(\.fileToken) + newTokens).map {
["file_token": $0]
}
try await update(bug, fields: [config.fieldMappings.screenshotsField: allTokens])
}
// MARK: - Helpers
private func update(_ bug: Bug, fields: [String: Any]) async throws {
guard let config = AppStateService.shared.config, config.isConfigured else {
throw EditError.notConfigured
}
let accessToken = try await TokenManager.shared.getAccessToken()
do {
try await FeishuService().updateRecord(
appToken: config.appToken,
tableId: config.tableId,
recordId: bug.id,
fields: fields,
accessToken: accessToken
)
} catch {
throw Self.mapError(error)
}
await PollerService.shared.fetchNow()
}
/// Permission failures mean either the token still has the old readonly
/// scope (re-auth needed) or the user lacks edit rights on the Bitable.
static func mapError(_ error: Error) -> Error {
if case let FeishuError.apiError(code, _) = error,
[1254302, 1254304, 1061004, 99991672, 99991679].contains(code) {
return EditError.needsReauth
}
return error
}
}

View File

@ -36,7 +36,7 @@ final class FeishuAuthService {
components.queryItems = [
URLQueryItem(name: "app_id", value: appId),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: "bitable:app:readonly offline_access")
URLQueryItem(name: "scope", value: "bitable:app offline_access")
]
return components.url!
}

View File

@ -29,6 +29,35 @@ struct RecordListData: Decodable {
let total: Int?
}
struct FieldListData: Decodable {
let items: [FieldItem]
let hasMore: Bool
let pageToken: String?
}
struct FieldItem: Decodable {
let fieldId: String
let fieldName: String
let type: Int?
let uiType: String?
let property: FieldProperty?
}
struct FieldProperty: Decodable {
let options: [SelectOption]?
}
struct SelectOption: Decodable, Equatable, Hashable {
let id: String
let name: String
let color: Int?
}
struct UploadMediaData: Decodable {
// file_token → fileToken via .convertFromSnakeCase
let fileToken: String
}
struct RecordItem: Decodable {
let recordId: String
let fields: [String: JSONValue]
@ -126,6 +155,30 @@ enum JSONValue: Decodable {
}
return nil
}
/// Attachment field values are arrays of objects like
/// `[{"file_token": "...", "name": "x.png", "size": 123, "type": "image/png"}]`.
func attachmentsValue(recordId: String) -> [BugAttachment] {
guard case let .array(items) = self else { return [] }
return items.compactMap { item in
guard case let .object(fields) = item,
let token = fields["file_token"]?.stringValue,
let name = fields["name"]?.stringValue else { return nil }
var size = 0
if case let .number(number)? = fields["size"] {
size = Int(number)
} else if let string = fields["size"]?.stringValue {
size = Int(string) ?? 0
}
return BugAttachment(
fileToken: token,
name: name,
size: size,
mimeType: fields["type"]?.stringValue,
recordId: recordId
)
}
}
}
enum BugMapper {
@ -139,11 +192,18 @@ enum BugMapper {
return Bug(
id: record.recordId,
title: stringValue(in: fields, key: mappings.titleField) ?? "Untitled",
description: description(in: fields, mappings: mappings),
priority: BugPriority(rawValue: stringValue(in: fields, key: mappings.priorityField) ?? "") ?? .unknown,
status: mapStatus(stringValue(in: fields, key: mappings.statusField), mappings: mappings),
statusRaw: stringValue(in: fields, key: mappings.statusField),
assignee: userName(in: fields, key: mappings.assigneeField) ?? "Unknown",
reporter: userName(in: fields, key: mappings.reporterField),
customer: rawValue(in: fields, key: mappings.customerField).map { "\($0)" },
module: joinedValue(in: fields, key: mappings.moduleField),
source: optionalStringValue(in: fields, key: mappings.sourceField),
flowStatus: optionalStringValue(in: fields, key: mappings.flowStatusField),
fixNotes: optionalStringValue(in: fields, key: mappings.fixNotesField),
screenshots: optionalAttachments(in: fields, key: mappings.screenshotsField, recordId: record.recordId),
createdAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.createdAtField)) ?? .distantPast,
updatedAt: FeishuDateParser.parse(rawValue(in: fields, key: mappings.updatedAtField)) ?? .distantPast,
feishuURL: FeishuURLBuilder.recordURL(
@ -174,6 +234,36 @@ enum BugMapper {
fields[key]?.textValue
}
private static func description(in fields: [String: JSONValue], mappings: AppConfig.FieldMappings) -> String? {
guard !mappings.descriptionField.isEmpty else { return nil }
let text = stringValue(in: fields, key: mappings.descriptionField)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
/// Like stringValue, but nil when the mapping is empty or the value is blank.
private static func optionalStringValue(in fields: [String: JSONValue], key: String) -> String? {
guard !key.isEmpty else { return nil }
let text = stringValue(in: fields, key: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
/// Multi-select fields decode as arrays of option strings; join them for display.
private static func joinedValue(in fields: [String: JSONValue], key: String) -> String? {
guard !key.isEmpty, let value = fields[key] else { return nil }
if case let .array(items) = value {
let parts = items.compactMap(\.textValue)
return parts.isEmpty ? nil : parts.joined(separator: ", ")
}
let text = value.textValue?.trimmingCharacters(in: .whitespacesAndNewlines)
return text?.isEmpty == false ? text : nil
}
private static func optionalAttachments(in fields: [String: JSONValue], key: String, recordId: String) -> [BugAttachment] {
guard !key.isEmpty else { return [] }
return fields[key]?.attachmentsValue(recordId: recordId) ?? []
}
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
fields[key]?.firstUserName ?? fields[key]?.stringValue
}

View File

@ -195,6 +195,213 @@ final class FeishuService {
return pageData
}
// MARK: - Attachment download (GET /drive/v1/medias/{file_token}/download)
/// Downloads a Bitable attachment by its file_token. Bitables with advanced
/// permissions require an `extra` query param for auth, e.g.
/// `{"bitablePerm":{"tableId":"...","attachments":{"<fieldId>":{"<recordId>":["<fileToken>"]}}}}`.
func downloadAttachment(fileToken: String, accessToken: String, extra: String? = nil) async throws -> Data {
guard var components = URLComponents(
string: "\(baseURL)/drive/v1/medias/\(fileToken)/download"
) else {
throw FeishuError.invalidConfiguration("Invalid attachment file token.")
}
if let extra {
components.queryItems = [URLQueryItem(name: "extra", value: extra)]
}
guard let url = components.url else {
throw FeishuError.invalidConfiguration("Could not build attachment download URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 60
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
guard (200..<300).contains(httpResponse.statusCode) else {
let body = String(data: data, encoding: .utf8) ?? ""
BuggerLog.error("downloadAttachment: HTTP \(httpResponse.statusCode) body=\(String(body.prefix(300)))")
throw FeishuError.apiError(code: httpResponse.statusCode, message: String(body.prefix(200)))
}
return data
}
// MARK: - Field metadata (GET /fields)
/// Returns the table's field metadata. Select fields carry their options
/// (name/id/color); field IDs (fldXXX) are needed to build the `extra`
/// auth parameter for attachment downloads.
func fetchFields(
appToken: String,
tableId: String,
accessToken: String
) async throws -> [FieldItem] {
var result: [FieldItem] = []
var pageToken: String?
repeat {
guard var components = URLComponents(
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/fields"
) else {
throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.")
}
var queryItems = [URLQueryItem(name: "page_size", value: "100")]
if let pageToken {
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
}
components.queryItems = queryItems
guard let url = components.url else {
throw FeishuError.invalidConfiguration("Could not build fields request URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
let apiResponse = try decoder.decode(FeishuAPIResponse<FieldListData>.self, from: data)
guard apiResponse.code == 0, let pageData = apiResponse.data else {
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
}
result.append(contentsOf: pageData.items)
pageToken = pageData.hasMore ? pageData.pageToken : nil
} while pageToken != nil
return result
}
// MARK: - Update record (PUT /records/{record_id})
/// Updates fields of a single record. Keys are field names. Included fields
/// are fully replaced; omitted fields are untouched. Select fields take the
/// option name; text fields take a plain string; attachment fields take a
/// full array of ["file_token": ...] entries.
func updateRecord(
appToken: String,
tableId: String,
recordId: String,
fields: [String: Any],
accessToken: String
) async throws {
guard let url = URL(
string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/\(recordId)"
) else {
throw FeishuError.invalidConfiguration("Could not build record update URL.")
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 30
request.httpBody = try JSONSerialization.data(withJSONObject: ["fields": fields])
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
let rawBody = String(data: data, encoding: .utf8) ?? "<binary \(data.count)B>"
BuggerLog.debug("updateRecord: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
let apiResponse = try decoder.decode(FeishuAPIResponse<JSONValue>.self, from: data)
guard apiResponse.code == 0 else {
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
}
}
// MARK: - Media upload (POST /drive/v1/medias/upload_all)
/// Uploads an image into the Bitable's media store and returns its
/// file_token, which can then be added to an attachment field via
/// updateRecord. Max 20 MB; uploads must be sequential.
func uploadMedia(
appToken: String,
fileName: String,
data: Data,
mimeType: String,
accessToken: String
) async throws -> String {
guard let url = URL(string: "\(baseURL)/drive/v1/medias/upload_all") else {
throw FeishuError.invalidConfiguration("Could not build media upload URL.")
}
let boundary = "BuggerBoundary-\(UUID().uuidString)"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
appendField("file_name", fileName)
appendField("parent_type", "bitable_image")
appendField("parent_node", appToken)
appendField("size", String(data.count))
appendField("checksum", String(adler32(data)))
appendField("extra", "{\"drive_route_token\":\"\(appToken)\"}")
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 120
request.httpBody = body
let (responseData, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw FeishuError.networkError(nil)
}
if httpResponse.statusCode == 401 {
throw FeishuError.unauthorized
}
let rawBody = String(data: responseData, encoding: .utf8) ?? "<binary \(responseData.count)B>"
BuggerLog.debug("uploadMedia: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
let apiResponse = try decoder.decode(FeishuAPIResponse<UploadMediaData>.self, from: responseData)
guard apiResponse.code == 0, let token = apiResponse.data?.fileToken else {
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
}
return token
}
/// Adler-32 checksum as required by the media upload API.
private func adler32(_ data: Data) -> UInt32 {
var a: UInt32 = 1
var b: UInt32 = 0
for byte in data {
a = (a + UInt32(byte)) % 65521
b = (b + a) % 65521
}
return (b << 16) | a
}
// MARK: - Search endpoint (POST /records/search)
private func searchRecords(

View File

@ -0,0 +1,15 @@
import Foundation
@Observable
final class MainWindowState {
static let shared = MainWindowState()
var selectedBugID: String?
var selectedBug: Bug? {
guard let selectedBugID else { return nil }
return BugStore.shared.bugs.first { $0.id == selectedBugID }
}
private init() {}
}

View File

@ -0,0 +1,479 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct BugDetailView: View {
let bug: Bug
let isUnseen: Bool
@State private var editService = BugEditService.shared
@State private var fixNotesDraft: String = ""
@State private var isSavingStatus = false
@State private var isSavingFixNotes = false
@State private var isUploading = false
@State private var editError: Error?
@State private var showNeedsReauth = false
private var fixNotesDirty: Bool {
fixNotesDraft != (bug.fixNotes ?? "")
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
Divider()
metadata
if let description = bug.description {
Divider()
descriptionSection(description)
}
Divider()
fixNotesSection
Divider()
screenshotsSection
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.task {
await editService.ensureFieldsLoaded()
}
.onAppear {
fixNotesDraft = bug.fixNotes ?? ""
}
.onChange(of: bug.id) { _, _ in
fixNotesDraft = bug.fixNotes ?? ""
}
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
copyLink()
} label: {
Label("Copy Link", systemImage: "link")
}
.disabled(bug.feishuURL == nil)
Button {
openInFeishu()
} label: {
Label("Open in Feishu", systemImage: "arrow.up.forward.app")
}
.disabled(bug.feishuURL == nil)
}
}
.alert(
"Failed to Save",
isPresented: Binding(
get: { editError != nil },
set: { if !$0 { editError = nil } }
),
presenting: editError
) { _ in
Button("OK") { editError = nil }
} message: { error in
Text(error.localizedDescription)
}
.alert("需要重新授权", isPresented: $showNeedsReauth) {
Button("重新连接 Feishu") {
TokenManager.shared.clearTokens()
AppDelegate.shared?.openPopover()
}
Button("取消", role: .cancel) {}
} message: {
Text("保存需要写权限。请重新授权飞书(已升级为可编辑权限范围);若重新授权后仍失败,请确认你在该多维表格中有编辑权限。")
}
}
// MARK: - Sections
private var header: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
PriorityBadge(priority: bug.priority)
StatusPill(status: bug.status)
if isUnseen {
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
}
Spacer()
}
Text(bug.title)
.font(.title2)
.fontWeight(.semibold)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
}
private var metadata: some View {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
GridRow {
metadataLabel("Status")
statusPicker
}
GridRow {
metadataLabel("Assignee")
Text(bug.assignee).textSelection(.enabled)
}
if let reporter = bug.reporter {
GridRow {
metadataLabel("Reporter")
Text(reporter).textSelection(.enabled)
}
}
if let customer = bug.customer {
GridRow {
metadataLabel("Customer")
Text(customer).textSelection(.enabled)
}
}
if let module = bug.module {
GridRow {
metadataLabel("Module")
Text(module).textSelection(.enabled)
}
}
if let source = bug.source {
GridRow {
metadataLabel("Source")
Text(source).textSelection(.enabled)
}
}
if let flowStatus = bug.flowStatus {
GridRow {
metadataLabel("Flow Status")
// Workflow (流程) fields can't be written via the API —
// advance the flow in Feishu ("Open in Feishu" above).
Text(flowStatus).textSelection(.enabled)
}
}
GridRow {
metadataLabel("Created")
Text(bug.createdAt, style: .date)
+ Text(" ") + Text(bug.createdAt, style: .time)
}
GridRow {
metadataLabel("Updated")
Text(bug.updatedAt, style: .date)
+ Text(" ") + Text(bug.updatedAt, style: .time)
}
GridRow {
metadataLabel("Age")
Text(ageString(bug.age))
}
}
}
private func metadataLabel(_ title: String) -> some View {
Text(title)
.foregroundStyle(.secondary)
.frame(width: 90, alignment: .trailing)
}
private func descriptionSection(_ description: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Description")
.font(.headline)
Text(description)
.font(.body)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
}
// MARK: - Status editing
private var statusPicker: some View {
HStack(spacing: 6) {
Menu {
ForEach(editService.statusOptions, id: \.id) { option in
Button {
changeStatus(to: option.name)
} label: {
HStack {
Circle()
.fill(feishuOptionColor(option.color))
.frame(width: 8, height: 8)
Text(option.name)
if option.name == bug.statusRaw {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(bug.statusRaw ?? "Set status")
.foregroundStyle(bug.statusRaw == nil ? .secondary : .primary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(isSavingStatus || editService.statusOptions.isEmpty)
if isSavingStatus {
ProgressView()
.scaleEffect(0.6)
.frame(width: 14, height: 14)
}
}
}
private func changeStatus(to optionName: String) {
guard optionName != bug.statusRaw else { return }
isSavingStatus = true
Task {
do {
try await editService.updateStatus(bug, to: optionName)
} catch {
handleEditError(error)
}
isSavingStatus = false
}
}
// MARK: - Fix notes editing
private var fixNotesSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Fix Notes")
.font(.headline)
if isSavingFixNotes {
ProgressView()
.scaleEffect(0.6)
.frame(width: 14, height: 14)
}
}
TextEditor(text: $fixNotesDraft)
.font(.body)
.frame(minHeight: 60, maxHeight: 200)
.scrollContentBackground(.hidden)
.padding(6)
.background(Color.secondary.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.2))
)
if fixNotesDirty && !isSavingFixNotes {
HStack(spacing: 8) {
Button("Save") { saveFixNotes() }
.keyboardShortcut(.return, modifiers: .command)
Button("Cancel") { fixNotesDraft = bug.fixNotes ?? "" }
}
.controlSize(.small)
}
}
}
private func saveFixNotes() {
isSavingFixNotes = true
Task {
do {
try await editService.updateFixNotes(bug, text: fixNotesDraft)
} catch {
handleEditError(error)
}
isSavingFixNotes = false
}
}
// MARK: - Screenshots
private var screenshotsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Screenshots")
.font(.headline)
ScrollView(.horizontal) {
HStack(spacing: 12) {
ForEach(bug.screenshots, id: \.fileToken) { attachment in
ScreenshotThumbnail(attachment: attachment)
}
addScreenshotTile
}
.padding(.vertical, 2)
}
}
}
private var addScreenshotTile: some View {
Button(action: pickScreenshots) {
VStack(spacing: 4) {
if isUploading {
ProgressView()
} else {
Image(systemName: "plus")
.font(.title2)
.foregroundStyle(.secondary)
Text("Add")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: 60, height: 120)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.secondary.opacity(0.3), style: StrokeStyle(dash: [4]))
)
}
.buttonStyle(.plain)
.disabled(isUploading)
.help("Add screenshot")
}
private func pickScreenshots() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image]
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.begin { response in
guard response == .OK else { return }
let images: [(name: String, data: Data, mimeType: String)] = panel.urls.compactMap { url in
guard let data = try? Data(contentsOf: url) else { return nil }
let ext = url.pathExtension.lowercased()
let mime = UTType(filenameExtension: ext)?.preferredMIMEType ?? "image/png"
return (url.lastPathComponent, data, mime)
}
guard !images.isEmpty else { return }
isUploading = true
Task {
do {
try await editService.addScreenshots(bug, images: images)
} catch {
handleEditError(error)
}
isUploading = false
}
}
}
// MARK: - Helpers
private func handleEditError(_ error: Error) {
if let editError = error as? EditError, editError == .needsReauth {
showNeedsReauth = true
} else {
self.editError = error
}
}
/// Approximate palette for Feishu select-option color indices.
private func feishuOptionColor(_ index: Int?) -> Color {
let palette: [Color] = [
.gray, .red, .orange, .yellow, .green, .teal, .cyan,
.blue, .indigo, .purple, .pink, .brown, .mint
]
guard let index else { return .gray }
return palette[abs(index) % palette.count]
}
private func openInFeishu() {
guard let url = bug.feishuURL else { return }
NSWorkspace.shared.open(url)
}
private func copyLink() {
guard let url = bug.feishuURL else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url.absoluteString, forType: .string)
}
private func ageString(_ age: TimeInterval) -> String {
let days = Int(age / 86_400)
let hours = Int(age / 3_600)
if days > 0 { return days == 1 ? "1 day" : "\(days) days" }
if hours > 0 { return hours == 1 ? "1 hour" : "\(hours) hours" }
return "less than an hour"
}
}
/// Thumbnail for a single attachment. Images are downloaded and shown inline;
/// tapping opens the file in the system viewer. Failed downloads retry on tap.
private struct ScreenshotThumbnail: View {
let attachment: BugAttachment
@State private var loader = AttachmentLoader.shared
var body: some View {
VStack(spacing: 4) {
ZStack {
if let image = loader.image(for: attachment) {
Image(nsImage: image)
.resizable()
.scaledToFit()
} else {
placeholder
}
}
.frame(width: 180, height: 120)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.secondary.opacity(0.2)))
Text(attachment.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.frame(width: 180)
}
.onAppear {
if attachment.isImage {
loader.load(attachment)
}
}
.onTapGesture {
switch loader.state(for: attachment) {
case .loaded:
loader.loadAndOpen(attachment)
case .failed:
loader.retry(attachment)
case .loading:
break
case nil:
loader.loadAndOpen(attachment)
}
}
.help(attachment.name)
}
@ViewBuilder
private var placeholder: some View {
switch loader.state(for: attachment) {
case .loading:
ProgressView()
case .failed:
VStack(spacing: 4) {
Image(systemName: "exclamationmark.arrow.triangle.2.circlepath")
.font(.title3)
.foregroundStyle(.orange)
Text("Tap to retry")
.font(.caption2)
.foregroundStyle(.secondary)
}
default:
VStack(spacing: 4) {
Image(systemName: attachment.isImage ? "photo" : "doc")
.font(.title2)
.foregroundStyle(.secondary)
if !attachment.isImage {
Text("Tap to open")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}

View File

@ -0,0 +1,116 @@
import SwiftUI
struct MainWindowView: View {
@State private var bugStore = BugStore.shared
@State private var windowState = MainWindowState.shared
@State private var showResolved = false
@State private var searchText = ""
private var filteredBugs: [Bug] {
let base = showResolved
? bugStore.bugs.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority < rhs.priority }
return lhs.createdAt < rhs.createdAt
}
: bugStore.bugsSortedByPriority
guard !searchText.isEmpty else { return base }
return base.filter { bug in
bug.title.localizedCaseInsensitiveContains(searchText)
|| bug.customer?.localizedCaseInsensitiveContains(searchText) == true
|| bug.reporter?.localizedCaseInsensitiveContains(searchText) == true
}
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
Picker("", selection: $showResolved) {
Text("Active").tag(false)
Text("All").tag(true)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 12)
.padding(.vertical, 8)
List(selection: $windowState.selectedBugID) {
ForEach(filteredBugs) { bug in
SidebarBugRow(
bug: bug,
isUnseen: bugStore.unseenBugs.contains(bug.id)
)
.tag(bug.id)
}
}
}
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 360)
.searchable(text: $searchText, placement: .sidebar, prompt: "Search bugs")
} detail: {
if let selectedID = windowState.selectedBugID {
if let bug = bugStore.bugs.first(where: { $0.id == selectedID }) {
BugDetailView(bug: bug, isUnseen: bugStore.unseenBugs.contains(bug.id))
} else {
ContentUnavailableView(
"Bug Not Found",
systemImage: "questionmark.circle",
description: Text("This bug is no longer in the list.")
)
}
} else {
ContentUnavailableView(
"Select a Bug",
systemImage: "ant",
description: Text("Choose a bug from the list to see its details.")
)
}
}
.onChange(of: windowState.selectedBugID) { _, newID in
guard let newID else { return }
bugStore.markSeen(newID)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await PollerService.shared.fetchNow() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.disabled(bugStore.isLoading)
}
}
}
}
private struct SidebarBugRow: View {
let bug: Bug
let isUnseen: Bool
var body: some View {
HStack(spacing: 8) {
PriorityBadge(priority: bug.priority)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(bug.title)
.font(.system(size: 13))
.lineLimit(1)
if isUnseen {
Circle()
.fill(.blue)
.frame(width: 6, height: 6)
}
}
HStack(spacing: 6) {
StatusPill(status: bug.status)
if let customer = bug.customer {
Text(customer)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
.padding(.vertical, 2)
}
}

View File

@ -5,6 +5,7 @@ struct BugListPopover: View {
@State private var bugStore = BugStore.shared
@State private var tokenManager = TokenManager.shared
@Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
var body: some View {
VStack(spacing: 0) {
@ -49,6 +50,19 @@ struct BugListPopover: View {
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
}
.font(.caption)
Button {
openWindow(id: "main")
DispatchQueue.main.async {
NSRunningApplication.current.activate(options: [.activateAllWindows])
}
AppDelegate.shared?.closePopover()
} label: {
Image(systemName: "macwindow")
.font(.caption)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Open main window")
}
.padding(.horizontal)
.padding(.vertical, 8)
@ -71,9 +85,11 @@ struct BugListPopover: View {
isUnseen: bugStore.unseenBugs.contains(bug.id)
)
.onTapGesture {
openInFeishu(bug)
bugStore.markSeen(bug.id)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
openDetails(bug)
}
.contextMenu {
Button("Open Details") { openDetails(bug) }
Button("Open in Feishu") { openInFeishu(bug) }
}
Divider().padding(.leading, 44)
}
@ -113,9 +129,24 @@ struct BugListPopover: View {
.font(.caption)
}
private func openDetails(_ bug: Bug) {
MainWindowState.shared.selectedBugID = bug.id
openWindow(id: "main")
// Accessory app: activate on the next runloop pass so the new
// window comes to the front instead of opening behind other apps.
DispatchQueue.main.async {
NSRunningApplication.current.activate(options: [.activateAllWindows])
}
bugStore.markSeen(bug.id)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
AppDelegate.shared?.closePopover()
}
private func openInFeishu(_ bug: Bug) {
guard let url = bug.feishuURL else { return }
NSWorkspace.shared.open(url)
bugStore.markSeen(bug.id)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
AppDelegate.shared?.closePopover()
}

View File

@ -236,6 +236,34 @@ struct SettingsView: View {
TextField("", text: $config.fieldMappings.updatedAtField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Description field") {
TextField("optional", text: $config.fieldMappings.descriptionField)
.textFieldStyle(.roundedBorder)
}
Text("Shown in the bug detail window. Leave empty to hide the description.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, kLabelWidth + 8)
labeledRow("Module field") {
TextField("对应模块", text: $config.fieldMappings.moduleField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Source field") {
TextField("来源", text: $config.fieldMappings.sourceField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Flow Status field") {
TextField("流转状态", text: $config.fieldMappings.flowStatusField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Fix Notes field") {
TextField("修复备注", text: $config.fieldMappings.fixNotesField)
.textFieldStyle(.roundedBorder)
}
labeledRow("Screenshots field") {
TextField("相关截图", text: $config.fieldMappings.screenshotsField)
.textFieldStyle(.roundedBorder)
}
}
Divider()

View File

@ -340,7 +340,7 @@ def build_pbxproj() -> str:
("CURRENT_PROJECT_VERSION", "5"),
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
("FEISHU_BASE_DOMAIN", '"xorbitlab.feishu.cn"'),
("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'),
("GENERATE_INFOPLIST_FILE", "NO"),
("INFOPLIST_FILE", "Resources/Info.plist"),
("LD_RUNPATH_SEARCH_PATHS",
@ -431,6 +431,13 @@ def build_xcscheme() -> str:
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
@ -452,6 +459,20 @@ def build_xcscheme() -> str:
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
"""