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:
parent
44e53e373d
commit
72cff00f72
|
|
@ -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 */;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue