From 8edb419954d47a4ba10096ef9fdcb26a53be9a16 Mon Sep 17 00:00:00 2001
From: Lava <34743145+CanglongCl@users.noreply.github.com>
Date: Wed, 17 Jan 2024 05:26:49 -0800
Subject: [PATCH] Refactor Setting - Service and provide service configuration
 view (#326)

* service tab refactor

* service configuration view

* add comment for ServiceStringConfigurationSection

* add comments for ConfigurableService

* rename openAIAPI with openAIAPIKey

* UI optimization

* revert schema

* fix: service setting in dark mode

* fix: cannot move and scroll position error

* introduce a view model in service tab

* delete unused code

* fix: do not post update notification if service enabled is not changed

* fix: resizing windows animation in service view

* small refactor on viewmodels

* refactor: ServiceItems

* reset selection after window type changes

* perf: update Localizable.xcstrings

---------

Co-authored-by: tisfeng <tisfeng@gmail.com>
Co-authored-by: phlpsong <103433299+phlpsong@users.noreply.github.com>
---
 Easydict.xcodeproj/project.pbxproj            |  40 ++-
 Easydict/App/Easydict-Bridging-Header.h       |   2 +
 Easydict/App/Localizable.xcstrings            | 148 +++++++++-
 Easydict/Feature/Service/Ali/AliService.swift |   7 +-
 .../Service/Caiyun/CaiyunService.swift        |   3 +-
 .../Feature/Service/OpenAI/EZOpenAIService.m  |   1 +
 .../Service/Tencent/TencentService.swift      |   5 +-
 .../NewApp/Configuration/Configuration.swift  |  35 +++
 .../OpenAIService+ConfigurableService.swift   |  35 +++
 .../Protocol/ConfigurableService.swift        |  29 ++
 Easydict/NewApp/View/ServiceItemView.swift    |  41 ---
 .../NewApp/View/SettingView/SettingView.swift |  33 ++-
 .../ServiceConfigurationSection.swift         |  84 ++++++
 .../View/SettingView/Tabs/ServiceTab.swift    | 262 +++++++++++-------
 14 files changed, 549 insertions(+), 176 deletions(-)
 create mode 100644 Easydict/NewApp/Utility/Extensions/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift
 create mode 100644 Easydict/NewApp/Utility/Protocol/ConfigurableService.swift
 delete mode 100644 Easydict/NewApp/View/ServiceItemView.swift
 create mode 100644 Easydict/NewApp/View/SettingView/Tabs/ServiceConfiguration/ServiceConfigurationSection.swift

diff --git a/Easydict.xcodeproj/project.pbxproj b/Easydict.xcodeproj/project.pbxproj
index adc5e75bc..9bb89217d 100644
--- a/Easydict.xcodeproj/project.pbxproj
+++ b/Easydict.xcodeproj/project.pbxproj
@@ -229,7 +229,6 @@
 		03FD68BB2B1DC59600FD388E /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 03FD68BA2B1DC59600FD388E /* CryptoSwift */; };
 		03FD68BE2B1E151A00FD388E /* String+EncryptAES.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03FD68BD2B1E151A00FD388E /* String+EncryptAES.swift */; };
 		0A057D6D2B499A000025C51D /* ServiceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A057D6C2B499A000025C51D /* ServiceTab.swift */; };
-		0A057D6F2B499A0B0025C51D /* ServiceItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A057D6E2B499A0B0025C51D /* ServiceItemView.swift */; };
 		0A2BA9602B49A989002872A4 /* Binding+DidSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2BA95F2B49A989002872A4 /* Binding+DidSet.swift */; };
 		0A2BA9642B4A3CCD002872A4 /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A2BA9632B4A3CCD002872A4 /* Notification+Name.swift */; };
 		0AC11B222B4D16A500F07198 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC11B212B4D16A500F07198 /* WindowAccessor.swift */; };
@@ -273,6 +272,9 @@
 		EA9943EE2B5353AB00EE7B97 /* WindowTypeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9943ED2B5353AB00EE7B97 /* WindowTypeExtensions.swift */; };
 		EA9943F02B5354C400EE7B97 /* ShowWindowPositionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9943EF2B5354C400EE7B97 /* ShowWindowPositionExtensions.swift */; };
 		EA9943F22B5358BF00EE7B97 /* LanguageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9943F12B5358BF00EE7B97 /* LanguageExtensions.swift */; };
+		EAED41EC2B54AA920005FE0A /* ServiceConfigurationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAED41EB2B54AA920005FE0A /* ServiceConfigurationSection.swift */; };
+		EAED41EF2B54B1430005FE0A /* ConfigurableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAED41EE2B54B1430005FE0A /* ConfigurableService.swift */; };
+		EAED41F22B54B39D0005FE0A /* OpenAIService+ConfigurableService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAED41F12B54B39D0005FE0A /* OpenAIService+ConfigurableService.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -702,7 +704,6 @@
 		03FD68BD2B1E151A00FD388E /* String+EncryptAES.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+EncryptAES.swift"; sourceTree = "<group>"; };
 		06E15747A7BD34D510ADC6A8 /* Pods-Easydict.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Easydict.debug.xcconfig"; path = "Target Support Files/Pods-Easydict/Pods-Easydict.debug.xcconfig"; sourceTree = "<group>"; };
 		0A057D6C2B499A000025C51D /* ServiceTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceTab.swift; sourceTree = "<group>"; };
-		0A057D6E2B499A0B0025C51D /* ServiceItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceItemView.swift; sourceTree = "<group>"; };
 		0A2BA95F2B49A989002872A4 /* Binding+DidSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+DidSet.swift"; sourceTree = "<group>"; };
 		0A2BA9632B4A3CCD002872A4 /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
 		0AC11B212B4D16A500F07198 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
@@ -761,6 +762,9 @@
 		EA9943ED2B5353AB00EE7B97 /* WindowTypeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTypeExtensions.swift; sourceTree = "<group>"; };
 		EA9943EF2B5354C400EE7B97 /* ShowWindowPositionExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowWindowPositionExtensions.swift; sourceTree = "<group>"; };
 		EA9943F12B5358BF00EE7B97 /* LanguageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageExtensions.swift; sourceTree = "<group>"; };
+		EAED41EB2B54AA920005FE0A /* ServiceConfigurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceConfigurationSection.swift; sourceTree = "<group>"; };
+		EAED41EE2B54B1430005FE0A /* ConfigurableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurableService.swift; sourceTree = "<group>"; };
+		EAED41F12B54B39D0005FE0A /* OpenAIService+ConfigurableService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAIService+ConfigurableService.swift"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -2051,7 +2055,6 @@
 			isa = PBXGroup;
 			children = (
 				27FE980A2B3DD5D1000AD654 /* MenuItemView.swift */,
-				0A057D6E2B499A0B0025C51D /* ServiceItemView.swift */,
 				0AC11B212B4D16A500F07198 /* WindowAccessor.swift */,
 				0AC11B232B4E46B300F07198 /* TapHandlerView.swift */,
 				27FE98072B3DD52B000AD654 /* SettingView */,
@@ -2071,6 +2074,7 @@
 		27FE980C2B3DD749000AD654 /* Tabs */ = {
 			isa = PBXGroup;
 			children = (
+				EAED41EA2B54A4900005FE0A /* ServiceConfiguration */,
 				278540332B3DE04F004E9488 /* GeneralTab.swift */,
 				0A057D6C2B499A000025C51D /* ServiceTab.swift */,
 				276742042B3DC230002A2C75 /* PrivacyTab.swift */,
@@ -2203,6 +2207,7 @@
 		EA9943DD2B534BAE00EE7B97 /* Utility */ = {
 			isa = PBXGroup;
 			children = (
+				EAED41ED2B54B1390005FE0A /* Protocol */,
 				EA9943E62B534D7C00EE7B97 /* Extensions */,
 			);
 			path = Utility;
@@ -2219,6 +2224,7 @@
 		EA9943E62B534D7C00EE7B97 /* Extensions */ = {
 			isa = PBXGroup;
 			children = (
+				EAED41F02B54B1A60005FE0A /* QueryService+ConfigurableService */,
 				EA9943E72B534D8900EE7B97 /* LanguageDetectOptimizeExtensions.swift */,
 				EA9943ED2B5353AB00EE7B97 /* WindowTypeExtensions.swift */,
 				EA9943EF2B5354C400EE7B97 /* ShowWindowPositionExtensions.swift */,
@@ -2227,6 +2233,30 @@
 			path = Extensions;
 			sourceTree = "<group>";
 		};
+		EAED41EA2B54A4900005FE0A /* ServiceConfiguration */ = {
+			isa = PBXGroup;
+			children = (
+				EAED41EB2B54AA920005FE0A /* ServiceConfigurationSection.swift */,
+			);
+			path = ServiceConfiguration;
+			sourceTree = "<group>";
+		};
+		EAED41ED2B54B1390005FE0A /* Protocol */ = {
+			isa = PBXGroup;
+			children = (
+				EAED41EE2B54B1430005FE0A /* ConfigurableService.swift */,
+			);
+			path = Protocol;
+			sourceTree = "<group>";
+		};
+		EAED41F02B54B1A60005FE0A /* QueryService+ConfigurableService */ = {
+			isa = PBXGroup;
+			children = (
+				EAED41F12B54B39D0005FE0A /* OpenAIService+ConfigurableService.swift */,
+			);
+			path = "QueryService+ConfigurableService";
+			sourceTree = "<group>";
+		};
 /* End PBXGroup section */
 
 /* Begin PBXNativeTarget section */
@@ -2602,7 +2632,6 @@
 				03991166292A8A4400E1B06D /* EZTitleBarMoveView.m in Sources */,
 				03542A582937CC3200C34C33 /* EZConfiguration.m in Sources */,
 				27FE98092B3DD536000AD654 /* SettingView.swift in Sources */,
-				0A057D6F2B499A0B0025C51D /* ServiceItemView.swift in Sources */,
 				035E37E72A0953120061DFAF /* EZToast.m in Sources */,
 				03542A492937B5CF00C34C33 /* EZGoogleTranslate.m in Sources */,
 				03D0435A2928C4C800E7559E /* EZWindowManager.m in Sources */,
@@ -2634,6 +2663,7 @@
 				03F14A3B2956016B00CB7379 /* EZVolcanoTranslate.m in Sources */,
 				03B0230429231FA6001C7E63 /* EZHoverButton.m in Sources */,
 				0342A9812AD64924002A9F5F /* NSString+EZSplit.m in Sources */,
+				EAED41EF2B54B1430005FE0A /* ConfigurableService.swift in Sources */,
 				03BD2825294875AE00F5891A /* EZMyLabel.m in Sources */,
 				03B0233029231FA6001C7E63 /* MMCrashUncaughtExceptionHandler.m in Sources */,
 				03D5FCFF2A5EF4E400AD26BE /* EZDeviceSystemInfo.m in Sources */,
@@ -2775,6 +2805,7 @@
 				039F5508294B6E29004AB940 /* EZAboutViewController.m in Sources */,
 				03D8A6592A42A1A300D9A968 /* EZAppModel.m in Sources */,
 				036E7D7B293F4FC8002675DF /* EZOpenLinkButton.m in Sources */,
+				EAED41EC2B54AA920005FE0A /* ServiceConfigurationSection.swift in Sources */,
 				276742092B3DC230002A2C75 /* AboutTab.swift in Sources */,
 				03008B2E2941956D0062B821 /* EZURLSchemeHandler.m in Sources */,
 				DC6D9C872B352EBC0055EFFC /* FontSizeHintView.swift in Sources */,
@@ -2790,6 +2821,7 @@
 				03008B3F29444B0A0062B821 /* NSView+EZAnimatedHidden.m in Sources */,
 				03B022FD29231FA6001C7E63 /* EZFixedQueryWindow.m in Sources */,
 				03B0232C29231FA6001C7E63 /* NSView+MM.m in Sources */,
+				EAED41F22B54B39D0005FE0A /* OpenAIService+ConfigurableService.swift in Sources */,
 				033C31002A74CECE0095926A /* EZAppleDictionary.m in Sources */,
 				03E2BF752A298F2B00E010F3 /* NSString+EZUtils.m in Sources */,
 				03B022F529231FA6001C7E63 /* EZDetectManager.m in Sources */,
diff --git a/Easydict/App/Easydict-Bridging-Header.h b/Easydict/App/Easydict-Bridging-Header.h
index 8030b13fa..f98fcafec 100644
--- a/Easydict/App/Easydict-Bridging-Header.h
+++ b/Easydict/App/Easydict-Bridging-Header.h
@@ -26,3 +26,5 @@
 #import "NSString+EZConvenience.h"
 #import "EZWindowManager.h"
 #import "NSViewController+EZWindow.h"
+
+#import "EZOpenAIService.h"
diff --git a/Easydict/App/Localizable.xcstrings b/Easydict/App/Localizable.xcstrings
index ae094d259..2114e3a63 100644
--- a/Easydict/App/Localizable.xcstrings
+++ b/Easydict/App/Localizable.xcstrings
@@ -1,16 +1,6 @@
 {
   "sourceLanguage" : "en",
   "strings" : {
-    "" : {
-      "localizations" : {
-        "zh-Hans" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : ""
-          }
-        }
-      }
-    },
     "about" : {
       "comment" : "about",
       "localizations" : {
@@ -505,7 +495,7 @@
         },
         "zh-Hans" : {
           "stringUnit" : {
-            "state" : "needs_review",
+            "state" : "translated",
             "value" : "[Beta] SwiftUI App模式"
           }
         }
@@ -1827,7 +1817,14 @@
       }
     },
     "none_window" : {
-
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : ""
+          }
+        }
+      }
     },
     "ocr_result_is_empty" : {
       "localizations" : {
@@ -2316,6 +2313,92 @@
         }
       }
     },
+    "service.configuration.openai.api_key.footer" : {
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "API Key的一些说明或者加入链接"
+          }
+        }
+      }
+    },
+    "service.configuration.openai.api_key.header" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "OpenAI API Key"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "OpenAI API Key"
+          }
+        }
+      }
+    },
+    "service.configuration.openai.api_key.prompt" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+          }
+        }
+      }
+    },
+    "service.configuration.openai.api_key.title" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "OpenAI API Key"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "OpenAI API Key"
+          }
+        }
+      }
+    },
+    "service.configuration.openai.translation.footer" : {
+
+    },
+    "service.configuration.openai.translation.header" : {
+
+    },
+    "service.configuration.openai.translation.prompt" : {
+
+    },
+    "service.configuration.openai.translation.title" : {
+
+    },
+    "service.service_configuration.reset" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Reset"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "重置"
+          }
+        }
+      }
+    },
     "setting_general" : {
       "localizations" : {
         "en" : {
@@ -2780,6 +2863,38 @@
         }
       }
     },
+    "setting.service.detail.no_configuration %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "No configuration for %@"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "%@没有可供配置的选项"
+          }
+        }
+      }
+    },
+    "setting.service.detail.no_selection" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Select a service to show configuration"
+          }
+        },
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "选择服务以查看配置"
+          }
+        }
+      }
+    },
     "setting.tts_service.options.apple" : {
       "localizations" : {
         "en" : {
@@ -3205,7 +3320,14 @@
       }
     },
     "unknown_option" : {
-
+      "localizations" : {
+        "zh-Hans" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : ""
+          }
+        }
+      }
     },
     "unpin" : {
       "localizations" : {
diff --git a/Easydict/Feature/Service/Ali/AliService.swift b/Easydict/Feature/Service/Ali/AliService.swift
index 6ba555109..9788fc063 100644
--- a/Easydict/Feature/Service/Ali/AliService.swift
+++ b/Easydict/Feature/Service/Ali/AliService.swift
@@ -8,6 +8,7 @@
 
 import Alamofire
 import CryptoKit
+import Defaults
 import Foundation
 
 @objc(EZAliService)
@@ -76,8 +77,10 @@ class AliService: QueryService {
          easydict://writeKeyValue?EZAliAccessKeyId=
          easydict://writeKeyValue?EZAliAccessKeySecret=
          */
-        if let id = UserDefaults.standard.string(forKey: EZAliAccessKeyId),
-           let secret = UserDefaults.standard.string(forKey: EZAliAccessKeySecret), !id.isEmpty, !secret.isEmpty
+        if let id = Defaults[.aliAccessKeyId],
+           let secret = Defaults[.aliAccessKeySecret],
+           !id.isEmpty,
+           !secret.isEmpty
         {
             requestByAPI(id: id, secret: secret, transType: transType, text: text, from: from, to: to, completion: completion)
         } else { // use web api
diff --git a/Easydict/Feature/Service/Caiyun/CaiyunService.swift b/Easydict/Feature/Service/Caiyun/CaiyunService.swift
index 2c6b3dcf3..b36cfbdf8 100644
--- a/Easydict/Feature/Service/Caiyun/CaiyunService.swift
+++ b/Easydict/Feature/Service/Caiyun/CaiyunService.swift
@@ -7,6 +7,7 @@
 //
 
 import Alamofire
+import Defaults
 import Foundation
 
 @objc(EZCaiyunService)
@@ -44,7 +45,7 @@ public final class CaiyunService: QueryService {
 
     // easydict://writeKeyValue?EZCaiyunToken=
     private var token: String {
-        let token = UserDefaults.standard.string(forKey: EZCaiyunToken)
+        let token = Defaults[.caiyunToken]
         if let token, !token.isEmpty {
             return token
         } else {
diff --git a/Easydict/Feature/Service/OpenAI/EZOpenAIService.m b/Easydict/Feature/Service/OpenAI/EZOpenAIService.m
index ad01eff55..1123049e4 100644
--- a/Easydict/Feature/Service/OpenAI/EZOpenAIService.m
+++ b/Easydict/Feature/Service/OpenAI/EZOpenAIService.m
@@ -28,6 +28,7 @@ @interface EZOpenAIService ()
 
 @end
 
+
 @implementation EZOpenAIService
 
 - (instancetype)init {
diff --git a/Easydict/Feature/Service/Tencent/TencentService.swift b/Easydict/Feature/Service/Tencent/TencentService.swift
index cc75b7db0..aaa07b9ab 100644
--- a/Easydict/Feature/Service/Tencent/TencentService.swift
+++ b/Easydict/Feature/Service/Tencent/TencentService.swift
@@ -7,6 +7,7 @@
 //
 
 import Alamofire
+import Defaults
 import Foundation
 
 @objc(EZTencentService)
@@ -64,7 +65,7 @@ public final class TencentService: QueryService {
 
     // easydict://writeKeyValue?EZTencentSecretId=xxx
     private var secretId: String {
-        let secretId = UserDefaults.standard.string(forKey: EZTencentSecretId)
+        let secretId = Defaults[.tencentSecretId]
         if let secretId, !secretId.isEmpty {
             return secretId
         } else {
@@ -74,7 +75,7 @@ public final class TencentService: QueryService {
 
     // easydict://writeKeyValue?EZTencentSecretKey=xxx
     private var secretKey: String {
-        let secretKey = UserDefaults.standard.string(forKey: EZTencentSecretKey)
+        let secretKey = Defaults[.tencentSecretKey]
         if let secretKey, !secretKey.isEmpty {
             return secretKey
         } else {
diff --git a/Easydict/NewApp/Configuration/Configuration.swift b/Easydict/NewApp/Configuration/Configuration.swift
index 976ab72f5..5777aa144 100644
--- a/Easydict/NewApp/Configuration/Configuration.swift
+++ b/Easydict/NewApp/Configuration/Configuration.swift
@@ -9,6 +9,7 @@
 import Defaults
 import Foundation
 
+// Setting
 extension Defaults.Keys {
     // rename `from`
     static let queryFromLanguage = Key<Language>("EZConfiguration_kFromKey", default: .auto)
@@ -52,3 +53,37 @@ extension Defaults.Keys {
     static let appearanceType = Key<AppearenceType>("EZConfiguration_kApperanceKey", default: .followSystem)
     static let fontSizeOptionIndex = Key<UInt>("EZConfiguration_kTranslationControllerFontKey", default: 0)
 }
+
+// Service Configuration
+extension Defaults.Keys {
+    // OPENAI
+    static let openAIAPIKey = Key<String?>("EZOpenAIAPIKey")
+    static let openAITranslation = Key<String?>("EZOpenAITranslationKey")
+    static let openAIDictionary = Key<String?>("EZOpenAIDictionaryKey")
+    static let openAISentence = Key<String?>("EZOpenAISentenceKey")
+    static let openAIServiceUsageStatus = Key<String?>("EZOpenAIServiceUsageStatusKey")
+    static let openAIDomain = Key<String?>("EZOpenAIDomainKey")
+    static let openAIEndPoint = Key<String?>("EZOpenAIEndPointKey")
+    static let openAIModel = Key<String?>("EZOpenAIModelKey")
+
+    // DEEPL
+    static let deepLAuth = Key<String?>("EZDeepLAuthKey")
+    static let deepLTranslateEndPointKey = Key<String?>("EZDeepLTranslateEndPointKey")
+
+    // BING
+    static let bingCookieKey = Key<String?>("EZBingCookieKey")
+
+    // niu
+    static let niuTransAPIKey = Key<String?>("EZNiuTransAPIKey")
+
+    // Caiyun
+    static let caiyunToken = Key<String?>("EZCaiyunToken")
+
+    // tencent
+    static let tencentSecretId = Key<String?>("EZTencentSecretId")
+    static let tencentSecretKey = Key<String?>("EZTencentSecretKey")
+
+    // ALI
+    static let aliAccessKeyId = Key<String?>("EZAliAccessKeyId")
+    static let aliAccessKeySecret = Key<String?>("EZAliAccessKeySecret")
+}
diff --git a/Easydict/NewApp/Utility/Extensions/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift b/Easydict/NewApp/Utility/Extensions/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift
new file mode 100644
index 000000000..960cada25
--- /dev/null
+++ b/Easydict/NewApp/Utility/Extensions/QueryService+ConfigurableService/OpenAIService+ConfigurableService.swift
@@ -0,0 +1,35 @@
+//
+//  OpenAIService+ConfigurableService.swift
+//  Easydict
+//
+//  Created by 戴藏龙 on 2024/1/14.
+//  Copyright © 2024 izual. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+@available(macOS 12.0, *)
+extension EZOpenAIService: ConfigurableService {
+    func configurationListItems() -> some View {
+        ServiceStringConfigurationSection(
+            textFieldTitleKey: "service.configuration.openai.api_key.header",
+            headerTitleKey: "service.configuration.openai.api_key.title",
+            key: .openAIAPIKey,
+            prompt: "service.configuration.openai.api_key.prompt",
+            footer: {
+                Text("service.configuration.openai.api_key.footer")
+            }
+        )
+
+        ServiceStringConfigurationSection(
+            textFieldTitleKey: "service.configuration.openai.translation.header",
+            headerTitleKey: "service.configuration.openai.translation.title",
+            key: .openAITranslation,
+            prompt: "service.configuration.openai.translation.prompt",
+            footer: {
+                Text("service.configuration.openai.translation.footer")
+            }
+        )
+    }
+}
diff --git a/Easydict/NewApp/Utility/Protocol/ConfigurableService.swift b/Easydict/NewApp/Utility/Protocol/ConfigurableService.swift
new file mode 100644
index 000000000..058151a5c
--- /dev/null
+++ b/Easydict/NewApp/Utility/Protocol/ConfigurableService.swift
@@ -0,0 +1,29 @@
+//
+//  ConfigurableService.swift
+//  Easydict
+//
+//  Created by 戴藏龙 on 2024/1/14.
+//  Copyright © 2024 izual. All rights reserved.
+//
+
+import Foundation
+import SwiftUI
+
+/// A service can provide configuration view in setting
+protocol ConfigurableService {
+    associatedtype T: View
+
+    /// Items in Configuration Form. Use ServiceStringConfigurationSection or other customize view.
+    @ViewBuilder
+    func configurationListItems() -> T
+}
+
+@available(macOS 13.0, *)
+extension ConfigurableService {
+    func configurationView() -> some View {
+        Form {
+            configurationListItems()
+        }
+        .formStyle(.grouped)
+    }
+}
diff --git a/Easydict/NewApp/View/ServiceItemView.swift b/Easydict/NewApp/View/ServiceItemView.swift
deleted file mode 100644
index d21f17e98..000000000
--- a/Easydict/NewApp/View/ServiceItemView.swift
+++ /dev/null
@@ -1,41 +0,0 @@
-//
-//  ServiceItemView.swift
-//  Easydict
-//
-//  Created by phlpsong on 2024/1/6.
-//  Copyright © 2024 izual. All rights reserved.
-//
-
-import SwiftUI
-
-@available(macOS 13.0, *)
-struct ServiceItemView: View {
-    @Binding var service: QueryService
-
-    var toggleValueChanged: (Bool) -> Void
-
-    var body: some View {
-        HStack {
-            Image(nsImage: NSImage(named: service.serviceType().rawValue) ?? NSImage())
-                .resizable()
-                .frame(maxWidth: 18.0, maxHeight: 18.0)
-
-            Text(service.name())
-
-            Toggle(isOn: $service.enabled.didSet(execute: { value in
-                toggleValueChanged(value)
-            })) {}
-                .toggleStyle(.switch)
-                .controlSize(.small)
-        }
-        .padding(4.0)
-    }
-}
-
-@available(macOS 13, *)
-#Preview {
-    let service = EZLocalStorage.shared().allServices(.mini).first ?? QueryService()
-    return ServiceItemView(service: .constant(service)) { val in
-        print("toggle value changed: \(val)")
-    }
-}
diff --git a/Easydict/NewApp/View/SettingView/SettingView.swift b/Easydict/NewApp/View/SettingView/SettingView.swift
index 8c4fec008..a7e27c991 100644
--- a/Easydict/NewApp/View/SettingView/SettingView.swift
+++ b/Easydict/NewApp/View/SettingView/SettingView.swift
@@ -17,41 +17,48 @@ enum SettingTab: Int {
 
 @available(macOS 13, *)
 struct SettingView: View {
-    @State private var selection = SettingTab.general.rawValue
+    @State private var selection = SettingTab.general
     @State private var window: NSWindow?
 
     var body: some View {
-        TabView(selection: $selection.didSet(execute: { _ in
-            resizeWindowFrame()
-        })) {
+        TabView(selection: $selection) {
             GeneralTab()
                 .tabItem { Label("setting_general", systemImage: "gear") }
-                .tag(SettingTab.general.rawValue)
+                .tag(SettingTab.general)
 
             ServiceTab()
                 .tabItem { Label("service", systemImage: "briefcase") }
-                .tag(SettingTab.service.rawValue)
+                .tag(SettingTab.service)
 
             PrivacyTab()
                 .tabItem { Label("privacy", systemImage: "hand.raised.square") }
-                .tag(SettingTab.privacy.rawValue)
+                .tag(SettingTab.privacy)
 
             AboutTab()
                 .tabItem { Label("about", systemImage: "info.bubble") }
-                .tag(SettingTab.about.rawValue)
+                .tag(SettingTab.about)
         }
-        .background(WindowAccessor(window: $window.didSet(execute: { _ in
-            // reset frame when first launch
+        .background(
+            WindowAccessor(window: $window.didSet(execute: { _ in
+                // reset frame when first launch
+                resizeWindowFrame()
+            }))
+        )
+        .onChange(of: selection) { _ in
             resizeWindowFrame()
-        })))
+        }
     }
 
     func resizeWindowFrame() {
         guard let window else { return }
 
         let originalFrame = window.frame
-        let newSize = selection == SettingTab.service.rawValue
-            ? CGSize(width: 360, height: 520) : CGSize(width: 500, height: 400)
+        let newSize = switch selection {
+        case .general, .privacy, .about:
+            CGSize(width: 500, height: 520)
+        case .service:
+            CGSize(width: 800, height: 520)
+        }
 
         let newY = originalFrame.origin.y + originalFrame.size.height - newSize.height
         let newRect = NSRect(origin: CGPoint(x: originalFrame.origin.x, y: newY), size: newSize)
diff --git a/Easydict/NewApp/View/SettingView/Tabs/ServiceConfiguration/ServiceConfigurationSection.swift b/Easydict/NewApp/View/SettingView/Tabs/ServiceConfiguration/ServiceConfigurationSection.swift
new file mode 100644
index 000000000..42c59d0ee
--- /dev/null
+++ b/Easydict/NewApp/View/SettingView/Tabs/ServiceConfiguration/ServiceConfigurationSection.swift
@@ -0,0 +1,84 @@
+//
+//  ServiceConfigurationSection.swift
+//  Easydict
+//
+//  Created by 戴藏龙 on 2024/1/14.
+//  Copyright © 2024 izual. All rights reserved.
+//
+
+import Defaults
+import SwiftUI
+
+@available(macOS 12.0, *)
+struct ServiceStringConfigurationSection<F: View>: View {
+    /// Title of text field
+    let textFieldTitleKey: LocalizedStringKey
+    /// Header of section. If there is no need to add an header, just leave empty string
+    let headerTitleKey: LocalizedStringKey
+    /// Defaults key for configuration. Please refer to `Configuration` - `Configuration`
+    let key: Defaults.Key<String?>
+    /// Prompt of text field
+    let prompt: LocalizedStringKey
+    /// Footer of section. Add comments, footnotes or links to describe the field.
+    @ViewBuilder let footer: () -> F
+
+    var body: some View {
+        ServiceConfigurationSection(
+            headerTitleKey,
+            key: key,
+            field: { value in
+                let value = Binding<String>.init {
+                    value.wrappedValue ?? ""
+                } set: { newValue in
+                    value.wrappedValue = newValue
+                }
+                TextField(textFieldTitleKey, text: value, prompt: Text(prompt))
+            },
+            footer: footer
+        )
+    }
+}
+
+@available(macOS 12.0, *)
+struct ServiceConfigurationSection<T: _DefaultsSerializable, F: View, V: View>: View {
+    @Default var value: T
+
+    init(
+        _ titleKey: LocalizedStringKey,
+        key: Defaults.Key<T>,
+        @ViewBuilder field: @escaping (Binding<T>) -> V,
+        footer: (() -> F)?
+    ) {
+        self.titleKey = titleKey
+        _value = .init(key)
+        self.footer = footer
+        self.field = field
+    }
+
+    let field: (Binding<T>) -> V
+    let footer: (() -> F)?
+
+    let titleKey: LocalizedStringKey
+
+    var body: some View {
+        Section {
+            field($value)
+        } header: {
+            HStack(alignment: .lastTextBaseline) {
+                Text(titleKey)
+                Spacer()
+                Button("service.service_configuration.reset") {
+                    _value.reset()
+                }
+                .buttonStyle(.plain)
+                .foregroundStyle(Color.accentColor)
+                .font(.footnote)
+            }
+        } footer: {
+            if let footer {
+                footer()
+                    .font(.footnote)
+            }
+        }
+    }
+}
diff --git a/Easydict/NewApp/View/SettingView/Tabs/ServiceTab.swift b/Easydict/NewApp/View/SettingView/Tabs/ServiceTab.swift
index 3da7ca423..5399f9af7 100644
--- a/Easydict/NewApp/View/SettingView/Tabs/ServiceTab.swift
+++ b/Easydict/NewApp/View/SettingView/Tabs/ServiceTab.swift
@@ -6,141 +6,203 @@
 //  Copyright © 2024 izual. All rights reserved.
 //
 
+import Combine
 import SwiftUI
 
 @available(macOS 13, *)
 struct ServiceTab: View {
-    @State private var windowTypeValue = EZWindowType.mini.rawValue
-    @State private var serviceTypes: [ServiceType] = []
-    @State private var services: [QueryService] = []
-    @State private var selectedIndex: Int?
-    // workaround for tap gesture conflict with onMove
-    @State private var isNeedTapHandler = true
-
-    var segmentCtrl: some View {
-        Picker("", selection: $windowTypeValue) {
-            Text("mini_window")
-                .tag(EZWindowType.mini.rawValue)
-
-            Text("fixed_window")
-                .tag(EZWindowType.fixed.rawValue)
-
-            Text("main_window")
-                .tag(EZWindowType.main.rawValue)
-        }
-        .padding()
-        .pickerStyle(.segmented)
-        .onChange(of: windowTypeValue) { type in
-            loadService(type: type)
-            selectedIndex = nil
-        }
+    @StateObject private var viewModel: ServiceTabViewModel = .init()
+
+    @Environment(\.colorScheme) private var colorScheme
+
+    var bgColor: Color {
+        Color(nsColor: colorScheme == .light ? .windowBackgroundColor : .controlBackgroundColor)
     }
 
-    var serviceList: some View {
-        List {
-            ForEach(Array(zip(serviceTypes.indices, serviceTypes)), id: \.0) { index, _ in
-                ServiceItemView(
-                    service: $services[index]
-                ) { isEnable in
-                    serviceToggled(index: index, isEnable: isEnable)
-                    selectedIndex = nil
-                    isNeedTapHandler = false
+    var tableColor: Color {
+        Color(nsColor: colorScheme == .light ? .ez_tableRowViewBgLight() : .ez_tableRowViewBgDark())
+    }
+
+    var body: some View {
+        HStack {
+            VStack {
+                WindowTypePicker(windowType: $viewModel.windowType)
+                    .padding()
+                List {
+                    ServiceItems()
                 }
-                .frame(height: 30)
-                .tag(index)
-                .listRowBackground(selectedIndex == index ? Color("service_cell_highlight") : Color.clear)
-                .overlay(TapHandler(tapAction: {
-                    if !isNeedTapHandler {
-                        isNeedTapHandler.toggle()
-                        return
-                    }
-                    if selectedIndex == nil || selectedIndex != index {
-                        selectedIndex = index
+                .scrollContentBackground(.hidden)
+                .listStyle(.plain)
+                .scrollIndicators(.never)
+                .clipShape(RoundedRectangle(cornerRadius: 10))
+                .background(bgColor, in: RoundedRectangle(cornerRadius: 10))
+                .padding(.bottom)
+                .padding(.horizontal)
+            }
+            .background(bgColor)
+            Group {
+                if let service = viewModel.selectedService {
+                    // To provide configuration options for a service, follow these steps
+                    // 1. If the Service is an object of Objc, expose it to Swift.
+                    // 2. Create a new file in the Utility - Extensions - QueryService+ConfigurableService,
+                    // 3. referring to OpenAIService+ConfigurableService, `extension` the Service as `ConfigurableService` to provide the configuration items.
+                    if let service = service as? (any ConfigurableService) {
+                        AnyView(service.configurationView())
                     } else {
-                        selectedIndex = nil
+                        HStack {
+                            Spacer()
+                            // No configuration for service xxx
+                            Text("setting.service.detail.no_configuration \(service.name())")
+                            Spacer()
+                        }
+                    }
+                } else {
+                    HStack {
+                        Spacer()
+                        Text("setting.service.detail.no_selection")
+                        Spacer()
                     }
-                }))
+                }
             }
-            .onMove(perform: { indices, newOffset in
-                onServiceItemMove(fromOffsets: indices, toOffset: newOffset)
-                selectedIndex = nil
-            })
-            .listRowSeparator(.hidden)
+            .layoutPriority(1)
         }
-        .scrollIndicators(.hidden)
-        .listStyle(.plain)
-        .clipShape(RoundedRectangle(cornerRadius: 8.0))
-        .padding([.horizontal, .bottom])
+        .environmentObject(viewModel)
     }
+}
 
-    var body: some View {
-        VStack {
-            segmentCtrl
-
-            serviceList
-        }
-        .onAppear {
-            loadService(type: windowTypeValue)
+private class ServiceTabViewModel: ObservableObject {
+    @Published var windowType = EZWindowType.mini {
+        didSet {
+            if oldValue != windowType {
+                updateServices()
+                selectedService = nil
+            }
         }
     }
 
-    func loadService(type: Int) {
-        let windowType = EZWindowType(rawValue: type) ?? .none
-        services = EZLocalStorage.shared().allServices(windowType)
-        serviceTypes = services.compactMap { $0.serviceType() }
-    }
+    @Published var selectedService: QueryService?
 
-    func serviceToggled(index: Int, isEnable: Bool) {
-        let service = services[index]
-        service.enabled = isEnable
-        if isEnable {
-            service.enabledQuery = true
-        }
-        let windowType = EZWindowType(rawValue: windowTypeValue) ?? .none
-        EZLocalStorage.shared().setService(services[index], windowType: windowType)
-        // refresh service list
-        loadService(type: windowTypeValue)
-        postUpdateServiceNotification()
+    @Published private(set) var services: [QueryService] = EZLocalStorage.shared().allServices(.mini)
+
+    func updateServices() {
+        services = getServices()
     }
 
-    func enabledServices(in services: [QueryService]) -> [QueryService] {
-        services.filter(\.enabled)
+    func getServices() -> [QueryService] {
+        EZLocalStorage.shared().allServices(windowType)
     }
 
     func onServiceItemMove(fromOffsets: IndexSet, toOffset: Int) {
-        let oldEnabledServices = enabledServices(in: services)
+        var services = services
 
         services.move(fromOffsets: fromOffsets, toOffset: toOffset)
-        serviceTypes.move(fromOffsets: fromOffsets, toOffset: toOffset)
 
-        let windowType = EZWindowType(rawValue: windowTypeValue) ?? .none
+        let serviceTypes = services.map { service in
+            service.serviceType()
+        }
+
         EZLocalStorage.shared().setAllServiceTypes(serviceTypes, windowType: windowType)
-        let newServices = EZLocalStorage.shared().allServices(windowType)
-        let newEnabledServices = enabledServices(in: newServices)
 
-        // post notification after enabled services order changed
-        if isEnabledServicesOrderChanged(source: oldEnabledServices, dest: newEnabledServices) {
-            postUpdateServiceNotification()
-        }
-    }
+        postUpdateServiceNotification()
 
-    func isEnabledServicesOrderChanged(
-        source: [QueryService],
-        dest: [QueryService]
-    ) -> Bool {
-        !source.elementsEqual(dest) { sItem, dItem in
-            sItem.serviceType() == dItem.serviceType() && sItem.name() == dItem.name()
-        }
+        updateServices()
     }
 
     func postUpdateServiceNotification() {
-        let userInfo: [String: Any] = [EZWindowTypeKey: windowTypeValue]
+        let userInfo: [String: Any] = [EZWindowTypeKey: windowType.rawValue]
         let notification = Notification(name: .serviceHasUpdated, object: nil, userInfo: userInfo)
         NotificationCenter.default.post(notification)
     }
 }
 
+@available(macOS 13.0, *)
+private struct ServiceItems: View {
+    @EnvironmentObject private var viewModel: ServiceTabViewModel
+
+    private var servicesWithID: [(QueryService, String)] {
+        viewModel.services.map { service in
+            (service, service.name())
+        }
+    }
+
+    var body: some View {
+        ForEach(servicesWithID, id: \.1) { service, _ in
+            ServiceItemView(service: service)
+                .tag(service)
+        }
+        .onMove(perform: viewModel.onServiceItemMove)
+    }
+}
+
+@available(macOS 13.0, *)
+private struct ServiceItemView: View {
+    let service: QueryService
+
+    @EnvironmentObject private var viewModel: ServiceTabViewModel
+
+    private var enabled: Binding<Bool> {
+        .init {
+            service.enabled
+        } set: { newValue in
+            guard service.enabled != newValue else { return }
+            service.enabled = newValue
+            if newValue {
+                service.enabledQuery = newValue
+            }
+            EZLocalStorage.shared().setService(service, windowType: viewModel.windowType)
+            viewModel.postUpdateServiceNotification()
+        }
+    }
+
+    var body: some View {
+        Toggle(isOn: enabled) {
+            HStack {
+                Image(service.serviceType().rawValue)
+                    .resizable()
+                    .scaledToFit()
+                    .frame(width: 20.0, height: 20.0)
+                Text(service.name())
+                    .lineLimit(1)
+                    .fixedSize()
+            }
+        }
+        .padding(4.0)
+        .toggleStyle(.switch)
+        .controlSize(.small)
+        .listRowSeparator(.hidden)
+        .listRowInsets(.init())
+        .padding(10)
+        .listRowBackground(viewModel.selectedService == service ? Color("service_cell_highlight") : tableColor)
+        .overlay {
+            TapHandler {
+                viewModel.selectedService = service
+            }
+        }
+    }
+
+    @Environment(\.colorScheme) private var colorScheme
+
+    private var tableColor: Color {
+        Color(nsColor: colorScheme == .light ? .ez_tableRowViewBgLight() : .ez_tableRowViewBgDark())
+    }
+}
+
 @available(macOS 13, *)
-#Preview {
-    ServiceTab()
+private struct WindowTypePicker: View {
+    @Binding var windowType: EZWindowType
+
+    var body: some View {
+        HStack {
+            Picker(selection: $windowType) {
+                ForEach([EZWindowType]([.mini, .fixed, .main]), id: \.rawValue) { windowType in
+                    Text(windowType.localizedStringResource)
+                        .tag(windowType)
+                }
+            } label: {
+                EmptyView()
+            }
+            .labelsHidden()
+            .pickerStyle(.segmented)
+        }
+    }
 }