瀏覽代碼

feat:新增站点信息管理,并修改大量bug,生成发布大版本

Jerry Liang 6 月之前
父節點
當前提交
f7234698e6
共有 100 個文件被更改,包括 7355 次插入 和 848 次删除
  1. 20 2
      eslint.config.ts
  2. 56 53
      package.json
  3. 464 315
      pnpm-lock.yaml
  4. 31 21
      service/.vscode/settings.json
  5. 4 0
      service/cache/cache.go
  6. 27 0
      service/cache/redisx/lockx.go
  7. 62 29
      service/config/config.go
  8. 132 0
      service/controllers/siteInfoManageControllers.go
  9. 106 0
      service/controllers/userInfoControllers.go
  10. 1 1
      service/database/database.go
  11. 87 0
      service/domain/fwhProDomain.go
  12. 254 0
      service/domain/sitesInfoManageDomain.go
  13. 173 0
      service/domain/smartHubDomain.go
  14. 24 0
      service/domain/userInfoDomain.go
  15. 3 2
      service/domain/xlogDomain.go
  16. 184 0
      service/dto/sitesInfoManageDTO.go
  17. 28 14
      service/dto/sitesMonitorDTO.go
  18. 3 1
      service/go.mod
  19. 4 0
      service/go.sum
  20. 9 6
      service/main.go
  21. 5 0
      service/middleware/auth.go
  22. 64 17
      service/networkEvents/evioEvents.go
  23. 82 0
      service/repositories/api/acceleronix/acceleronixHttpClient.go
  24. 105 0
      service/repositories/api/acceleronix/simCardManage.go
  25. 32 6
      service/repositories/api/fwh_pro/fwhHttpClient.go
  26. 3 7
      service/repositories/api/fwh_pro/fwh_emsLog.go
  27. 2 5
      service/repositories/api/fwh_pro/fwh_eventAlarmLog.go
  28. 6 13
      service/repositories/api/fwh_pro/fwh_faultRecord.go
  29. 3 6
      service/repositories/api/fwh_pro/fwh_login.go
  30. 1 3
      service/repositories/api/fwh_pro/fwh_mqttLog.go
  31. 1 3
      service/repositories/api/fwh_pro/fwh_powerFlow.go
  32. 3 7
      service/repositories/api/fwh_pro/fwh_realtimeAalrm.go
  33. 1 4
      service/repositories/api/fwh_pro/fwh_shutdownList.go
  34. 8 13
      service/repositories/api/fwh_pro/fwh_siteInfo.go
  35. 3 7
      service/repositories/api/fwh_pro/fwh_update.go
  36. 57 0
      service/repositories/api/smartHub/simCardManage.go
  37. 40 0
      service/repositories/api/smartHub/smartHttpClient.go
  38. 1 1
      service/repositories/events/xlogRepo.go
  39. 24 17
      service/repositories/mongo/autoUpdateConfigRepo.go
  40. 21 17
      service/repositories/mongo/batchDownloadRepo.go
  41. 283 0
      service/repositories/mongo/siteInfoManageRepo.go
  42. 15 10
      service/repositories/mongo/sitesAnalysisRepo.go
  43. 11 7
      service/repositories/mongo/sitesMonitorRepo.go
  44. 8 4
      service/repositories/mongo/statisticsAlarmRepo.go
  45. 8 4
      service/repositories/mongo/statisticsShutdownRepo.go
  46. 205 0
      service/repositories/mongo/userInfoRepo.go
  47. 262 0
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go
  48. 43 0
      service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go
  49. 30 4
      service/repositories/repositories.go
  50. 0 0
      service/routes/alarmManageRoutes.go
  51. 2 0
      service/routes/routes.go
  52. 35 0
      service/routes/siteInfoManageRoutes.go
  53. 34 0
      service/routes/userInfoRoutes.go
  54. 181 0
      service/services/siteInfoManageServices.go
  55. 17 2
      service/services/sitesMonitorServices.go
  56. 36 0
      service/services/userInfoServices.go
  57. 76 1
      service/taskQueue/taskQueue.go
  58. 97 0
      service/taskQueue/taskSiteInfoUpdate.go
  59. 25 0
      service/test/smartHub/apiTest.go
  60. 17 0
      service/utils/global/global.go
  61. 24 13
      service/utils/httpClient/httpClient.go
  62. 1 1
      service/utils/minio/minio.go
  63. 238 0
      service/utils/mqttClient/mqttClient.go
  64. 1 1
      service/utils/snowflakeID/snowflakeID.go
  65. 138 6
      service/utils/xlog/xlog_package.go
  66. 20 5
      service/utils/xlog/xlog_types.go
  67. 75 0
      src/event/eventEmitter.ts
  68. 6 0
      src/event/eventTypes.ts
  69. 8 0
      src/event/events.ts
  70. 1 0
      src/main.ts
  71. 8 1
      src/router/guard.ts
  72. 1 1
      src/router/index.ts
  73. 52 41
      src/router/record.ts
  74. 8 3
      src/service/fetchClient.ts
  75. 99 0
      src/service/siteInfoManage/index.ts
  76. 114 0
      src/service/siteInfoManage/interface.ts
  77. 11 6
      src/service/taskQueue.ts
  78. 1 0
      src/service/xlog/index.ts
  79. 2 0
      src/service/xlog/interface.ts
  80. 47 17
      src/stores/initStores.ts
  81. 1 1
      src/stores/modules/data/siteModeData.ts
  82. 120 0
      src/stores/modules/views/siteInfoManageState.ts
  83. 67 0
      src/stores/modules/views/xlogViewsState.ts
  84. 17 15
      src/stores/public/template.ts
  85. 52 69
      src/stores/user.ts
  86. 0 46
      src/types/components.d.ts
  87. 8 0
      src/types/events.d.ts
  88. 1 1
      src/utils/casdoor/casdoor.ts
  89. 40 0
      src/utils/casdoor/token.ts
  90. 228 0
      src/views/site-info-manage/component/filterNodeComp/index.vue
  91. 64 0
      src/views/site-info-manage/component/siteInfoFields.vue
  92. 439 0
      src/views/site-info-manage/component/siteInfoFilter.vue
  93. 194 0
      src/views/site-info-manage/component/siteInfoView.vue
  94. 547 0
      src/views/site-info-manage/config.ts
  95. 692 0
      src/views/site-info-manage/index.vue
  96. 114 0
      src/views/site-info-manage/infoRender/devRender.ts
  97. 10 0
      src/views/site-info-manage/infoRender/index.ts
  98. 264 0
      src/views/site-info-manage/infoRender/netRender.ts
  99. 1 1
      src/views/sites-monitor/interface.ts
  100. 122 18
      src/views/xlog/component/xlogCommand.vue

+ 20 - 2
eslint.config.ts

@@ -17,6 +17,7 @@ export default antfu(
     jsonc: true, // 支持 .json/.jsonc
     yaml: true, // 支持 .yaml
     markdown: true, // 支持 .md 中代码块
+    unocss: true, // 支持 unocss
     test: true, // 启用 Vitest 支持
     stylistic: {
       indent: 2,
@@ -26,15 +27,28 @@ export default antfu(
     },
     ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
     rules: {
-      'vue/multi-word-component-names': 'off', // 🚫 禁用多单词组件名规则
+      // 禁用规则
+      'perfectionist/sort-imports': 'off',
+      'perfectionist/sort-named-imports': 'off',
+      'perfectionist/sort-named-exports': 'off',
+      'vue/block-order': 'off',
+      'node/prefer-global/process': 'off',
+      'import/consistent-type-specifier-style': 'off',
+      'antfu/top-level-function': 'off',
+      'antfu/if-newline': 'off',
+      'ts/no-use-before-define': 'off',
+      'dot-notation': 'off',
+
+      // 开发期禁用规则
+      'no-console': 'off',
     },
   },
 
   pluginVue.configs['flat/essential'],
 
-  // ✅ 添加 vue/block-lang 规则
   {
     rules: {
+      // ✅ 添加 vue/block-lang 规则
       'vue/block-lang': [
         'error',
         {
@@ -43,6 +57,9 @@ export default antfu(
           },
         },
       ],
+
+      // 禁用 pluginVue.configs['flat/essential'] 开启的规则
+      'vue/multi-word-component-names': 'off', // 🚫 禁用多单词组件名规则
     },
   },
 
@@ -57,5 +74,6 @@ export default antfu(
     ...pluginPlaywright.configs['flat/recommended'],
     files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
   },
+
   skipFormatting,
 )

+ 56 - 53
package.json

@@ -22,80 +22,83 @@
     "install": "rm -rf /var/www/fwh-toolbox/fwh-toolbox/* && cp -rf ./dist/* /var/www/fwh-toolbox/fwh-toolbox/"
   },
   "dependencies": {
-    "@antv/g2": "^5.3.4",
-    "@dotenvx/dotenvx": "^1.48.0",
-    "@iconify-json/carbon": "^1.2.13",
+    "@antv/g2": "^5.4.8",
+    "@dotenvx/dotenvx": "^1.59.1",
+    "@iconify-json/boxicons": "^1.2.0",
+    "@iconify-json/carbon": "^1.2.20",
     "@iconify-json/circle-flags": "^1.2.10",
-    "@iconify-json/fluent": "^1.2.28",
-    "@iconify-json/hugeicons": "^1.2.6",
-    "@iconify-json/mage": "^1.2.2",
-    "@iconify-json/material-symbols": "^1.2.30",
+    "@iconify-json/fluent": "^1.2.43",
+    "@iconify-json/hugeicons": "^1.2.24",
+    "@iconify-json/mage": "^1.2.4",
+    "@iconify-json/material-symbols": "^1.2.64",
     "@iconify-json/mdi": "^1.2.3",
     "@iconify-json/ph": "^1.2.2",
-    "@iconify-json/pixelarticons": "^1.2.2",
-    "@iconify-json/prime": "^1.2.2",
+    "@iconify-json/pixelarticons": "^1.2.4",
+    "@iconify-json/prime": "^1.2.4",
     "@iconify-json/streamline-freehand": "^1.2.1",
-    "@unocss/reset": "^66.3.3",
-    "@unocss/transformer-directives": "^66.3.3",
-    "@unocss/transformer-variant-group": "^66.3.3",
-    "@vueuse/components": "^13.5.0",
-    "@vueuse/core": "^13.5.0",
-    "animejs": "^4.2.2",
+    "@unocss/reset": "^66.6.7",
+    "@unocss/transformer-directives": "^66.6.7",
+    "@unocss/transformer-variant-group": "^66.6.7",
+    "@vueuse/components": "^14.2.1",
+    "@vueuse/core": "^14.2.1",
+    "animejs": "^4.3.6",
     "casdoor-vue-sdk": "^1.6.0",
-    "chroma-js": "^3.1.2",
-    "dayjs": "^1.11.18",
-    "es-toolkit": "^1.39.8",
+    "chroma-js": "^3.2.0",
+    "dayjs": "^1.11.20",
+    "es-toolkit": "^1.45.1",
     "idb": "^8.0.3",
     "jwt-decode": "^4.0.0",
     "monaco-editor": "^0.55.1",
     "monaco-editor-vue3": "^1.0.5",
-    "naive-ui": "^2.42.0",
-    "pinia": "^3.0.3",
-    "pinia-plugin-persistedstate": "^4.4.1",
+    "naive-ui": "^2.44.1",
+    "pinia": "^3.0.4",
+    "pinia-plugin-persistedstate": "^4.7.1",
     "streamsaver": "^2.0.6",
     "uplot": "^1.6.32",
     "uplot-vue": "^1.2.4",
-    "uuid": "^11.1.0",
-    "vue": "^3.5.18",
-    "vue-draggable-plus": "^0.6.0",
-    "vue-router": "^4.5.1"
+    "uuid": "^13.0.0",
+    "vue": "^3.5.31",
+    "vue-draggable-plus": "^0.6.1",
+    "vue-router": "^5.0.4"
   },
   "devDependencies": {
-    "@antfu/eslint-config": "^5.0.0",
-    "@playwright/test": "^1.54.1",
-    "@tsconfig/node22": "^22.0.2",
-    "@types/chroma-js": "^3.1.1",
-    "@types/jsdom": "^21.1.7",
-    "@types/node": "^22.16.4",
+    "@antfu/eslint-config": "^7.7.3",
+    "@playwright/test": "^1.58.2",
+    "@tsconfig/node22": "^22.0.5",
+    "@types/chroma-js": "^3.1.2",
+    "@types/jsdom": "^28.0.1",
+    "@types/node": "^25.5.0",
     "@types/streamsaver": "^2.0.5",
-    "@vitejs/plugin-vue": "^6.0.0",
-    "@vitejs/plugin-vue-jsx": "^5.0.1",
-    "@vitest/eslint-plugin": "^1.3.4",
-    "@vitest/ui": "^3.2.4",
+    "@unocss/eslint-plugin": "^66.6.7",
+    "@vitejs/plugin-vue": "^6.0.5",
+    "@vitejs/plugin-vue-jsx": "^5.1.5",
+    "@vitest/eslint-plugin": "^1.6.13",
+    "@vitest/ui": "^4.1.2",
     "@vue/eslint-config-prettier": "^10.2.0",
     "@vue/test-utils": "^2.4.6",
-    "@vue/tsconfig": "^0.7.0",
-    "eslint": "^9.31.0",
-    "eslint-plugin-playwright": "^2.2.0",
-    "eslint-plugin-vue": "~10.2.0",
-    "jiti": "^2.4.2",
-    "jsdom": "^26.1.0",
-    "node-modules-inspector": "^1.0.0",
+    "@vue/tsconfig": "^0.9.1",
+    "eslint": "^10.1.0",
+    "eslint-plugin-format": "^2.0.1",
+    "eslint-plugin-playwright": "^2.10.1",
+    "eslint-plugin-vue": "~10.8.0",
+    "jiti": "^2.6.1",
+    "jsdom": "^29.0.1",
+    "node-modules-inspector": "^1.4.2",
     "npm-run-all2": "^8.0.4",
-    "prettier": "3.5.3",
-    "sass-embedded": "^1.89.2",
-    "typescript": "~5.8.3",
-    "unocss": "^66.3.3",
-    "unplugin-auto-import": "^20.0.0",
-    "unplugin-vue-components": "^28.8.0",
+    "prettier": "3.8.1",
+    "sass-embedded": "^1.98.0",
+    "typescript": "~6.0.2",
+    "unocss": "^66.6.7",
+    "unplugin-auto-import": "^21.0.0",
+    "unplugin-vue-components": "^32.0.0",
     "vite": "npm:rolldown-vite@^7.3.1",
     "vite-plugin-compression": "^0.5.1",
     "vite-plugin-html": "^3.2.2",
-    "vite-plugin-vue-devtools": "^7.7.7",
-    "vitest": "^3.2.4",
-    "vue-macros": "3.0.0-beta.21",
-    "vue-tsc": "^3.0.4",
-    "vue-vine-tsc": "^1.6.5"
+    "vite-plugin-vue-devtools": "^8.1.1",
+    "vitest": "^4.1.2",
+    "vue-macros": "3.1.2",
+    "vue-tsc": "^3.2.6",
+    "vue-vine-tsc": "^1.7.27"
   },
   "pnpm": {
     "overrides": {

文件差異過大導致無法顯示
+ 464 - 315
pnpm-lock.yaml


+ 31 - 21
service/.vscode/settings.json

@@ -1,23 +1,33 @@
 {
-    "cSpell.words": [
-        "bson",
-        "infof",
-        "infoln",
-        "warnf",
-        "warnln",
-        "errorf",
-        "errorln",
-        "gorm",
-        "redisx",
-        "robfig",
-        "mqtt",
-        "echart",
-        "echarts",
-        "excelize",
-        "apower",
-        "powerlink",
-        "mppt",
-        "fwhtoolbox",
-        "emspara"
-    ]
+  "cSpell.words": [
+    "bson",
+    "infof",
+    "infoln",
+    "warnf",
+    "warnln",
+    "errorf",
+    "errorln",
+    "gorm",
+    "redisx",
+    "robfig",
+    "mqtt",
+    "echart",
+    "echarts",
+    "excelize",
+    "apower",
+    "powerlink",
+    "mppt",
+    "fwhtoolbox",
+    "emspara",
+    "xlog",
+    "snno",
+    "iccid",
+    "giccid",
+    "iccids",
+    "imei",
+    "imsi",
+    "msisdn",
+    "acceleronix"
+  ],
+  "editor.tabSize": 2
 }

+ 4 - 0
service/cache/cache.go

@@ -214,6 +214,10 @@ func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Dura
 	return redisx.Lock(ctx, key, ttl, retryInterval, timeout)
 }
 
+func LockRelease(ctx context.Context, key string, token string) error {
+	return redisx.LockRelease(ctx, key, token)
+}
+
 /********* JSON操作 *********/
 
 // SetJSON 将任意结构体序列化为 JSON 存入 Redis

+ 27 - 0
service/cache/redisx/lockx.go

@@ -52,6 +52,25 @@ func Lock(ctx context.Context, key string, ttl, retryInterval, timeout time.Dura
 	}
 }
 
+// Release 安全释放锁(Lua 脚本) - 通过key和token直接释放
+func LockRelease(ctx context.Context, key string, token string) error {
+	const luaScript = `
+if redis.call("get", KEYS[1]) == ARGV[1] then
+	return redis.call("del", KEYS[1])
+else
+	return 0
+end
+`
+	res, err := redis.NewScript(luaScript).Run(ctx, getClient(), []string{key}, token).Int()
+	if err != nil {
+		return err
+	}
+	if res == 0 {
+		return errors.New("unlock failed: not owner")
+	}
+	return nil
+}
+
 // Release 安全释放锁(Lua 脚本)
 func (l *RedisLock) Release(ctx context.Context) error {
 	const luaScript = `
@@ -75,3 +94,11 @@ end
 func (l *RedisLock) SafeRelease(ctx context.Context) {
 	_ = l.Release(ctx)
 }
+
+func (l *RedisLock) GetKey() string {
+	return l.key
+}
+
+func (l *RedisLock) GetToken() string {
+	return l.token
+}

+ 62 - 29
service/config/config.go

@@ -8,11 +8,9 @@ import (
 	"github.com/spf13/viper"
 )
 
-type Config struct {
+type AppConfig struct {
 	AppEnv          string
 	AppPort         string
-	FwhEnergy       string
-	FwhOss          string
 	RedisAddr       string
 	MySQLDSN        string
 	PostgresDSN     string
@@ -23,22 +21,41 @@ type Config struct {
 	MinioBucketName string
 }
 
+type FwhApiConfig struct {
+	FwhEnergy   string
+	FwhOss      string
+	FwhUser     string
+	FwhPassword string
+}
+
+type FwhMqttConfig struct {
+	FwhMqttBroker  string
+	FwhMqttCaUrl   string
+	FwhMqttCertUrl string
+	FwhMqttKeyUrl  string
+}
+
+type Config struct {
+	App     AppConfig
+	FwhApi  FwhApiConfig
+	FwhMqtt FwhMqttConfig
+}
+
 // 需先执行LoadConfig后才能读取ENV
-var APP_ENV string = "dev"
-var FWH_ENERGY string = "energy.fwh-toolbox.top"
-var FWH_OSS string = "energy-oss.fwh-toolbox.top"
-var FWH_USER string = ""
-var FWH_PASSWORD string = ""
+// var APP_ENV string = "dev"
+// var FWH_ENERGY string = "energy.franklinwh.com"
+// var FWH_OSS string = "oss.franklinwh.com"
+// var FWH_USER string = ""
+// var FWH_PASSWORD string = "" // MD5 - PASSWORD
 
 // LoadConfig 读取配置文件和环境变量
 func LoadConfig() (*Config, error) {
 	v := viper.New()
 
 	// 默认值(避免缺省时崩溃)
+	// APP
 	v.SetDefault("APP_ENV", "dev")
 	v.SetDefault("APP_PORT", "8080")
-	v.SetDefault("FWH_ENERGY", "energy.fwh-toolbox.top")
-	v.SetDefault("FWH_OSS", "energy-oss.fwh-toolbox.top")
 	v.SetDefault("REDIS_ADDR", "localhost:6379")
 	v.SetDefault("MYSQL_DSN", "")
 	v.SetDefault("POSTGRES_DSN", "host=localhost user=fwh_toolbox password=fwh_toolbox dbname=fwh_toolbox_db port=5432 sslmode=disable")
@@ -47,6 +64,10 @@ func LoadConfig() (*Config, error) {
 	v.SetDefault("MINIO_ACCESS_KEY", "fwh-toolbox")
 	v.SetDefault("MINIO_SECRET_KEY", "fwh-toolbox")
 	v.SetDefault("MINIO_BUCKET_NAME", "fwh-toolbox")
+	// FWH API
+	v.SetDefault("FWH_ENERGY", "energy.fwh-toolbox.top")
+	v.SetDefault("FWH_OSS", "energy-oss.fwh-toolbox.top")
+	// FWH MQTT
 
 	// 多文件路径支持
 	paths := []string{"./.env", "./config.env"}
@@ -70,28 +91,40 @@ func LoadConfig() (*Config, error) {
 		fmt.Println("⚠️ No config file found, using default values & environment variables")
 	}
 
-	APP_ENV = v.GetString("APP_ENV")
-	FWH_ENERGY = v.GetString("FWH_ENERGY")
-	FWH_OSS = v.GetString("FWH_OSS")
-	FWH_USER = v.GetString("FWH_USER")
-	FWH_PASSWORD = v.GetString("FWH_PASSWORD")
+	// APP_ENV = v.GetString("APP_ENV")
+	// FWH_ENERGY = v.GetString("FWH_ENERGY")
+	// FWH_OSS = v.GetString("FWH_OSS")
+	// FWH_USER = v.GetString("FWH_USER")
+	// FWH_PASSWORD = v.GetString("FWH_PASSWORD")
 
 	h := md5.New()
-	h.Write([]byte(FWH_PASSWORD))
-	FWH_PASSWORD = hex.EncodeToString(h.Sum(nil))
+	h.Write([]byte(v.GetString("FWH_PASSWORD")))
+	fwhApiPassword := hex.EncodeToString(h.Sum(nil))
 
 	return &Config{
-		AppEnv:          v.GetString("APP_ENV"),
-		AppPort:         v.GetString("APP_PORT"),
-		FwhEnergy:       v.GetString("FWH_ENERGY"),
-		FwhOss:          v.GetString("FWH_OSS"),
-		RedisAddr:       v.GetString("REDIS_ADDR"),
-		MySQLDSN:        v.GetString("MYSQL_DSN"),
-		PostgresDSN:     v.GetString("POSTGRES_DSN"),
-		MongoDSN:        v.GetString("MONGO_DSN"),
-		MinIoEndpoint:   v.GetString("MINIO_ENDPOINT"),
-		MinioAccessKey:  v.GetString("MINIO_ACCESS_KEY"),
-		MinioSecretKey:  v.GetString("MINIO_SECRET_KEY"),
-		MinioBucketName: v.GetString("MINIO_BUCKET_NAME"),
+		App: AppConfig{
+			AppEnv:          v.GetString("APP_ENV"),
+			AppPort:         v.GetString("APP_PORT"),
+			RedisAddr:       v.GetString("REDIS_ADDR"),
+			MySQLDSN:        v.GetString("MYSQL_DSN"),
+			PostgresDSN:     v.GetString("POSTGRES_DSN"),
+			MongoDSN:        v.GetString("MONGO_DSN"),
+			MinIoEndpoint:   v.GetString("MINIO_ENDPOINT"),
+			MinioAccessKey:  v.GetString("MINIO_ACCESS_KEY"),
+			MinioSecretKey:  v.GetString("MINIO_SECRET_KEY"),
+			MinioBucketName: v.GetString("MINIO_BUCKET_NAME"),
+		},
+		FwhApi: FwhApiConfig{
+			FwhEnergy:   v.GetString("FWH_ENERGY"),
+			FwhOss:      v.GetString("FWH_OSS"),
+			FwhUser:     v.GetString("FWH_USER"),
+			FwhPassword: fwhApiPassword,
+		},
+		FwhMqtt: FwhMqttConfig{
+			FwhMqttBroker:  v.GetString("FWH_MQTT_BROKER"),
+			FwhMqttCaUrl:   v.GetString("FWH_MQTT_CA"),
+			FwhMqttCertUrl: v.GetString("FWH_MQTT_CERT"),
+			FwhMqttKeyUrl:  v.GetString("FWH_MQTT_KEY"),
+		},
 	}, nil
 }

+ 132 - 0
service/controllers/siteInfoManageControllers.go

@@ -0,0 +1,132 @@
+/*
+ * Site Info Manage API List
+ * /siteInfoManage/manage/update - GET & POST 获取更新状态 & 请求更新站点信息表
+ * /siteInfoManage/dataList - POST 站点信息表
+ * /siteInfoManage/dataListExport - POST 导出站点信息表
+ * /siteInfoManage/siteInfo/query - GET 获取单个站点实时信息(同时更新单个站点数据)
+ * /siteInfoManage/operate/enableXlog - POST 使能站点xlog
+ */
+package controllers
+
+import (
+	"bufio"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+)
+
+// 获取更新状态
+// /siteInfoManage/manage/update
+func GetSiteInfoUpdateStatus(c *fiber.Ctx) error {
+	date, err := services.GetSiteInfoUpdateStatus()
+	if err != nil {
+		return Error(c, "Site Info date read failed.")
+	}
+	return Success(c, date)
+}
+
+// 请求更新站点信息表
+// /siteInfoManage/manage/update
+func UpdateSiteInfoDate(c *fiber.Ctx) error {
+	err := services.UpdateSiteInfoDate()
+	if err != nil {
+		return Error(c, "Site Info update failed.")
+	}
+	return Success[any](c, nil, "Success")
+}
+
+// 获取站点信息表
+// /siteInfoManage/dataList
+func QuerySiteInfoDataList(c *fiber.Ctx) error {
+	var body dto.SiteInfoRequest
+	if err := c.BodyParser(&body); err != nil {
+		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request body")
+	}
+
+	data, err := services.QuerySiteInfoDataList(body)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success[*dto.ResPage[dto.SiteInfoDTO]](c, data, "Success")
+}
+
+// dataListExport
+// /siteInfoManage/dataListExport
+func ExportSiteInfoDataList(c *fiber.Ctx) error {
+	var body struct {
+		Filter *domain.SiteInfoFilter `json:"filter"`
+	}
+	if err := c.BodyParser(&body); err != nil {
+		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request body")
+	}
+
+	// 导出excel文件
+	f, err := services.ExportSiteInfoDataList(body.Filter)
+	if err != nil {
+		zap.L().Warn("File export failed", zap.String("err", err.Error()))
+		return Fail(c, "File export failed")
+	}
+
+	fileName := fmt.Sprintf("LMT站点管理表_%s.xlsx", strings.Replace(time.Now().Format(time.DateOnly), "-", "_", -1))
+	encoded := url.QueryEscape(fileName)
+	c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+	c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s; filename*=UTF-8''%s", encoded, encoded))
+
+	// Fiber 原生 Context 的 BodyWriter,可直接写数据到 HTTP 输出
+	// writer := c.Context().Response.BodyWriter()
+
+	// 使用 SetBodyStreamWriter 流式写入
+	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
+		f.Write(w)
+		w.Flush()
+
+		if err := f.Close(); err != nil {
+			zap.L().Error("Excel 文件close失败", zap.String("err", err.Error()))
+		}
+	})
+
+	// return Success[any](c, nil, "Success")
+	return nil
+}
+
+// 查询单个站点实时信息(同时更新单个站点数据)
+// /siteInfoManage/siteInfo/query
+func GetSiteInfoData(c *fiber.Ctx) error {
+	sn := c.Query("sn")
+
+	if len(sn) == 0 {
+		zap.L().Warn("Invalid request param", zap.String("sn", sn))
+		return Fail(c, "Invalid request param")
+	}
+
+	data, err := services.GetSiteInfoData(sn)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success[*dto.SiteInfoDTO](c, data, "Success")
+}
+
+// 使能站点xlog
+// /siteInfoManage/operate/enableXlog
+func OperateSiteInfoEnableXlog(c *fiber.Ctx) error {
+	var body string
+	if err := c.BodyParser(&body); err != nil {
+		zap.L().Warn("Invalid request body", zap.String("err", err.Error()))
+		return Fail(c, "Invalid request body")
+	}
+
+	data, err := services.OperateSiteInfoEnableXlog(c.UserContext(), body)
+	if err != nil {
+		return Error(c, err.Error())
+	}
+	return Success[string](c, data, "Success")
+}

+ 106 - 0
service/controllers/userInfoControllers.go

@@ -0,0 +1,106 @@
+package controllers
+
+import (
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/middleware"
+	"fwh-toolbox-server/services"
+
+	"github.com/gofiber/fiber/v2"
+	"go.uber.org/zap"
+)
+
+// 获取用户的完整配置
+// /userInfo/config
+func GetUserInfoAllConfig(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+	config, err := services.UserInfoReadAllConfig(userName)
+	if err != nil {
+		zap.L().Error("查询用户的完整配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database read failed. (MongoDb)")
+	}
+	return Success(c, config)
+}
+
+// 更新用户的完整配置
+// /userInfo/config
+func UpdateUserInfoAllConfig(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+
+	var body domain.UserConfigDomain
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "Invalid request body")
+	}
+
+	err := services.UserInfoUpdateAllConfig(userName, body)
+	if err != nil {
+		zap.L().Error("更用户的完整配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}
+
+// 获取用户的SiteInfoManage配置
+// /userInfo/config/siteInfoManage
+func GetUserInfoSiteInfoManageConfig(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+	config, err := services.UserInfoReadSiteInfoManageConfig(userName)
+	if err != nil {
+		zap.L().Error("查询用户的SiteInfoManage配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database read failed. (MongoDb)")
+	}
+	return Success(c, config)
+}
+
+// 更新用户的SiteInfoManage配置
+// /userInfo/config/siteInfoManage
+func UpdateUserInfoSiteInfoManageConfig(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+
+	var body domain.SiteInfoManageConfig
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "Invalid request body")
+	}
+
+	err := services.UserInfoUpdateSiteInfoManageConfig(userName, body)
+	if err != nil {
+		zap.L().Error("更用户的SiteInfoManage配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}
+
+// 更新用户的SiteInfoManage - TitleFilter配置
+// /userInfo/config/siteInfoManage/titleFilter
+func UpdateUserInfoSiteInfoManageConfigTitleFilter(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+
+	var body []string
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "Invalid request body")
+	}
+
+	err := services.UserInfoUpdateSiteInfoManageConfigTitleFilter(userName, body)
+	if err != nil {
+		zap.L().Error("更用户的SiteInfoManage - TitleFilter配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}
+
+// 更新用户的SiteInfoManage - CustomQueryFilter配置
+// /userInfo/config/siteInfoManage/customQueryFilter
+func UpdateUserInfoSiteInfoManageConfigCustomQueryFilter(c *fiber.Ctx) error {
+	userName := middleware.AuthGetUserName(c)
+
+	var body []domain.SiteInfoQueryFilter
+	if err := c.BodyParser(&body); err != nil {
+		return Fail(c, "Invalid request body")
+	}
+
+	err := services.UserInfoUpdateSiteInfoManageConfigCustomQueryFilter(userName, body)
+	if err != nil {
+		zap.L().Error("更用户的SiteInfoManage - CustomQueryFilter配置错误。", zap.String("err", err.Error()))
+		return Error(c, "Database write failed. (MongoDb)")
+	}
+	return Success(c, body)
+}

+ 1 - 1
service/database/database.go

@@ -4,7 +4,7 @@ import (
 	"fwh-toolbox-server/config"
 )
 
-func Init(cfg *config.Config) error {
+func Init(cfg *config.AppConfig) error {
 	// if _, err := InitMySQL(cfg.MySQLDSN); err != nil {
 	// 	return err
 	// }

+ 87 - 0
service/domain/fwhProDomain.go

@@ -246,3 +246,90 @@ type AlarmStatisticsData[T any] struct {
 	Records   []T `json:"records"`
 	Total     int `json:"total"`
 }
+
+type FwhSiteInfo struct {
+	Id                 string  `json:"id"` // aGate SN
+	Name               *string `json:"name"`
+	DistributorId      *int    `json:"distributorId"`      // 经销商ID
+	DistributorName    *string `json:"distributorName"`    // 经销商名称
+	InstallerId        *int    `json:"installerId"`        // 安装商ID
+	InstallerAccountId *int    `json:"installerAccountId"` // 安装商账号ID
+	InstallerAccount   *string `json:"installerAccount"`   // 安装商账号
+	InstallerName      *string `json:"installerName"`      // 安装商名称
+	MaintId            *int    `json:"maintId"`            // 维护商ID
+	MaintName          *string `json:"maintName"`          // 维护商名称
+	Account            *string `json:"account"`            // 绑定的用户账号
+	AccountName        *string `json:"accountName"`        // 绑定账号名
+	Longitude          string  `json:"longitude"`          // 经度
+	Latitude           string  `json:"latitude"`           // 纬度
+	Address            *string `json:"address"`
+	InstallTime        *string `json:"installTime"`     // 安装时间
+	ActiveStatus       int     `json:"activeStatus"`    // 激活状态 0-未激活 1-激活
+	ActiveTime         string  `json:"activeTime"`      // 激活时间(北京时间)
+	LocalActiveTime    string  `json:"localActiveTime"` // 激活时间(站点当地时间)
+	Status             int     `json:"status"`          // 网络状态 0-离线 1-在线
+	OnLineTime         *string `json:"onLineTime"`      // 最后上线时间 YYYY-MM-DD HH:mm:SS
+	OffLineTime        *string `json:"offLineTime"`     // 最后离线时间 YYYY-MM-DD HH:mm:SS
+	// model * ??? 未知字段 (TODO:后续处理)
+	DeviceIp        *string `json:"deviceIp"`  // 设备联网IP
+	Subnet          *string `json:"subnet"`    // 子掩码
+	Mac             *string `json:"mac"`       // MAC地址
+	GatewayIp       *string `json:"gatewayIp"` // 网关IP
+	Signal          *string `json:"signal"`    // 信号强度
+	ICCID           string  `json:"iccId"`
+	IMEI            string  `json:"imei"`
+	FirmUpgradeTime *string `json:"firmUpgradeTime"` // 固件最后更新时间 YYYY-MM-DD HH:mm:SS
+	TurnoverTime    *string `json:"turnoverTime"`    // 周转时间??? YYYY-MM-DD HH:mm:SS
+	CreateTime      *string `json:"createTime"`      // 设备首次登录时间 YYYY-MM-DD HH:mm:SS
+	// 忽略部分功率字段powerSun、powerGen、powerUti、powerFhp、powerLoad
+	BatterySoc      *float32 `json:"batterySoc"`      // 系统电池SOC
+	RunStatus       int      `json:"runStatus"`       // 设备运行状态 0-待机 1-充电 2-放电 3-故障
+	EmsModel        *int     `json:"emsModel"`        // EMS调度运行模式(策略) 参考调度ID
+	ElectricityType int      `json:"electricityType"` // 电网类型 0-离网运行 1-并网运行
+	// workMode * ??? 未知字段 (TODO:后续处理)
+	FhpSn            string  `json:"fhpSn"`            // aPower SN 多设备用,分割
+	TimeZone         float32 `json:"timeZone"`         // 时区数值
+	Version          string  `json:"version"`          // 设备固件版本
+	SlVersion        string  `json:"slVersion"`        // EMS-DSP版本
+	SlVersionJson    string  `json:"slVersionJson"`    // EMS-DSP版本详细信息JSON
+	MeterVersion     *string `json:"meterVersion"`     // 电表版本
+	MbVersion        *string `json:"mbVersion"`        // MODBUS版本
+	AwsVersion       *string `json:"awsVersion"`       // AWS版本
+	AppVersion       *string `json:"appVersion"`       // APP版本(与APP通讯的模块软件)
+	ElecnetStatus    int     `json:"elecnetStatus"`    // 电网状态 0-离网 1-并网
+	SlaverStat       int     `json:"slaverStat"`       // EMS-DSP状态
+	Dst              *int    `json:"dst"`              // 夏令时 0-否 1-是
+	ZoneInfo         string  `json:"zoneInfo"`         // 时区信息 America/Los_Angeles
+	PostCode         string  `json:"postCode"`         // 邮编
+	DeviceTime       *string `json:"deviceTime"`       // 设备时间 YYYY-MM-DD HH:mm:SS
+	ProtocolVer      string  `json:"protocolVer"`      // 协议版本
+	SysHdVersion     int     `json:"sysHdVersion"`     // aGate硬件版本
+	RealSysHdVersion *string `json:"realSysHdVersion"` // aGate系统版本名称
+	WifiSignal       string  `json:"wifiSignal"`       // WIFI信号
+	ConnType         int     `json:"connType"`         // 网络连接类型 0-未连接 1-eth0 2-eth1 3-WIFI 4-4G
+	UserName         *string `json:"userName"`
+	ContactNumber    *string `json:"contactNumber"` // 联系方式(手机号)
+	// 忽略部分未知字段totalData、usedData、resourceList
+	FinancierName           *string `json:"financierName"`           // 大客户名称
+	FinancierContractNumber *string `json:"financierContractNumber"` // 大客户联系方式(手机号)
+	CountryId               *int    `json:"countryId"`               // 国家ID
+	ProvinceId              *int    `json:"provinceId"`              // 州/省ID
+	FinancierId             *int    `json:"financierId"`             // 大客户ID
+	FirstName               *string `json:"firstName"`               // 用户名
+	LastName                *string `json:"lastName"`                // 用户姓
+	// region * ??? 未知字段 (TODO:后续处理)
+	Remark            *string `json:"remark"`   // 备注信息
+	GenEn             int     `json:"genEn"`    // 发电机是否使能 0-不使能 1-使能
+	UserId            *int    `json:"userId"`   // 用户ID
+	Country           string  `json:"country"`  // 国家
+	Province          string  `json:"province"` // 州/省
+	City              string  `json:"city"`     // 城市
+	AddressSecondLast *string `json:"addressSecondLast"`
+	DeviceAddress     *string `json:"deviceAddress"` // 设备地址
+	// whetherEncrypt * ??? 未知字段 (TODO:后续处理)
+	EmsDeviceVer *int    `json:"emsDeviceVer"` // EMS版本
+	Site         *string `json:"site"`         // 站点ID名
+	// 忽略部分字段newHomeFlag、sleepStatus、isJoinJA12
+	SiteId  *uint   `json:"siteId"`  // 站点ID号
+	JobCode *string `json:"jobCode"` // Job Code (Sunrun相关)
+}

+ 254 - 0
service/domain/sitesInfoManageDomain.go

@@ -0,0 +1,254 @@
+package domain
+
+import (
+	"encoding/json"
+	"fmt"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+// 过滤类型定义
+const (
+	FilterUnknown       int = iota // 0 - 未知类型
+	FilterEqualInt                 // 1 - 等于指定值(整数)
+	FilterEqualFloat               // 2 - 等于指定值(浮点数)
+	FilterEqualString              // 3 - 等于指定字符串
+	FilterIncludeString            // 4 - 包含指定字符串
+	FilterRangeInt                 // 5 - 数值范围检索(整数)
+	FilterRangeFloat               // 6 - 数值范围检索(浮点数)
+	FilterRangeTime                // 7 - 数值范围检索(时间)
+	FilterExists                   // 8 - 判断字段是否存在或不为空
+)
+
+type FlexiblePara struct {
+	String *string
+	Int    *int64
+	Float  *float64
+}
+
+func (f *FlexiblePara) UnmarshalJSON(data []byte) error {
+	// 尝试 string
+	var s string
+	if err := json.Unmarshal(data, &s); err == nil {
+		f.String = &s
+		return nil
+	}
+
+	// 尝试 int64
+	var i int64
+	if err := json.Unmarshal(data, &i); err == nil {
+		f.Int = &i
+		return nil
+	}
+
+	// 尝试 float64
+	var fl float64
+	if err := json.Unmarshal(data, &fl); err == nil {
+		f.Float = &fl
+		return nil
+	}
+
+	return fmt.Errorf("invalid type")
+}
+
+type SiteInfoFilterConfig struct {
+	Key   string        `json:"key"`             // 过滤的关键字
+	Type  int           `json:"type"`            // 过滤类型
+	Para1 FlexiblePara  `json:"para1"`           // 参数1
+	Para2 *FlexiblePara `json:"para2,omitempty"` // 参数2
+}
+
+type SiteInfoSubFilter struct {
+	Logic  string                 `json:"logic"`
+	Config []SiteInfoFilterConfig `json:"config"`
+}
+
+type SiteInfoFilter struct {
+	Logic     string              `json:"logic"`
+	SubFilter []SiteInfoSubFilter `json:"subFilter"`
+}
+
+type SiteInfoUpdateRecord struct {
+	Id         *primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
+	Progress   Progress            `bson:"progress" json:"progress"`
+	CreateTime time.Time           `bson:"createTime" json:"createTime"`
+	UpdateTime time.Time           `bson:"updateTime" json:"updateTime"`
+	Total      uint64              `bson:"total" json:"total"`
+}
+
+type TaskSiteInfoUpdate struct {
+	Id primitive.ObjectID `bson:"_id" json:"id"`
+}
+
+type SiteInfoDomain struct {
+	Sn                 string  `bson:"_id" json:"sn"` // aGate SN
+	Name               *string `bson:"name,omitempty" json:"name,omitempty"`
+	DistributorId      *int    `bson:"distributorId,omitempty" json:"distributorId,omitempty"`           // 经销商ID
+	DistributorName    *string `bson:"distributorName,omitempty" json:"distributorName,omitempty"`       // 经销商名称
+	InstallerId        *int    `bson:"installerId,omitempty" json:"installerId,omitempty"`               // 安装商ID
+	InstallerAccountId *int    `bson:"installerAccountId,omitempty" json:"installerAccountId,omitempty"` // 安装商账号ID
+	InstallerAccount   *string `bson:"installerAccount,omitempty" json:"installerAccount,omitempty"`     // 安装商账号
+	InstallerName      *string `bson:"installerName,omitempty" json:"installerName,omitempty"`           // 安装商名称
+	MaintId            *int    `bson:"maintId,omitempty" json:"maintId,omitempty"`                       // 维护商ID
+	MaintName          *string `bson:"maintName,omitempty" json:"maintName,omitempty"`                   // 维护商名称
+	Account            *string `bson:"account,omitempty" json:"account,omitempty"`                       // 绑定的用户账号
+	AccountName        *string `bson:"accountName,omitempty" json:"accountName,omitempty"`               // 绑定账号名
+	Longitude          string  `bson:"longitude" json:"longitude"`                                       // 经度
+	Latitude           string  `bson:"latitude" json:"latitude"`                                         // 纬度
+	Address            *string `bson:"address,omitempty" json:"address,omitempty"`
+	InstallTime        *string `bson:"installTime,omitempty" json:"installTime,omitempty"` // 安装时间
+	ActiveStatus       int     `bson:"activeStatus" json:"activeStatus"`                   // 激活状态 0-未激活 1-激活
+	ActiveTime         string  `bson:"activeTime" json:"activeTime"`                       // 激活时间(北京时间)
+	LocalActiveTime    string  `bson:"localActiveTime" json:"localActiveTime"`             // 激活时间(站点当地时间)
+	Status             int     `bson:"status" json:"status"`                               // 网络状态 0-离线 1-在线
+	OnLineTime         *string `bson:"onLineTime,omitempty" json:"onLineTime,omitempty"`   // 最后上线时间 YYYY-MM-DD HH:mm:SS
+	OffLineTime        *string `bson:"offLineTime,omitempty" json:"offLineTime,omitempty"` // 最后离线时间 YYYY-MM-DD HH:mm:SS
+	// model * ??? 未知字段 (TODO:后续处理)
+	DeviceIp        *string `bson:"deviceIp,omitempty" json:"deviceIp,omitempty"`   // 设备联网IP
+	Subnet          *string `bson:"subnet,omitempty" json:"subnet,omitempty"`       // 子掩码
+	Mac             *string `bson:"mac,omitempty" json:"mac,omitempty"`             // MAC地址
+	GatewayIp       *string `bson:"gatewayIp,omitempty" json:"gatewayIp,omitempty"` // 网关IP
+	Signal          *string `bson:"signal,omitempty" json:"signal,omitempty"`       // 信号强度
+	ICCID           string  `bson:"iccId" json:"iccId"`
+	IMEI            string  `bson:"imei" json:"imei"`
+	FirmUpgradeTime *string `bson:"firmUpgradeTime,omitempty" json:"firmUpgradeTime,omitempty"` // 固件最后更新时间 YYYY-MM-DD HH:mm:SS
+	TurnoverTime    *string `bson:"turnoverTime,omitempty" json:"turnoverTime,omitempty"`       // 周转时间??? YYYY-MM-DD HH:mm:SS
+	CreateTime      *string `bson:"createTime,omitempty" json:"createTime,omitempty"`           // 设备首次登录时间 YYYY-MM-DD HH:mm:SS
+	// 忽略部分功率字段powerSun、powerGen、powerUti、powerFhp、powerLoad
+	BatterySoc      *float32 `bson:"batterySoc,omitempty" json:"batterySoc,omitempty"` // 系统电池SOC
+	RunStatus       int      `bson:"runStatus" json:"runStatus"`                       // 设备运行状态 0-待机 1-充电 2-放电 3-故障
+	EmsModel        *int     `bson:"emsModel,omitempty" json:"emsModel,omitempty"`     // EMS调度运行模式(策略) 参考调度ID
+	ElectricityType int      `bson:"electricityType" json:"electricityType"`           // 电网类型 0-离网运行 1-并网运行
+	// workMode * ??? 未知字段 (TODO:后续处理)
+	FhpSn            string  `bson:"fhpSn" json:"fhpSn"`                                           // aPower SN 多设备用,分割
+	TimeZone         float32 `bson:"timeZone" json:"timeZone"`                                     // 时区数值
+	Version          string  `bson:"version" json:"version"`                                       // 设备固件版本
+	SlVersion        string  `bson:"slVersion" json:"slVersion"`                                   // EMS-DSP版本
+	SlVersionJson    string  `bson:"slVersionJson" json:"slVersionJson"`                           // EMS-DSP版本详细信息JSON
+	MeterVersion     *string `bson:"meterVersion,omitempty" json:"meterVersion,omitempty"`         // 电表版本
+	MbVersion        *string `bson:"mbVersion,omitempty" json:"mbVersion,omitempty"`               // MODBUS版本
+	AwsVersion       *string `bson:"awsVersion,omitempty" json:"awsVersion,omitempty"`             // AWS版本
+	AppVersion       *string `bson:"appVersion,omitempty" json:"appVersion,omitempty"`             // APP版本(与APP通讯的模块软件)
+	ElecnetStatus    int     `bson:"elecnetStatus" json:"elecnetStatus"`                           // 电网状态 0-离网 1-并网
+	SlaverStat       int     `bson:"slaverStat" json:"slaverStat"`                                 // EMS-DSP状态
+	Dst              *int    `bson:"dst,omitempty" json:"dst,omitempty"`                           // 夏令时 0-否 1-是
+	ZoneInfo         string  `bson:"zoneInfo" json:"zoneInfo"`                                     // 时区信息 America/Los_Angeles
+	PostCode         string  `bson:"postCode" json:"postCode"`                                     // 邮编
+	DeviceTime       *string `bson:"deviceTime,omitempty" json:"deviceTime,omitempty"`             // 设备时间 YYYY-MM-DD HH:mm:SS
+	ProtocolVer      string  `bson:"protocolVer" json:"protocolVer"`                               // 协议版本
+	SysHdVersion     int     `bson:"sysHdVersion" json:"sysHdVersion"`                             // aGate硬件版本
+	RealSysHdVersion *string `bson:"realSysHdVersion,omitempty" json:"realSysHdVersion,omitempty"` // aGate系统版本名称
+	WifiSignal       string  `bson:"wifiSignal" json:"wifiSignal"`                                 // WIFI信号
+	ConnType         int     `bson:"connType" json:"connType"`                                     // 网络连接类型 0-未连接 1-eth0 2-eth1 3-WIFI 4-4G
+	UserName         *string `bson:"userName,omitempty" json:"userName,omitempty"`
+	ContactNumber    *string `bson:"contactNumber,omitempty" json:"contactNumber,omitempty"` // 联系方式(手机号)
+	// 忽略部分未知字段totalData、usedData、resourceList
+	FinancierName           *string `bson:"financierName,omitempty" json:"financierName,omitempty"`                     // 大客户名称
+	FinancierContractNumber *string `bson:"financierContractNumber,omitempty" json:"financierContractNumber,omitempty"` // 大客户联系方式(手机号)
+	CountryId               *int    `bson:"countryId,omitempty" json:"countryId,omitempty"`                             // 国家ID
+	ProvinceId              *int    `bson:"provinceId,omitempty" json:"provinceId,omitempty"`                           // 州/省ID
+	FinancierId             *int    `bson:"financierId,omitempty" json:"financierId,omitempty"`                         // 大客户ID
+	FirstName               *string `bson:"firstName,omitempty" json:"firstName,omitempty"`                             // 用户名
+	LastName                *string `bson:"lastName,omitempty" json:"lastName,omitempty"`                               // 用户姓
+	// region * ??? 未知字段 (TODO:后续处理)
+	Remark            *string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注信息
+	GenEn             int     `bson:"genEn" json:"genEn"`                       // 发电机是否使能 0-不使能 1-使能
+	UserId            *int    `bson:"userId,omitempty" json:"userId,omitempty"` // 用户ID
+	Country           string  `bson:"country" json:"country"`                   // 国家
+	Province          string  `bson:"province" json:"province"`                 // 州/省
+	City              string  `bson:"city" json:"city"`                         // 城市
+	AddressSecondLast *string `bson:"addressSecondLast,omitempty" json:"addressSecondLast,omitempty"`
+	DeviceAddress     *string `bson:"deviceAddress,omitempty" json:"deviceAddress,omitempty"` // 设备地址
+	// whetherEncrypt * ??? 未知字段 (TODO:后续处理)
+	EmsDeviceVer *int    `bson:"emsDeviceVer,omitempty" json:"emsDeviceVer,omitempty"` // EMS版本
+	Site         *string `bson:"site,omitempty" json:"site,omitempty"`                 // 站点ID名
+	// 忽略部分字段newHomeFlag、sleepStatus、isJoinJA12
+	SiteId  *uint   `bson:"siteId,omitempty" json:"siteId,omitempty"`   // 站点ID号
+	JobCode *string `bson:"jobCode,omitempty" json:"jobCode,omitempty"` // Job Code (Sunrun相关)
+}
+
+// Code generated. FwhSiteInfo -> SiteInfoDomain
+func FwhSiteInfoToSiteInfoDomain(src *FwhSiteInfo) *SiteInfoDomain {
+	if src == nil {
+		return nil
+	}
+
+	return &SiteInfoDomain{
+		Sn:                      src.Id,
+		Name:                    src.Name,
+		DistributorId:           src.DistributorId,
+		DistributorName:         src.DistributorName,
+		InstallerId:             src.InstallerId,
+		InstallerAccountId:      src.InstallerAccountId,
+		InstallerAccount:        src.InstallerAccount,
+		InstallerName:           src.InstallerName,
+		MaintId:                 src.MaintId,
+		MaintName:               src.MaintName,
+		Account:                 src.Account,
+		AccountName:             src.AccountName,
+		Longitude:               src.Longitude,
+		Latitude:                src.Latitude,
+		Address:                 src.Address,
+		InstallTime:             src.InstallTime,
+		ActiveStatus:            src.ActiveStatus,
+		ActiveTime:              src.ActiveTime,
+		Status:                  src.Status,
+		OnLineTime:              src.OnLineTime,
+		OffLineTime:             src.OffLineTime,
+		DeviceIp:                src.DeviceIp,
+		Subnet:                  src.Subnet,
+		Mac:                     src.Mac,
+		GatewayIp:               src.GatewayIp,
+		Signal:                  src.Signal,
+		ICCID:                   src.ICCID,
+		IMEI:                    src.IMEI,
+		FirmUpgradeTime:         src.FirmUpgradeTime,
+		TurnoverTime:            src.TurnoverTime,
+		CreateTime:              src.CreateTime,
+		BatterySoc:              src.BatterySoc,
+		RunStatus:               src.RunStatus,
+		EmsModel:                src.EmsModel,
+		ElectricityType:         src.ElectricityType,
+		FhpSn:                   src.FhpSn,
+		TimeZone:                src.TimeZone,
+		Version:                 src.Version,
+		SlVersion:               src.SlVersion,
+		SlVersionJson:           src.SlVersionJson,
+		MeterVersion:            src.MeterVersion,
+		MbVersion:               src.MbVersion,
+		AwsVersion:              src.AwsVersion,
+		AppVersion:              src.AppVersion,
+		ElecnetStatus:           src.ElecnetStatus,
+		SlaverStat:              src.SlaverStat,
+		Dst:                     src.Dst,
+		ZoneInfo:                src.ZoneInfo,
+		PostCode:                src.PostCode,
+		DeviceTime:              src.DeviceTime,
+		ProtocolVer:             src.ProtocolVer,
+		SysHdVersion:            src.SysHdVersion,
+		RealSysHdVersion:        src.RealSysHdVersion,
+		WifiSignal:              src.WifiSignal,
+		ConnType:                src.ConnType,
+		UserName:                src.UserName,
+		ContactNumber:           src.ContactNumber,
+		FinancierName:           src.FinancierName,
+		FinancierContractNumber: src.FinancierContractNumber,
+		CountryId:               src.CountryId,
+		ProvinceId:              src.ProvinceId,
+		FinancierId:             src.FinancierId,
+		FirstName:               src.FirstName,
+		LastName:                src.LastName,
+		Remark:                  src.Remark,
+		GenEn:                   src.GenEn,
+		UserId:                  src.UserId,
+		Country:                 src.Country,
+		Province:                src.Province,
+		City:                    src.City,
+		AddressSecondLast:       src.AddressSecondLast,
+		DeviceAddress:           src.DeviceAddress,
+		EmsDeviceVer:            src.EmsDeviceVer,
+		Site:                    src.Site,
+		SiteId:                  src.SiteId,
+	}
+}

+ 173 - 0
service/domain/smartHubDomain.go

@@ -0,0 +1,173 @@
+package domain
+
+import (
+	"time"
+
+	"go.uber.org/zap"
+)
+
+type SmartHubSimCardInfo struct {
+	GICCID             string `bson:"giccid" json:"giccid"`               // sim卡唯一标识
+	IMEI               string `bson:"imei" json:"imei"`                   // 国际移动用户识别码
+	IMSI               string `bson:"imsi" json:"imsi"`                   // 国际移动用户识别码
+	MSISDN             string `bson:"msisdn" json:"msisdn"`               // 移动用户号码
+	Status             string `bson:"status" json:"status"`               // SimStatus
+	SellTime           string `bson:"sellTime" json:"sellTime"`           // 销售时间
+	ActivatedTime      string `bson:"activatedTime" json:"activatedTime"` // 首次激活时间
+	CurrentCycleUsages struct {
+		Name       string  `bson:"name" json:"name"`             // 周期名称 exp.2018-10
+		DataUsage  float64 `bson:"dataUsage" json:"dataUsage"`   // 流量用量 KB
+		SmsUsage   uint    `bson:"smsUsage" json:"smsUsage"`     // 短信用量 条
+		VoiceUsage uint    `bson:"voiceUsage" json:"voiceUsage"` // 语音用量 6s * n
+	} `bson:"currentCycleUsages" json:"currentCycleUsages"` // 当前周期数据、短信、语言用量
+	CurrentCycleAPNUsagesList []struct {
+		APN       string `bson:"apn" json:"apn"`             // APN名称
+		DataUsage string `bson:"dataUsage" json:"dataUsage"` // 流量用量 KB
+	} `bson:"currentCycleAPNUsagesList" json:"currentCycleAPNUsagesList"` // 设备当前周期APN流量统计
+	ServicePackages []struct {
+		ID            int    `bson:"id" json:"id"`                       // 资费方案id
+		Name          string `bson:"name" json:"name"`                   // 资费方案名称
+		SubscribeTime string `bson:"subscribeTime" json:"subscribeTime"` // 订购时间
+		StartTime     string `bson:"startTime" json:"startTime"`         // 生效时间
+		EndTime       string `bson:"endTime" json:"endTime"`             // 失效时间
+	} `bson:"servicePackages" json:"servicePackages"` // 资费包
+	ApnList []struct {
+		ApnName   string `bson:"apnName" json:"apnName"`     // APN名称
+		ApnStatus string `bson:"apnStatus" json:"apnStatus"` // APN状态 "CLOSED" or "OPEN"
+	} `bson:"apnList" json:"apnList"` // APN列表
+}
+
+type SmartHubSimCardInfoItem struct {
+	GICCID     string `bson:"giccid" json:"giccid"` // sim卡唯一标识
+	DeviceInfo []struct {
+		IMEI   string `bson:"imei" json:"imei"`     // 国际移动用户识别码
+		IMSI   string `bson:"imsi" json:"imsi"`     // 国际移动用户识别码
+		MSISDN string `bson:"msisdn" json:"msisdn"` // 移动用户号码
+	} `bson:"deviceInfo" json:"deviceInfo"`
+	Status             string `bson:"status" json:"status"`               // SimStatus
+	SellTime           string `bson:"sellTime" json:"sellTime"`           // 销售时间
+	ActivatedTime      string `bson:"activatedTime" json:"activatedTime"` // 首次激活时间
+	CurrentCycleUsages struct {
+		DataUsage  float64 `bson:"dataUsage" json:"dataUsage"`   // 流量用量 KB
+		SmsUsage   uint    `bson:"smsUsage" json:"smsUsage"`     // 短信用量 条
+		VoiceUsage uint    `bson:"voiceUsage" json:"voiceUsage"` // 语音用量 6s * n
+	} `bson:"currentCycleUsages" json:"currentCycleUsages"` // 当前周期数据、短信、语言用量
+	CurrentCycleAPNUsagesList []struct {
+		APN       string `bson:"apn" json:"apn"`             // APN名称
+		DataUsage string `bson:"dataUsage" json:"dataUsage"` // 流量用量 KB
+	} `bson:"currentCycleAPNUsagesList" json:"currentCycleAPNUsagesList"` // 设备当前周期APN流量统计
+	CurrentBasicServicePackages struct {
+		Name      string `bson:"name" json:"name"`           // 资费方案名称
+		StartTime string `bson:"startTime" json:"startTime"` // 生效时间
+		EndTime   string `bson:"endTime" json:"endTime"`     // 失效时间
+	} `bson:"currentBasicServicePackages" json:"currentBasicServicePackage"` // 资费包
+	ApnList []struct {
+		ApnName   string `bson:"apnName" json:"apnName"`     // APN名称
+		ApnStatus string `bson:"apnStatus" json:"apnStatus"` // APN状态 "CLOSED" or "OPEN"
+	} `bson:"apnList" json:"apnList"` // APN列表
+}
+
+type SmartHubSimCardInfoList struct {
+	Data  []SmartHubSimCardInfoItem `bson:"data" json:"data"`
+	Page  uint                      `bson:"page" json:"page"`
+	Size  uint                      `bson:"size" json:"size"`
+	Total uint64                    `bson:"total" json:"total"`
+}
+
+type SmartHubSimCardInfoStorage struct {
+	SN                 string `bson:"sn" json:"sn"`                       // 当前使用设备的SN
+	GICCID             string `bson:"_id" json:"giccid"`                  // sim卡唯一标识
+	IMEI               string `bson:"imei" json:"imei"`                   // 国际移动用户识别码
+	IMSI               string `bson:"imsi" json:"imsi"`                   // 国际移动用户识别码
+	MSISDN             string `bson:"msisdn" json:"msisdn"`               // 移动用户号码
+	Status             string `bson:"status" json:"status"`               // SimStatus
+	SellTime           string `bson:"sellTime" json:"sellTime"`           // 销售时间
+	ActivatedTime      string `bson:"activatedTime" json:"activatedTime"` // 首次激活时间
+	CurrentCycleUsages struct {
+		Name       string  `bson:"name" json:"name"`             // 周期名称 exp.2018-10
+		DataUsage  float64 `bson:"dataUsage" json:"dataUsage"`   // 流量用量 KB
+		SmsUsage   uint    `bson:"smsUsage" json:"smsUsage"`     // 短信用量 条
+		VoiceUsage uint    `bson:"voiceUsage" json:"voiceUsage"` // 语音用量 6s * n
+	} `bson:"currentCycleUsages" json:"currentCycleUsages"` // 当前周期数据、短信、语言用量
+	CurrentBasicServicePackages struct {
+		Name      string `bson:"name" json:"name"`           // 资费方案名称
+		StartTime string `bson:"startTime" json:"startTime"` // 生效时间
+		EndTime   string `bson:"endTime" json:"endTime"`     // 失效时间
+	} `bson:"currentBasicServicePackages" json:"currentBasicServicePackage"` // 资费包
+}
+
+// Code generated. SmartHubSimCardInfo -> SmartHubSimCardInfoStorage
+func SmartHubSimCardInfoToStorage(src *SmartHubSimCardInfo) *SmartHubSimCardInfoStorage {
+	if src == nil {
+		return nil
+	}
+
+	infoStorage := SmartHubSimCardInfoStorage{
+		GICCID:             src.GICCID,
+		IMEI:               src.IMEI,
+		IMSI:               src.IMSI,
+		MSISDN:             src.MSISDN,
+		Status:             src.Status,
+		SellTime:           src.SellTime,
+		ActivatedTime:      src.ActivatedTime,
+		CurrentCycleUsages: src.CurrentCycleUsages,
+	}
+
+	nowTime := time.Now()
+	for _, pack := range src.ServicePackages {
+		startTime, err := time.Parse(time.RFC3339, pack.StartTime)
+		if err != nil {
+			zap.L().Error("Parse SmartHub Sim Card Info ServicePackage StartTime Error.", zap.String("err", err.Error()))
+			continue
+		}
+
+		endTime, err := time.Parse(time.RFC3339, pack.EndTime)
+		if err != nil {
+			zap.L().Error("Parse SmartHub Sim Card Info ServicePackage EndTime Error.", zap.String("err", err.Error()))
+			continue
+		}
+
+		if (nowTime.Equal(startTime) || nowTime.After(startTime)) &&
+			(nowTime.Equal(endTime) || nowTime.Before(endTime)) {
+			// 在区间内(含 start 和 end)
+			infoStorage.CurrentBasicServicePackages.Name = pack.Name
+			infoStorage.CurrentBasicServicePackages.StartTime = pack.StartTime
+			infoStorage.CurrentBasicServicePackages.EndTime = pack.EndTime
+			break
+		}
+	}
+
+	return &infoStorage
+}
+
+// Code generated. SmartHubSimCardInfoItem -> SmartHubSimCardInfoStorage
+func SmartHubSimCardInfoItemToStorage(src *SmartHubSimCardInfoItem) *SmartHubSimCardInfoStorage {
+	if src == nil {
+		return nil
+	}
+
+	infoStorage := SmartHubSimCardInfoStorage{
+		GICCID:                      src.GICCID,
+		IMEI:                        "",
+		IMSI:                        "",
+		MSISDN:                      "",
+		Status:                      src.Status,
+		SellTime:                    src.SellTime,
+		ActivatedTime:               src.ActivatedTime,
+		CurrentBasicServicePackages: src.CurrentBasicServicePackages,
+	}
+
+	if len(src.DeviceInfo) > 0 {
+		infoStorage.IMEI = src.DeviceInfo[0].IMEI
+		infoStorage.IMSI = src.DeviceInfo[0].IMSI
+		infoStorage.MSISDN = src.DeviceInfo[0].MSISDN
+	}
+
+	nowTime := time.Now()
+	infoStorage.CurrentCycleUsages.Name = nowTime.Format("2006-01")
+	infoStorage.CurrentCycleUsages.DataUsage = src.CurrentCycleUsages.DataUsage
+	infoStorage.CurrentCycleUsages.SmsUsage = src.CurrentCycleUsages.SmsUsage
+	infoStorage.CurrentCycleUsages.VoiceUsage = src.CurrentCycleUsages.VoiceUsage
+
+	return &infoStorage
+}

+ 24 - 0
service/domain/userInfoDomain.go

@@ -0,0 +1,24 @@
+package domain
+
+type UserInfoDomain struct {
+	Name   string             `bson:"_id" json:"name"`
+	Info   UserBaseInfoDomain `bson:"info" json:"info"`
+	Config UserConfigDomain   `bson:"config" json:"config"`
+}
+
+type UserBaseInfoDomain struct {
+}
+
+type UserConfigDomain struct {
+	SiteInfoManage *SiteInfoManageConfig `bson:"siteInfoManage,omitempty" json:"siteInfoManage,omitempty"`
+}
+
+type SiteInfoManageConfig struct {
+	TitleFilter       *[]string              `bson:"titleFilter,omitempty" json:"titleFilter,omitempty"`
+	CustomQueryFilter *[]SiteInfoQueryFilter `bson:"customQueryFilter,omitempty" json:"customQueryFilter,omitempty"`
+}
+
+type SiteInfoQueryFilter struct {
+	Name   string         `bson:"name" json:"name"`
+	Filter SiteInfoFilter `bson:"filter" json:"filter"`
+}

+ 3 - 2
service/domain/xlogDomain.go

@@ -1,6 +1,7 @@
 package domain
 
 type XlogSiteInfo struct {
-	Sn   string `json:"sn"`
-	Uuid string `json:"uuid"`
+	Sn      string `json:"sn"`
+	Uuid    string `json:"uuid"`
+	Version string `json:"version"`
 }

+ 184 - 0
service/dto/sitesInfoManageDTO.go

@@ -0,0 +1,184 @@
+package dto
+
+import (
+	"fwh-toolbox-server/domain"
+)
+
+type SiteInfoRequest struct {
+	Search   string                 `json:"search"`
+	Filter   *domain.SiteInfoFilter `json:"filter"`
+	PageSize uint                   `json:"pageSize"`
+	PageNum  uint                   `json:"pageNum"`
+}
+
+type SiteInfoDTO struct {
+	Sn                 string  `bson:"_id" json:"sn"` // aGate SN
+	Name               *string `bson:"name,omitempty" json:"name,omitempty"`
+	DistributorId      *int    `bson:"distributorId,omitempty" json:"distributorId,omitempty"`           // 经销商ID
+	DistributorName    *string `bson:"distributorName,omitempty" json:"distributorName,omitempty"`       // 经销商名称
+	InstallerId        *int    `bson:"installerId,omitempty" json:"installerId,omitempty"`               // 安装商ID
+	InstallerAccountId *int    `bson:"installerAccountId,omitempty" json:"installerAccountId,omitempty"` // 安装商账号ID
+	InstallerAccount   *string `bson:"installerAccount,omitempty" json:"installerAccount,omitempty"`     // 安装商账号
+	InstallerName      *string `bson:"installerName,omitempty" json:"installerName,omitempty"`           // 安装商名称
+	MaintId            *int    `bson:"maintId,omitempty" json:"maintId,omitempty"`                       // 维护商ID
+	MaintName          *string `bson:"maintName,omitempty" json:"maintName,omitempty"`                   // 维护商名称
+	Account            *string `bson:"account,omitempty" json:"account,omitempty"`                       // 绑定的用户账号
+	AccountName        *string `bson:"accountName,omitempty" json:"accountName,omitempty"`               // 绑定账号名
+	Longitude          string  `bson:"longitude" json:"longitude"`                                       // 经度
+	Latitude           string  `bson:"latitude" json:"latitude"`                                         // 纬度
+	Address            *string `bson:"address,omitempty" json:"address,omitempty"`
+	InstallTime        *string `bson:"installTime,omitempty" json:"installTime,omitempty"` // 安装时间
+	ActiveStatus       int     `bson:"activeStatus" json:"activeStatus"`                   // 激活状态 0-未激活 1-激活
+	ActiveTime         string  `bson:"activeTime" json:"activeTime"`                       // 激活时间(北京时间)
+	LocalActiveTime    string  `bson:"localActiveTime" json:"localActiveTime"`             // 激活时间(站点当地时间)
+	Status             int     `bson:"status" json:"status"`                               // 网络状态 0-离线 1-在线
+	OnLineTime         *string `bson:"onLineTime,omitempty" json:"onLineTime,omitempty"`   // 最后上线时间 YYYY-MM-DD HH:mm:SS
+	OffLineTime        *string `bson:"offLineTime,omitempty" json:"offLineTime,omitempty"` // 最后离线时间 YYYY-MM-DD HH:mm:SS
+	// model * ??? 未知字段 (TODO:后续处理)
+	DeviceIp        *string `bson:"deviceIp,omitempty" json:"deviceIp,omitempty"`   // 设备联网IP
+	Subnet          *string `bson:"subnet,omitempty" json:"subnet,omitempty"`       // 子掩码
+	Mac             *string `bson:"mac,omitempty" json:"mac,omitempty"`             // MAC地址
+	GatewayIp       *string `bson:"gatewayIp,omitempty" json:"gatewayIp,omitempty"` // 网关IP
+	Signal          *string `bson:"signal,omitempty" json:"signal,omitempty"`       // 信号强度
+	ICCID           string  `bson:"iccId" json:"iccId"`
+	IMEI            string  `bson:"imei" json:"imei"`
+	FirmUpgradeTime *string `bson:"firmUpgradeTime,omitempty" json:"firmUpgradeTime,omitempty"` // 固件最后更新时间 YYYY-MM-DD HH:mm:SS
+	TurnoverTime    *string `bson:"turnoverTime,omitempty" json:"turnoverTime,omitempty"`       // 周转时间??? YYYY-MM-DD HH:mm:SS
+	CreateTime      *string `bson:"createTime,omitempty" json:"createTime,omitempty"`           // 设备首次登录时间 YYYY-MM-DD HH:mm:SS
+	// 忽略部分功率字段powerSun、powerGen、powerUti、powerFhp、powerLoad
+	BatterySoc      *float32 `bson:"batterySoc,omitempty" json:"batterySoc,omitempty"` // 系统电池SOC
+	RunStatus       int      `bson:"runStatus" json:"runStatus"`                       // 设备运行状态 0-待机 1-充电 2-放电 3-故障
+	EmsModel        *int     `bson:"emsModel,omitempty" json:"emsModel,omitempty"`     // EMS调度运行模式(策略) 参考调度ID
+	ElectricityType int      `bson:"electricityType" json:"electricityType"`           // 电网类型 0-离网运行 1-并网运行
+	// workMode * ??? 未知字段 (TODO:后续处理)
+	FhpSn            string  `bson:"fhpSn" json:"fhpSn"`                                           // aPower SN 多设备用,分割
+	TimeZone         float32 `bson:"timeZone" json:"timeZone"`                                     // 时区数值
+	Version          string  `bson:"version" json:"version"`                                       // 设备固件版本
+	SlVersion        string  `bson:"slVersion" json:"slVersion"`                                   // EMS-DSP版本
+	SlVersionJson    string  `bson:"slVersionJson" json:"slVersionJson"`                           // EMS-DSP版本详细信息JSON
+	MeterVersion     *string `bson:"meterVersion,omitempty" json:"meterVersion,omitempty"`         // 电表版本
+	MbVersion        *string `bson:"mbVersion,omitempty" json:"mbVersion,omitempty"`               // MODBUS版本
+	AwsVersion       *string `bson:"awsVersion,omitempty" json:"awsVersion,omitempty"`             // AWS版本
+	AppVersion       *string `bson:"appVersion,omitempty" json:"appVersion,omitempty"`             // APP版本(与APP通讯的模块软件)
+	ElecnetStatus    int     `bson:"elecnetStatus" json:"elecnetStatus"`                           // 电网状态 0-离网 1-并网
+	SlaverStat       int     `bson:"slaverStat" json:"slaverStat"`                                 // EMS-DSP状态
+	Dst              *int    `bson:"dst,omitempty" json:"dst,omitempty"`                           // 夏令时 0-否 1-是
+	ZoneInfo         string  `bson:"zoneInfo" json:"zoneInfo"`                                     // 时区信息 America/Los_Angeles
+	PostCode         string  `bson:"postCode" json:"postCode"`                                     // 邮编
+	DeviceTime       *string `bson:"deviceTime,omitempty" json:"deviceTime,omitempty"`             // 设备时间 YYYY-MM-DD HH:mm:SS
+	ProtocolVer      string  `bson:"protocolVer" json:"protocolVer"`                               // 协议版本
+	SysHdVersion     int     `bson:"sysHdVersion" json:"sysHdVersion"`                             // aGate硬件版本
+	RealSysHdVersion *string `bson:"realSysHdVersion,omitempty" json:"realSysHdVersion,omitempty"` // aGate系统版本名称
+	WifiSignal       string  `bson:"wifiSignal" json:"wifiSignal"`                                 // WIFI信号
+	ConnType         int     `bson:"connType" json:"connType"`                                     // 网络连接类型 0-未连接 1-eth0 2-eth1 3-WIFI 4-4G
+	UserName         *string `bson:"userName,omitempty" json:"userName,omitempty"`
+	ContactNumber    *string `bson:"contactNumber,omitempty" json:"contactNumber,omitempty"` // 联系方式(手机号)
+	// 忽略部分未知字段totalData、usedData、resourceList
+	FinancierName           *string `bson:"financierName,omitempty" json:"financierName,omitempty"`                     // 大客户名称
+	FinancierContractNumber *string `bson:"financierContractNumber,omitempty" json:"financierContractNumber,omitempty"` // 大客户联系方式(手机号)
+	CountryId               *int    `bson:"countryId,omitempty" json:"countryId,omitempty"`                             // 国家ID
+	ProvinceId              *int    `bson:"provinceId,omitempty" json:"provinceId,omitempty"`                           // 州/省ID
+	FinancierId             *int    `bson:"financierId,omitempty" json:"financierId,omitempty"`                         // 大客户ID
+	FirstName               *string `bson:"firstName,omitempty" json:"firstName,omitempty"`                             // 用户名
+	LastName                *string `bson:"lastName,omitempty" json:"lastName,omitempty"`                               // 用户姓
+	// region * ??? 未知字段 (TODO:后续处理)
+	Remark            *string `bson:"remark,omitempty" json:"remark,omitempty"` // 备注信息
+	GenEn             int     `bson:"genEn" json:"genEn"`                       // 发电机是否使能 0-不使能 1-使能
+	UserId            *int    `bson:"userId,omitempty" json:"userId,omitempty"` // 用户ID
+	Country           string  `bson:"country" json:"country"`                   // 国家
+	Province          string  `bson:"province" json:"province"`                 // 州/省
+	City              string  `bson:"city" json:"city"`                         // 城市
+	AddressSecondLast *string `bson:"addressSecondLast,omitempty" json:"addressSecondLast,omitempty"`
+	DeviceAddress     *string `bson:"deviceAddress,omitempty" json:"deviceAddress,omitempty"` // 设备地址
+	// whetherEncrypt * ??? 未知字段 (TODO:后续处理)
+	EmsDeviceVer *int    `bson:"emsDeviceVer,omitempty" json:"emsDeviceVer,omitempty"` // EMS版本
+	Site         *string `bson:"site,omitempty" json:"site,omitempty"`                 // 站点ID名
+	// 忽略部分字段newHomeFlag、sleepStatus、isJoinJA12
+	SiteId  *uint   `bson:"siteId,omitempty" json:"siteId,omitempty"`   // 站点ID号
+	JobCode *string `bson:"jobCode,omitempty" json:"jobCode,omitempty"` // Job Code (Sunrun相关)
+}
+
+// Code generated. SiteInfoDomain -> SiteInfoDTO
+func SiteInfoDomainToSiteInfoDTO(src *domain.SiteInfoDomain) *SiteInfoDTO {
+	if src == nil {
+		return nil
+	}
+
+	return &SiteInfoDTO{
+		Sn:                      src.Sn,
+		Name:                    src.Name,
+		DistributorId:           src.DistributorId,
+		DistributorName:         src.DistributorName,
+		InstallerId:             src.InstallerId,
+		InstallerAccountId:      src.InstallerAccountId,
+		InstallerAccount:        src.InstallerAccount,
+		InstallerName:           src.InstallerName,
+		MaintId:                 src.MaintId,
+		MaintName:               src.MaintName,
+		Account:                 src.Account,
+		AccountName:             src.AccountName,
+		Longitude:               src.Longitude,
+		Latitude:                src.Latitude,
+		Address:                 src.Address,
+		InstallTime:             src.InstallTime,
+		ActiveStatus:            src.ActiveStatus,
+		ActiveTime:              src.ActiveTime,
+		Status:                  src.Status,
+		OnLineTime:              src.OnLineTime,
+		OffLineTime:             src.OffLineTime,
+		DeviceIp:                src.DeviceIp,
+		Subnet:                  src.Subnet,
+		Mac:                     src.Mac,
+		GatewayIp:               src.GatewayIp,
+		Signal:                  src.Signal,
+		ICCID:                   src.ICCID,
+		IMEI:                    src.IMEI,
+		FirmUpgradeTime:         src.FirmUpgradeTime,
+		TurnoverTime:            src.TurnoverTime,
+		CreateTime:              src.CreateTime,
+		BatterySoc:              src.BatterySoc,
+		RunStatus:               src.RunStatus,
+		EmsModel:                src.EmsModel,
+		ElectricityType:         src.ElectricityType,
+		FhpSn:                   src.FhpSn,
+		TimeZone:                src.TimeZone,
+		Version:                 src.Version,
+		SlVersion:               src.SlVersion,
+		SlVersionJson:           src.SlVersionJson,
+		MeterVersion:            src.MeterVersion,
+		MbVersion:               src.MbVersion,
+		AwsVersion:              src.AwsVersion,
+		AppVersion:              src.AppVersion,
+		ElecnetStatus:           src.ElecnetStatus,
+		SlaverStat:              src.SlaverStat,
+		Dst:                     src.Dst,
+		ZoneInfo:                src.ZoneInfo,
+		PostCode:                src.PostCode,
+		DeviceTime:              src.DeviceTime,
+		ProtocolVer:             src.ProtocolVer,
+		SysHdVersion:            src.SysHdVersion,
+		RealSysHdVersion:        src.RealSysHdVersion,
+		WifiSignal:              src.WifiSignal,
+		ConnType:                src.ConnType,
+		UserName:                src.UserName,
+		ContactNumber:           src.ContactNumber,
+		FinancierName:           src.FinancierName,
+		FinancierContractNumber: src.FinancierContractNumber,
+		CountryId:               src.CountryId,
+		ProvinceId:              src.ProvinceId,
+		FinancierId:             src.FinancierId,
+		FirstName:               src.FirstName,
+		LastName:                src.LastName,
+		Remark:                  src.Remark,
+		GenEn:                   src.GenEn,
+		UserId:                  src.UserId,
+		Country:                 src.Country,
+		Province:                src.Province,
+		City:                    src.City,
+		AddressSecondLast:       src.AddressSecondLast,
+		DeviceAddress:           src.DeviceAddress,
+		EmsDeviceVer:            src.EmsDeviceVer,
+		Site:                    src.Site,
+		SiteId:                  src.SiteId,
+	}
+}

+ 28 - 14
service/dto/sitesMonitorDTO.go

@@ -1,5 +1,7 @@
 package dto
 
+import "fwh-toolbox-server/domain"
+
 type SiteBaseInfoPara struct {
 	SiteList []string `json:"siteList"`
 	PageSize uint     `json:"pageSize"`
@@ -27,29 +29,19 @@ type SiteInfoPara struct {
 	PageNum  uint    `json:"pageNum"`
 }
 
-type SiteInfo struct {
+type MonitorSiteInfo struct {
 	Id               string  `json:"id"`
 	Name             *string `json:"name"`
 	FhpSn            string  `json:"fhpSn"`            // aPower SN 多设备用,分割
 	ActiveStatus     int     `json:"activeStatus"`     // 激活状态 0-未激活 1-激活
 	ActiveTime       string  `json:"activeTime"`       // 激活时间(北京时间)
-	LocalActiveTime  string  `json:"localActiveTime"`  // 激活时间(站点当地时间)
 	Country          string  `json:"country"`          // 国家
 	Province         string  `json:"province"`         // 州/省
 	City             string  `json:"city"`             // 城市
-	RealSysHdVersion string  `json:"realSysHdVersion"` // 系统版本信息
+	Status           int     `json:"status"`           // 网络状态 0-离线 1-在线
+	RealSysHdVersion *string `json:"realSysHdVersion"` // aGate系统版本名称
+	RunStatus        int     `json:"runStatus"`        // 设备运行状态 0-待机 1-充电 2-放电 3-故障
 	Version          string  `json:"version"`          // 设备固件版本
-	InstallerName    string  `json:"installerName"`    // 安装商名称
-	Longitude        string  `json:"longitude"`        // 经度
-	Latitude         string  `json:"latitude"`         // 纬度
-	ICCID            string  `json:"iccId"`
-	IMEI             string  `json:"imei"`
-	FirmUpgradeTime  string  `json:"firmUpgradeTime"` // 固件最后更新时间 YYYY-MM-DD HH:mm:SS
-	CreateTime       string  `json:"createTime"`      // 设备首次登录时间 YYYY-MM-DD HH:mm:SS
-	TimeZone         float32 `json:"timeZone"`        // 时区数值
-	ZoneInfo         string  `json:"zoneInfo"`        // 时区信息 America/Los_Angeles
-	GenEn            int     `json:"genEn"`           // 发电机是否使能 0-不使能 1-使能
-	Remark           *string `json:"remark"`          // 备注信息
 }
 
 type WorkModeInfo struct {
@@ -96,3 +88,25 @@ type SiteAlarmList struct {
 	LogNameEn       string `json:"logNameEn"`
 	CommonLogNameEn string `json:"commonLogNameEn"`
 }
+
+// Code generated. FwhSiteInfo -> MonitorSiteInfo
+func FwhSiteInfoToMonitorSiteInfo(src *domain.FwhSiteInfo) *MonitorSiteInfo {
+	if src == nil {
+		return nil
+	}
+
+	return &MonitorSiteInfo{
+		Id:               src.Id,
+		Name:             src.Name,
+		FhpSn:            src.FhpSn,
+		ActiveStatus:     src.ActiveStatus,
+		ActiveTime:       src.ActiveTime,
+		Country:          src.Country,
+		Province:         src.Province,
+		City:             src.City,
+		Status:           src.Status,
+		RealSysHdVersion: src.RealSysHdVersion,
+		RunStatus:        src.RunStatus,
+		Version:          src.Version,
+	}
+}

+ 3 - 1
service/go.mod

@@ -30,6 +30,8 @@ require (
 )
 
 require (
+	github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
+	github.com/gorilla/websocket v1.5.3 // indirect
 	github.com/mattn/go-sqlite3 v1.14.16 // indirect
 	github.com/richardlehane/mscfb v1.0.4 // indirect
 	github.com/richardlehane/msoleps v1.0.4 // indirect
@@ -105,7 +107,7 @@ require (
 	golang.org/x/net v0.46.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/sys v0.37.0 // indirect
-	golang.org/x/text v0.30.0
+	golang.org/x/text v0.30.0 // indirect
 	golang.org/x/tools v0.37.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 4 - 0
service/go.sum

@@ -26,6 +26,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
+github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
 github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
 github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -70,6 +72,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=

+ 9 - 6
service/main.go

@@ -30,27 +30,27 @@ func main() {
 	if err != nil {
 		panic(err)
 	}
-	err = logger.Init(cfg.AppEnv)
+	err = logger.Init(cfg.App.AppEnv)
 	if err != nil {
 		panic(err)
 	}
 	defer logger.Log.Sync() // 退出前同步zap缓冲区
 
 	// 初始化数据库
-	err = database.Init(cfg)
+	err = database.Init(&cfg.App)
 	if err != nil {
 		zap.L().Error("database init failed.", zap.String("err", err.Error()))
 		panic(err)
 	}
 
 	// 初始化缓存
-	cache.Init(cfg.RedisAddr)
+	cache.Init(cfg.App.RedisAddr)
 
 	// 初始化Minio
-	minio.InitClient(cfg)
+	minio.InitClient(&cfg.App)
 
 	// 初始化FwhApi
-	repositories.FwhHttpInit()
+	repositories.FwhHttpInit(&cfg.FwhApi)
 
 	// 初始化计划任务
 	scheduler.Init()
@@ -61,6 +61,9 @@ func main() {
 	// 初始化Casdoor
 	casdoor.InitAuthConfig()
 
+	// 初始化FWH MQTT客户端
+	repositories.FwhMqttInit(&cfg.FwhMqtt)
+
 	app := fiber.New()
 	app.Use(func(c *fiber.Ctx) error {
 		logger.Log.Info("请求", zap.String("path", c.Path()))
@@ -88,7 +91,7 @@ func main() {
 		// panic(err)
 	}
 
-	err = app.Listen("localhost:" + cfg.AppPort)
+	err = app.Listen("localhost:" + cfg.App.AppPort)
 	if err != nil {
 		zap.L().Error("Port listening failed.", zap.String("err", err.Error()))
 		panic(err)

+ 5 - 0
service/middleware/auth.go

@@ -141,6 +141,11 @@ func AuthGetUserInfo(c *fiber.Ctx) *AuthUserInfo {
 	return c.Locals("user").(*AuthUserInfo)
 }
 
+func AuthGetUserName(c *fiber.Ctx) string {
+	userInfo := AuthGetUserInfo(c)
+	return userInfo.Name
+}
+
 func AuthIsAdmin(c *fiber.Ctx) bool {
 	userInfo := AuthGetUserInfo(c)
 	return userInfo.IsAdmin

+ 64 - 17
service/networkEvents/evioEvents.go

@@ -6,9 +6,11 @@ import (
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/logger"
 	"fwh-toolbox-server/utils/xlog"
+	"regexp"
 	"sync"
 	"time"
 
+	"github.com/google/uuid"
 	"github.com/tidwall/evio"
 	"go.uber.org/zap"
 )
@@ -29,16 +31,22 @@ func InitEvio() error {
 }
 
 func EventsOpened(c evio.Conn) (out []byte, opts evio.Options, action evio.Action) {
+	logger.Std.Println("EventsOpened ", c.RemoteAddr().String())
+	newUUID := uuid.New().String()
 	connectContext := &ConnectContext{
-		UUID:              "",
+		UUID:              newUUID,
 		SN:                "",
+		Version:           "xlog v1.0",
 		XlogPackageParser: nil,
 		DoneChan:          make(chan string, 1), // 缓冲避免阻塞
+		ChanIsRunning:     false,
 	}
 	connectContext.XlogPackageParser = xlog.NewXlogPackageParser(connectContext)
 	connectContext.SendBuffer.Store([]byte(nil))
 	c.SetContext(connectContext)
 
+	connMap.Store(newUUID, c) // 预保存连接(兼容xlog 1.0)
+
 	// connMap.Store(c.RemoteAddr().String(), c) // 保存连接
 	// xlogPackageParserMap.Store(c.RemoteAddr().String(), xlog.NewXlogPackageParser())
 	opts = evio.Options{}
@@ -67,8 +75,8 @@ func EventsData(c evio.Conn, in []byte) (out []byte, action evio.Action) {
 		return
 	}
 
-	uuid := connectContext.UUID
-	logger.Std.Println("接收数据 UUID: ", uuid)
+	// logger.Std.Println("接收数据 UUID: ", connectContext.UUID)
+	// logger.Std.Println("接收数据: ", in)
 
 	out = nil
 
@@ -82,22 +90,38 @@ func EventsData(c evio.Conn, in []byte) (out []byte, action evio.Action) {
 		// 处理数据接收事件
 		parser := connectContext.XlogPackageParser
 		if !parser.Handshake {
-			data, err := parser.ParseXlogHandshakePackage(in)
+			// 先判断协议版本,是否是xlog1.0
+			ok, err := parser.ParseXlogCheckVersionIsOld(in)
 			if err != nil {
-				zap.L().Error("握手失败", zap.String("err", err.Error()))
+				zap.L().Error("版本判断失败", zap.String("err", err.Error()))
 			} else {
-				out, err = parser.CreateXlogHandshakePackage()
-				if err != nil {
-					zap.L().Error("握手失败", zap.String("err", err.Error()))
-				} else {
+				if ok {
 					parser.Handshake = true
-
-					connectContext.UUID = string(data.DevUuid[:])
-					connMap.Store(string(data.DevUuid[:]), c) // 保存连接
-
-					zap.L().Info("握手校验", zap.Binary("newIv", data.NewIv[:]))
+					parser.PreparserXlog10Package(in)
+				} else {
+					data, err := parser.ParseXlogHandshakePackage(in)
+					if err != nil {
+						zap.L().Error("握手失败", zap.String("err", err.Error()))
+					} else {
+						out, err = parser.CreateXlogHandshakePackage()
+						if err != nil {
+							zap.L().Error("握手失败", zap.String("err", err.Error()))
+						} else {
+							parser.Handshake = true
+							parser.IsOldProtocol = false
+
+							connMap.Delete(connectContext.UUID)           // 删除预保存的连接,使用xlog v2.0的UUID
+							connectContext.UUID = string(data.DevUuid[:]) // 覆盖UUID
+							connectContext.Version = "xlog v2.0"
+							connMap.Store(connectContext.UUID, c) // 保存xlog v2.0的连接
+
+							zap.L().Info("握手校验", zap.Binary("newIv", data.NewIv[:]))
+						}
+					}
 				}
 			}
+		} else if parser.IsOldProtocol {
+			parser.PreparserXlog10Package(in)
 		} else {
 			parser.PreparserXlogPackage(in)
 		}
@@ -113,8 +137,9 @@ func GetConnectList() []domain.XlogSiteInfo {
 		conn := value.(evio.Conn)
 		ctx := conn.Context().(*ConnectContext)
 		infoList = append(infoList, domain.XlogSiteInfo{
-			Sn:   ctx.SN,
-			Uuid: key.(string),
+			Sn:      ctx.SN,
+			Uuid:    key.(string),
+			Version: ctx.Version,
 		})
 		return true
 	})
@@ -131,6 +156,15 @@ func SendXlogCmdDate(uuid string, data []byte) (string, error) {
 			return "", err
 		}
 
+		// 判断连接是否在传输结果
+		if connectContext.ChanIsRunning {
+			return "命令正在执行中,请稍后...", nil
+		}
+		connectContext.ChanIsRunning = true
+		defer func() {
+			connectContext.ChanIsRunning = false
+		}()
+
 		buffer, _ := connectContext.SendBuffer.Load().([]byte)
 		if buffer != nil {
 			err = fmt.Errorf("send xlog cmd filed : Data sending, UUID: %s", uuid)
@@ -145,7 +179,12 @@ func SendXlogCmdDate(uuid string, data []byte) (string, error) {
 		connectContext.DoneChan = done
 
 		// 存储要发送的数据
-		cmdPackage, err := connectContext.XlogPackageParser.CreateXlogCmdPackage(append([]byte(connectContext.UUID)[len(uuid)-8:], data...))
+		var cmdPackage []byte
+		if connectContext.XlogPackageParser.IsOldProtocol {
+			cmdPackage, err = connectContext.XlogPackageParser.CreateXlog10CmdPackage(data)
+		} else {
+			cmdPackage, err = connectContext.XlogPackageParser.CreateXlogCmdPackage(append([]byte(connectContext.UUID)[len(uuid)-8:], data...))
+		}
 		if err != nil {
 			return "", err
 		}
@@ -156,6 +195,14 @@ func SendXlogCmdDate(uuid string, data []byte) (string, error) {
 		// 等待结果或超时
 		select {
 		case result := <-done:
+			if connectContext.XlogPackageParser.IsOldProtocol && len(connectContext.SN) == 0 {
+				re := regexp.MustCompile(`"AGATE_SN"\s*:\s*"([^"]*)"`)
+				match := re.FindStringSubmatch(result)
+				if len(match) > 1 {
+					fmt.Println("AGATE_SN:", match[1])
+					connectContext.SN = match[1]
+				}
+			}
 			return result, nil
 		case <-ctx.Done():
 			return "", fmt.Errorf("send xlog cmd timeout, UUID: %s, err: %v", uuid, ctx.Err())

+ 82 - 0
service/repositories/api/acceleronix/acceleronixHttpClient.go

@@ -0,0 +1,82 @@
+package acceleronix
+
+import (
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fwh-toolbox-server/utils/httpClient"
+	"net/http"
+	"strconv"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+const (
+	mainUrl                 = "https://cmp.acceleronix.io/gateway/openapi"
+	appKey                  = "0cE57qK03qsA9158"
+	appSecret               = "6051y5YS"
+	timeout   time.Duration = 30 * time.Second // SmartHub请求超时30s
+)
+
+var acceleronixHttpClient = httpClient.HttpClient{}
+
+func makeSignature(timestamp, requestBody string) string {
+	message := appKey + timestamp + requestBody
+
+	// 创建 HMAC-SHA256
+	h := hmac.New(sha256.New, []byte(appSecret))
+
+	// 写入数据
+	h.Write([]byte(message))
+
+	// 计算摘要
+	signature := h.Sum(nil)
+
+	// hex 编码
+	return hex.EncodeToString(signature)
+}
+
+func bodyToStableString(body any) (string, error) {
+	if body == nil {
+		return "", nil
+	}
+
+	switch v := body.(type) {
+	case string:
+		return v, nil
+
+	case []byte:
+		return string(v), nil
+
+	default:
+		b, err := json.Marshal(v)
+		if err != nil {
+			return "", err
+		}
+		return string(b), nil
+	}
+}
+
+func acceleronixHttpPost(url string, body any) (*httpClient.Response, error) {
+	t := timeout
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+
+	bodyStr, err := bodyToStableString(body)
+	if err != nil {
+		zap.L().Error("body marshal failed.", zap.Any("body", body), zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"APP-Key":   {appKey},
+			"Signature": {makeSignature(timestamp, bodyStr)},
+			"Timestamp": {timestamp},
+			"Accept":    {"application/json", "text/plain", "*/*"},
+		},
+	}
+	return acceleronixHttpClient.HttpPost(mainUrl+url, body, &opt)
+}

+ 105 - 0
service/repositories/api/acceleronix/simCardManage.go

@@ -0,0 +1,105 @@
+package acceleronix
+
+import (
+	"encoding/json"
+	"fwh-toolbox-server/domain"
+
+	"go.uber.org/zap"
+)
+
+type AcceleronixSimCardState int
+
+const (
+	SCS_PreActivation   AcceleronixSimCardState = iota + 2 // 2 预激活
+	SCS_Test                                               // 3 测试
+	SCS_Silence                                            // 4 沉默
+	SCS_Standby                                            // 5 待机
+	SCS_Activated                                          // 6 激活
+	SCS_Deactivated                                        // 7 停机
+	SCS_Pause                                              // 8 暂停
+	SCS_PreCancellation                                    // 9 预注销
+	SCS_Cancellation                                       // 10 注销
+)
+
+type AcceleronixSimCardListRequestBody struct {
+	EnterpriseCode      *string                  `json:"enterpriseCode,omitempty"`      // 企业编码
+	EnterpriseDataPlan  *string                  `json:"enterpriseDataPlan,omitempty"`  // 企业资费计划名称
+	ExpirationTimeEnd   *string                  `json:"expirationTimeEnd,omitempty"`   // 支持到期时间区间段查询,这里指截止到期时间 格式:yyyy-MM-dd
+	ExpirationTimeStart *string                  `json:"expirationTimeStart,omitempty"` // 支持到期时间区间段查询,这里指起始到期时间 格式:yyyy-MM-dd
+	IccidEnd            *string                  `json:"iccidEnd,omitempty"`            // 支持ICCID号段查询,这里指ICCID截止号码
+	IccidStart          *string                  `json:"iccidStart,omitempty"`          // 支持ICCID号段查询,这里指ICCID起始号码
+	Label               *string                  `json:"label,omitempty"`               // 标签
+	PageNum             uint                     `json:"pageNum"`                       // 页码 默认值:1
+	PageSize            uint                     `json:"pageSize"`                      // 每页记录数 默认值:10 最大值:1000
+	SimState            *AcceleronixSimCardState `json:"simState,omitempty"`            // 卡状态 AcceleronixSimCardState
+	SimType             *string                  `json:"simType,omitempty"`             // 卡类型
+}
+
+type AcceleronixSimCardListResponse struct {
+	Code int `json:"code"` // 响应码
+	Data struct {
+		Current int64 `json:"current"` // 当前页码
+		List    []struct {
+			ActivationTime       string                  `json:"activationTime"`       // 激活时间 格式:yyyy-MM-ddd
+			Enterprise           string                  `json:"enterprise"`           // 归属企业
+			EnterpriseDataPlan   string                  `json:"enterpriseDataPlan"`   // 企业资费计划
+			ExpirationTime       string                  `json:"expirationTime"`       // 到期时间 格式:yyyy-MM-dd
+			Iccid                string                  `json:"iccid"`                // ICCID
+			Imei                 string                  `json:"imei"`                 // IMEI
+			Imsi                 string                  `json:"imsi"`                 // IMSI
+			InitialTime          string                  `json:"initialTime"`          // 开卡时间 格式:yyyy-MM-dd
+			Label                string                  `json:"label"`                // 标签,多个标签时逗号隔开
+			Msisdn               string                  `json:"msisdn"`               // MSISDN
+			NetworkType          string                  `json:"networkType"`          // 网络制式 1:ALL 2:Cat-M 3:LTE-M
+			SimId                string                  `json:"simId"`                // SIM ID
+			SimState             AcceleronixSimCardState `json:"simState"`             // 卡状态 AcceleronixSimCardState
+			SimStateChangeReason string                  `json:"simStateChangeReason"` // 卡状态变更原因
+			SimType              string                  `json:"simType"`              // 卡类型
+		} `json:"list"` // 数据集合
+		Pages int64 `json:"pages"` // 总页码
+		Size  int64 `json:"size"`  // 每页数量
+		Total int64 `json:"total"` // 总条数
+	} `json:"data"` // 响应体
+	Msg   string `json:"msg"` // 响应描述
+	ReqId string `json:"reqId"`
+}
+
+type AcceleronixBatchDataUsageRequestBody struct {
+	Iccids []string `json:"iccids"` // ICCID 最多支持100张卡批量操作
+	Month  string   `json:"month"`  // 统计月份,yyyyMM 日期格式化字符串
+}
+
+type AcceleronixSimCardDataUsageInfo struct {
+	dataUsageDetails       []struct{} `json:"dataUsageDetails"` // 流量详细
+	iccid                  string     `json:"iccid"` // ICCID
+	month                  string     `json:"month"` // 统计月份 格式:yyyyMM
+	outsideRegionDataUsage string     `json:"outsideRegionDataUsage"` // 超区流量 单位:MB
+	remainingData          string     `json:"remainingData"`
+	totalDataAllowance     string     `json:"totalDataAllowance"`
+	totalDataUsage         string     `json:"totalDataUsage"`
+}
+
+// 查询SIM卡列表
+func QuerySimCardList(page uint, size uint) (*AcceleronixSimCardListResponse, error) {
+	res, err := acceleronixHttpPost("/sim/page", AcceleronixSimCardListRequestBody{
+		PageNum:  page,
+		PageSize: size,
+	})
+	if err != nil {
+		zap.L().Error("QuerySimCardList failed.", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	var resList AcceleronixSimCardListResponse
+	if err := json.Unmarshal(res.Body, &resList); err != nil {
+		return nil, err
+	}
+
+	return &resList, nil
+}
+
+// 批量获取SIM卡数据用量信息
+// month 格式YYYYMM exp.202301
+func QueryBatchSimCardDataInfo(iccidList []string, month string) (*domain.SmartHubSimCardInfoList, error) {
+
+}

+ 32 - 6
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -2,6 +2,7 @@ package fwh_pro
 
 import (
 	"encoding/json"
+	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/utils/httpClient"
 	"net/http"
@@ -13,9 +14,33 @@ const (
 	timeout time.Duration = 3 * time.Minute // fwh请求超时3min
 )
 
+var (
+	fwhEnergy     string
+	fwhOss        string
+	fwhUser       string
+	fwhPassword   string
+	fwhHttpClient httpClient.HttpClient
+)
+
 // FwhHttpInit
-func FwhHttpInit() {
-	httpClient.RegisterLogin(reLogin)
+func FwhHttpInit(cfg *config.FwhApiConfig) {
+	fwhEnergy = cfg.FwhEnergy
+	fwhOss = cfg.FwhOss
+	fwhUser = cfg.FwhUser
+	fwhPassword = cfg.FwhPassword
+
+	fwhHttpClient = httpClient.HttpClient{}
+	fwhHttpClient.RegisterLogin(reLogin)
+}
+
+// 获取完整EnergyURL
+func getFullEnergyUrl(relativeURL string) string {
+	return "https://" + fwhEnergy + relativeURL
+}
+
+// 获取完整OssURL
+func getFullOssUrl(relativeURL string) string {
+	return "https://" + fwhOss + relativeURL
 }
 
 // 仅用户登录或者不需要token的请求
@@ -27,7 +52,7 @@ func FwhHttpGetWithoutToken(url string, params url.Values) (*httpClient.Response
 			"Accept": {"application/json", "text/plain", "*/*"},
 		},
 	}
-	return httpClient.HttpGet(url, params, &opt)
+	return fwhHttpClient.HttpGet(url, &params, &opt)
 }
 
 func FwhHttpGet(url string, params url.Values) (*httpClient.Response, error) {
@@ -39,7 +64,7 @@ func FwhHttpGet(url string, params url.Values) (*httpClient.Response, error) {
 			"Accept":     {"application/json", "text/plain", "*/*"},
 		},
 	}
-	return httpClient.HttpGet(url, params, &opt)
+	return fwhHttpClient.HttpGet(url, &params, &opt)
 }
 
 // 仅用户登录或者不需要token的请求
@@ -51,7 +76,7 @@ func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error)
 			"Accept": {"application/json", "text/plain", "*/*"},
 		},
 	}
-	return httpClient.HttpPost(url, body, &opt)
+	return fwhHttpClient.HttpPost(url, body, &opt)
 }
 
 func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
@@ -63,7 +88,7 @@ func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
 			"Accept":     {"application/json", "text/plain", "*/*"},
 		},
 	}
-	return httpClient.HttpPost(url, body, &opt)
+	return fwhHttpClient.HttpPost(url, body, &opt)
 }
 
 /************ 实现标准结构体解析 ************/
@@ -201,6 +226,7 @@ func FwhHttpGetUnmarshalB[T any](url string, params url.Values) (*domain.FwhResp
 }
 
 func FwhHttpPostUnmarshal[T any](url string, body any) (*domain.FwhResponse[T], error) {
+
 	res, err := FwhHttpPost(url, body)
 	if err != nil {
 		return nil, err

+ 3 - 7
service/repositories/api/fwh_pro/fwh_emsLog.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -28,8 +27,7 @@ func queryCallEmsLog(sn string, dateStr string, logType int) (*domain.FwhRespons
 		"lang":      "ZH_CN",
 	}
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/logFileCall", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[bool](url, form)
+	response, err := FwhHttpPostUnmarshal[bool](getFullEnergyUrl("/hes-gateway/manage/logFileCall"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -65,8 +63,7 @@ func queryEmsLogCallRecord(sn string) (*domain.FwhResponse[[]domain.EmsLogRecord
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotDeviceFileRecords", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.EmsLogRecordInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.EmsLogRecordInfo](getFullEnergyUrl("/hes-gateway/manage/iotDeviceFileRecords"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -96,8 +93,7 @@ func QueryEmsLogCallRecord(sn string, dateStr string, logType int) (*domain.EmsL
 
 // 请求下载EMS日志文件
 func QueryDownloadEmsLogFile(filePath string) ([]byte, error) {
-	reqUrl := fmt.Sprintf("https://%s/hes-upload/v1/file/download/"+filePath, config.FWH_ENERGY)
-	response, err := FwhHttpGet(reqUrl, url.Values{})
+	response, err := FwhHttpGet(getFullEnergyUrl("/hes-upload/v1/file/download/"+filePath), url.Values{})
 	if err != nil {
 		return nil, err
 	}

+ 2 - 5
service/repositories/api/fwh_pro/fwh_eventAlarmLog.go

@@ -3,7 +3,6 @@ package fwh_pro
 import (
 	"errors"
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -32,8 +31,7 @@ func queryEventAndAlarmLog(sn string, params url.Values) (*domain.FwhResponse[[]
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotDeviceRunLogs", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.EventAlarmItem](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.EventAlarmItem](getFullEnergyUrl("/hes-gateway/manage/iotDeviceRunLogs"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -201,8 +199,7 @@ func queryAlarmSnapshotInfo(sn, logTime string, page int) (*domain.FwhPageRespon
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectAlarmSnapshotList", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]domain.AlarmSnapshotInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]domain.AlarmSnapshotInfo](getFullEnergyUrl("/hes-gateway/manage/selectAlarmSnapshotList"), params)
 	if err != nil {
 		return nil, err
 	}

+ 6 - 13
service/repositories/api/fwh_pro/fwh_faultRecord.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -21,8 +20,7 @@ func queryFaultRecordList(sn string, startTime string, endTime string) (*domain.
 	params.Add("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/faultRecordingLst", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](getFullEnergyUrl("/hes-gateway/manage/faultRecordingLst"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -51,8 +49,7 @@ func queryFaultRecordCallableList(devType int, sn string) (*domain.FwhResponse[[
 	params.Add("callType", strconv.Itoa(devType))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/getWaveRecordingCatalog", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordCallableInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordCallableInfo](getFullEnergyUrl("/hes-gateway/manage/getWaveRecordingCatalog"), params)
 	// 此处无数据也会失败,暂时定义失败为无数据,不报错
 	if err != nil {
 		zap.L().Error("[queryFaultRecordCallableList] Response failed", zap.String("err", err.Error()))
@@ -104,8 +101,7 @@ func queryCellFaultRecord(devType int, sn string, indexList []int) (*domain.FwhR
 	form.Set("triggerType", strconv.Itoa(2)) // 触发类型,目前仅支持2-自动
 	form.Set("lang", "ZH_CN")
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/callFaultRecording", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[interface{}](url, form)
+	response, err := FwhHttpPostUnmarshal[interface{}](getFullEnergyUrl("/hes-gateway/manage/callFaultRecording"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -134,8 +130,7 @@ func queryFaultRecordCallRecordList() (*domain.FwhResponse[[]domain.FaultRecordI
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/lstFaultWaveCall", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordInfo](getFullEnergyUrl("/hes-gateway/manage/lstFaultWaveCall"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -165,8 +160,7 @@ func queryFaultRecordSubList(id int64) (*domain.FwhResponse[[]domain.FaultRecord
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/lstSubFaultWave", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordSubInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshal[[]domain.FaultRecordSubInfo](getFullEnergyUrl("/hes-gateway/manage/lstSubFaultWave"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -196,8 +190,7 @@ func QueryDownloadFaultRecordData(faultId int64, subRecordId int64) (string, []b
 	params.Add("id", strconv.FormatInt(subRecordId, 10))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprint("https://%s/hes-gateway/manage/exportSubFaultWave", config.FWH_ENERGY)
-	response, err := FwhHttpGet(reqUrl, params)
+	response, err := FwhHttpGet(getFullEnergyUrl("/hes-gateway/manage/exportSubFaultWave"), params)
 	if err != nil {
 		return "", nil, err
 	}

+ 3 - 6
service/repositories/api/fwh_pro/fwh_login.go

@@ -2,8 +2,6 @@ package fwh_pro
 
 import (
 	"encoding/json"
-	"fmt"
-	"fwh-toolbox-server/config"
 )
 
 var loginInfo LoginData = LoginData{
@@ -46,14 +44,13 @@ type JwtPayload struct {
 func Login() error {
 	// 要发送的 JSON
 	payload := LoginPayload{
-		LoginName: config.FWH_USER,
-		Password:  config.FWH_PASSWORD,
+		LoginName: fwhUser,
+		Password:  fwhPassword,
 		Type:      1,
 		Lang:      "ZH_CN",
 	}
 
-	url := fmt.Sprintf("https://%s/api-user/user/login", config.FWH_OSS)
-	response, err := FwhHttpPostWithoutToken(url, payload)
+	response, err := FwhHttpPostWithoutToken(getFullOssUrl("/api-user/user/login"), payload)
 	if err != nil {
 		return err
 	}

+ 1 - 3
service/repositories/api/fwh_pro/fwh_mqttLog.go

@@ -3,7 +3,6 @@ package fwh_pro
 import (
 	"errors"
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -45,8 +44,7 @@ func queryMqttLog(param QueryMqttLogParam) (*domain.FwhPageResponse[[]domain.Mqt
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/iotMqttCommlogs", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]domain.MqttLogItem](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]domain.MqttLogItem](getFullEnergyUrl("/hes-gateway/manage/iotMqttCommlogs"), params)
 	if err != nil {
 		return nil, err
 	}

+ 1 - 3
service/repositories/api/fwh_pro/fwh_powerFlow.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"net/url"
@@ -16,8 +15,7 @@ func querySitePowerFlow(sn string, day time.Time) (*domain.FwhResponseA[dto.Site
 	params.Add("gatewayId", sn)
 	params.Add("dayTime", day.Format(time.DateOnly)) // 会自动 URL 编码
 
-	reqUrl := fmt.Sprintf("https://%s/api-energy/power/getFhpPowerByDayOld", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalA[dto.SitePowerFlow](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalA[dto.SitePowerFlow](getFullEnergyUrl("/api-energy/power/getFhpPowerByDayOld"), params)
 	if err != nil {
 		return nil, err
 	}

+ 3 - 7
service/repositories/api/fwh_pro/fwh_realtimeAalrm.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"net/url"
@@ -16,8 +15,7 @@ func querySiteAlarmList(sn string) (*domain.FwhPageResponse[[]dto.SiteAlarmList]
 	params.Add("gatewayId", sn)
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/getEquipmentAlarm", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]dto.SiteAlarmList](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]dto.SiteAlarmList](getFullEnergyUrl("/hes-gateway/manage/getEquipmentAlarm"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -51,8 +49,7 @@ func queryAlarmStatistics(country domain.CountryType, page int) (*domain.FwhResp
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/api-inspect/currentAlarm/listCurrentAlarm", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalB[domain.AlarmStatisticsData[domain.AlarmStatisticsInfo]](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalB[domain.AlarmStatisticsData[domain.AlarmStatisticsInfo]](getFullEnergyUrl("/api-inspect/currentAlarm/listCurrentAlarm"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -87,8 +84,7 @@ func queryAlarmStatisticsFromCode(country domain.CountryType, alarmCode string,
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/api-inspect/currentAlarm/listDeviceByAlarm", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalB[domain.AlarmStatisticsData[domain.AlarmStatisticsFromCodeInfo]](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalB[domain.AlarmStatisticsData[domain.AlarmStatisticsFromCodeInfo]](getFullEnergyUrl("/api-inspect/currentAlarm/listDeviceByAlarm"), params)
 	if err != nil {
 		return nil, err
 	}

+ 1 - 4
service/repositories/api/fwh_pro/fwh_shutdownList.go

@@ -1,8 +1,6 @@
 package fwh_pro
 
 import (
-	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -31,8 +29,7 @@ func querySiteShutdownListOnePage(date string, country int, page int) (*domain.F
 	params.Add("pageNum", strconv.Itoa(page))
 	params.Add("land", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/api-inspect/fault/listShutdownGateway", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalB[ShutdownListData](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalB[ShutdownListData](getFullEnergyUrl("/api-inspect/fault/listShutdownGateway"), params)
 	if err != nil {
 		return nil, err
 	}

+ 8 - 13
service/repositories/api/fwh_pro/fwh_siteInfo.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/dto"
 	"net/url"
@@ -59,8 +58,7 @@ func querySiteBaseInfoList(sn []string, pageSize uint, pageNum uint) (*domain.Fw
 	form.Set("pageNum", fmt.Sprintf("%d", payload.PageNum))
 	form.Set("lang", payload.Lang)
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareGatewayList", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[[]dto.SiteBaseInfo](url, form)
+	response, err := FwhHttpPostUnmarshal[[]dto.SiteBaseInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotFirmwareGatewayList"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -82,7 +80,7 @@ func QuerySiteBaseInfoList(para dto.SiteBaseInfoPara) (*domain.FwhResponse[[]dto
 }
 
 // 查询站点信息
-func querySiteInfoList(sn *string, pageSize uint, pageNum uint) (*domain.FwhPageResponse[[]dto.SiteInfo], error) {
+func querySiteInfoList(sn *string, pageSize uint, pageNum uint) (*domain.FwhPageResponse[[]domain.FwhSiteInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
 	if sn != nil {
@@ -93,8 +91,7 @@ func querySiteInfoList(sn *string, pageSize uint, pageNum uint) (*domain.FwhPage
 	params.Add("pageNum", fmt.Sprintf("%d", pageNum))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotGatewayList", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]dto.SiteInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]domain.FwhSiteInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotGatewayList"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -102,7 +99,7 @@ func querySiteInfoList(sn *string, pageSize uint, pageNum uint) (*domain.FwhPage
 }
 
 // 从fwh_pro获取站点基本信息表
-func QuerySiteInfoList(para dto.SiteInfoPara) (*domain.FwhPageResponse[[]dto.SiteInfo], error) {
+func QuerySiteInfoList(para dto.SiteInfoPara) (*domain.FwhPageResponse[[]domain.FwhSiteInfo], error) {
 	siteInfoRes, err := querySiteInfoList(para.SN, para.PageSize, para.PageNum)
 	if err != nil {
 		return nil, err
@@ -121,7 +118,7 @@ func querySiteList(
 	searchFlag int,
 	pageSize uint,
 	pageNum uint,
-) (*domain.FwhPageResponse[[]dto.SiteInfo], error) {
+) (*domain.FwhPageResponse[[]domain.FwhSiteInfo], error) {
 	// 构造 query 参数
 	params := url.Values{}
 	params.Add("search", search)
@@ -130,8 +127,7 @@ func querySiteList(
 	params.Add("pageNum", fmt.Sprintf("%d", pageNum))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotGatewayList", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]dto.SiteInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]domain.FwhSiteInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotGatewayList"), params)
 	if err != nil {
 		return nil, err
 	}
@@ -144,7 +140,7 @@ func QuerySiteList(
 	searchFlag int,
 	pageSize uint,
 	pageNum uint,
-) (*domain.FwhPageResponse[[]dto.SiteInfo], error) {
+) (*domain.FwhPageResponse[[]domain.FwhSiteInfo], error) {
 	siteInfoRes, err := querySiteList(search, searchFlag, pageSize, pageNum)
 	if err != nil {
 		return nil, err
@@ -172,8 +168,7 @@ func querySiteModeInfo(sn string) (*domain.FwhResponse[dto.SiteModeInfo], error)
 	form.Set("showType", strconv.Itoa(payload.ShowType))
 	form.Set("lang", payload.Lang)
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/tou/getGatewayTouListV2", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[dto.SiteModeInfo](url, form)
+	response, err := FwhHttpPostUnmarshal[dto.SiteModeInfo](getFullEnergyUrl("/hes-gateway/manage/tou/getGatewayTouListV2"), form)
 	if err != nil {
 		return nil, err
 	}

+ 3 - 7
service/repositories/api/fwh_pro/fwh_update.go

@@ -2,7 +2,6 @@ package fwh_pro
 
 import (
 	"fmt"
-	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/domain"
 	"net/url"
 	"strconv"
@@ -17,8 +16,7 @@ func querySystemUpdateLog(sn string) (*domain.FwhResponse[[]domain.SystemUpdateR
 	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
 	form.Set("pageNum", "1")
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareOrderList", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateRecordInfo](url, form)
+	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateRecordInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotFirmwareOrderList"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -48,8 +46,7 @@ func querySystemUpdateLogDetail(orderId int64) (*domain.FwhResponse[[]domain.Sys
 	form.Set("pageSize", strconv.FormatUint(REQ_PAGE_SIZE, 10))
 	form.Set("pageNum", "1")
 
-	url := fmt.Sprintf("https://%s/hes-gateway/manage/selectIotFirmwareOrderDetailList", config.FWH_ENERGY)
-	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateDetailInfo](url, form)
+	response, err := FwhHttpPostUnmarshal[[]domain.SystemUpdateDetailInfo](getFullEnergyUrl("/hes-gateway/manage/selectIotFirmwareOrderDetailList"), form)
 	if err != nil {
 		return nil, err
 	}
@@ -79,8 +76,7 @@ func queryAHubUpdateLog(sn string) (*domain.FwhPageResponse[[]domain.AHubUpdateR
 	params.Add("pageNum", strconv.Itoa(1))
 	params.Add("lang", "ZH_CN")
 
-	reqUrl := fmt.Sprintf("https://%s/hes-gateway/manage/listAhubFirmwareOrder", config.FWH_ENERGY)
-	response, err := FwhHttpGetUnmarshalPage[[]domain.AHubUpdateRecordInfo](reqUrl, params)
+	response, err := FwhHttpGetUnmarshalPage[[]domain.AHubUpdateRecordInfo](getFullEnergyUrl("/hes-gateway/manage/listAhubFirmwareOrder"), params)
 	if err != nil {
 		return nil, err
 	}

+ 57 - 0
service/repositories/api/smartHub/simCardManage.go

@@ -0,0 +1,57 @@
+package smartHub
+
+import (
+	"encoding/json"
+	"fwh-toolbox-server/domain"
+	"net/url"
+	"strconv"
+
+	"go.uber.org/zap"
+)
+
+type SimStatus string
+
+const (
+	SimStatusTestReady     SimStatus = "TEST_READY"     // 可测试
+	SimStatusActivateReady SimStatus = "ACTIVATE_READY" // 可激活
+	SimStatusActivated     SimStatus = "ACTIVATED"      // 已激活
+	SimStatusDeactivated   SimStatus = "DEACTIVATED"    // 已停用
+	SimStatusInventory     SimStatus = "INVENTORY"      // 库存
+	SimStatusRetired       SimStatus = "RETIRED"        // 失效
+)
+
+// 获取SIM卡详细信息
+func QuerySimCardInfo(giccid string) (*domain.SmartHubSimCardInfo, error) {
+	res, err := smartHttpGet("/global-sims/"+giccid, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	var info domain.SmartHubSimCardInfo
+	if err := json.Unmarshal(res.Body, &info); err != nil {
+		return nil, err
+	}
+
+	return &info, nil
+}
+
+// 批量获取SIM卡详细信息
+func QueryBatchSimCardInfo(page uint, size uint) (*domain.SmartHubSimCardInfoList, error) {
+	// 构造 query 参数
+	params := url.Values{}
+	params.Add("page", strconv.FormatUint(uint64(page), 10))
+	params.Add("size", strconv.FormatUint(uint64(size), 10))
+
+	res, err := smartHttpGet("/global-sims", &params)
+	if err != nil {
+		return nil, err
+	}
+
+	var infoList domain.SmartHubSimCardInfoList
+	if err := json.Unmarshal(res.Body, &infoList); err != nil {
+		zap.L().Error("QueryBatchSimCardInfo Unmarshal failed.", zap.String("body", string(res.Body)), zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	return &infoList, nil
+}

+ 40 - 0
service/repositories/api/smartHub/smartHttpClient.go

@@ -0,0 +1,40 @@
+package smartHub
+
+import (
+	"fwh-toolbox-server/utils/httpClient"
+	"net/http"
+	"net/url"
+	"time"
+)
+
+const (
+	mainUrl                = "https://api.smh.smarthing.com/v1"
+	timeout  time.Duration = 30 * time.Second // SmartHub请求超时30s
+	apiToken               = "UzIyMDExNzA4NTE0NTc6MmYyNmFjY2I4ZmQ1NDFiZmE3ZTZkZWMyZjQ1YTQyMzQ="
+)
+
+var smartHttpClient = httpClient.HttpClient{}
+
+func smartHttpGet(url string, params *url.Values) (*httpClient.Response, error) {
+	t := timeout
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"X-API-Key": {apiToken},
+			"Accept":    {"application/json", "text/plain", "*/*"},
+		},
+	}
+	return smartHttpClient.HttpGet(mainUrl+url, params, &opt)
+}
+
+func smartHttpPost(url string, body any) (*httpClient.Response, error) {
+	t := timeout
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"X-API-Key": {apiToken},
+			"Accept":    {"application/json", "text/plain", "*/*"},
+		},
+	}
+	return smartHttpClient.HttpPost(mainUrl+url, body, &opt)
+}

+ 1 - 1
service/repositories/mongo/xlogRepo.go → service/repositories/events/xlogRepo.go

@@ -1,4 +1,4 @@
-package mongo
+package events
 
 import (
 	"fwh-toolbox-server/domain"

+ 24 - 17
service/repositories/mongo/autoUpdateConfigRepo.go

@@ -14,6 +14,13 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_AutoUpdateConfigOption  = "AutoUpdateConfigOption"
+	CollectionName_AutoUpdateConfig        = "AutoUpdateConfig"
+	CollectionName_AutoUpdateAPowerVersion = "AutoUpdateAPowerVersion"
+	CollectionName_AutoUpdatePackages      = "AutoUpdatePackages"
+)
+
 /**************** 自动升级配置选项 ****************/
 
 // 读取自动升级配置的选项
@@ -21,7 +28,7 @@ func GetAutoUpdateConfigOption() (*domain.AutoUpdateConfigOption, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfigOption")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfigOption)
 
 	update := bson.M{
 		"$setOnInsert": bson.M{
@@ -53,7 +60,7 @@ func insertPackageInfoToOption(packageType string, fileName string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfigOption")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfigOption)
 
 	var opt bson.M
 
@@ -112,7 +119,7 @@ func deletePackageInfoByOption(packageType string, fileName string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfigOption")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfigOption)
 
 	var opt bson.M
 
@@ -178,7 +185,7 @@ func insertAGateVersionToOption(aGateVersion string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfigOption")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfigOption)
 
 	var opt bson.M
 
@@ -229,7 +236,7 @@ func deleteAGateVersionByOption(aGateVersion string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfigOption")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfigOption)
 
 	var opt bson.M
 
@@ -277,7 +284,7 @@ func GetAutoUpdatePackages(fileName string) (*domain.AutoUpdatePackages, error)
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdatePackages")
+	collection := database.GetCollection(CollectionName_AutoUpdatePackages)
 	var cfg domain.AutoUpdatePackages
 	err := collection.FindOne(ctx, bson.M{"_id": fileName}).Decode(&cfg)
 	if err != nil {
@@ -291,7 +298,7 @@ func CreateAutoUpdatePackage(packageType string, info domain.AutoUpdatePackages)
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdatePackages")
+	collection := database.GetCollection(CollectionName_AutoUpdatePackages)
 	// 依赖数据库唯一索引
 	if _, err := collection.InsertOne(ctx, &info); err != nil {
 		if mongo.IsDuplicateKeyError(err) {
@@ -316,7 +323,7 @@ func UpdateAutoUpdatePackage(fileName string, info domain.AutoUpdatePackages) er
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdatePackages")
+	collection := database.GetCollection(CollectionName_AutoUpdatePackages)
 	res, err := collection.UpdateOne(ctx, bson.M{"_id": fileName}, bson.M{"$set": bson.M{
 		"title":      info.Title,
 		"invVersion": info.InvVersion,
@@ -337,7 +344,7 @@ func DeleteAutoUpdatePackage(packageType string, fileName string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdatePackages")
+	collection := database.GetCollection(CollectionName_AutoUpdatePackages)
 
 	// 删除分类表中的文件名
 	if err := deletePackageInfoByOption(packageType, fileName); err != nil {
@@ -357,7 +364,7 @@ func GetAutoUpdateAPowerVersion() (*[]domain.AutoUpdateAPowerVersion, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateAPowerVersion")
+	collection := database.GetCollection(CollectionName_AutoUpdateAPowerVersion)
 
 	cursor, err := collection.Find(ctx, bson.M{})
 	if err != nil {
@@ -381,7 +388,7 @@ func UpdateAutoUpdateAPowerVersion(version string, info domain.AutoUpdateAPowerV
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateAPowerVersion")
+	collection := database.GetCollection(CollectionName_AutoUpdateAPowerVersion)
 
 	filter := bson.M{"_id": version}
 
@@ -409,7 +416,7 @@ func DeleteAutoUpdateAPowerVersion(version string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateAPowerVersion")
+	collection := database.GetCollection(CollectionName_AutoUpdateAPowerVersion)
 
 	_, err := collection.DeleteOne(ctx, bson.M{"_id": version})
 	if err != nil {
@@ -425,7 +432,7 @@ func GetAutoUpdateConfig(aGateVersions string) (*domain.AutoUpdateConfig, error)
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfig")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfig)
 	var cfg domain.AutoUpdateConfig
 	err := collection.FindOne(ctx, bson.M{"_id": aGateVersions}).Decode(&cfg)
 	if err != nil {
@@ -439,7 +446,7 @@ func UpdateAutoUpdateConfig(aGateVersions string, config domain.AutoUpdateConfig
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfig")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfig)
 
 	filter := bson.M{"_id": aGateVersions}
 
@@ -478,7 +485,7 @@ func DeleteAutoUpdateConfig(aGateVersions string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfig")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfig)
 
 	// 删除分类表中的版本
 	if err := deleteAGateVersionByOption(aGateVersions); err != nil {
@@ -497,7 +504,7 @@ func CreateAutoUpdateSpecialConfig(aGateVersions string, config domain.AutoUpdat
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfig")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfig)
 
 	filter := bson.M{"_id": aGateVersions}
 
@@ -528,7 +535,7 @@ func DeleteAutoUpdateSpecialConfig(aGateVersions string, id string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("AutoUpdateConfig")
+	collection := database.GetCollection(CollectionName_AutoUpdateConfig)
 
 	filter := bson.M{"_id": aGateVersions}
 

+ 21 - 17
service/repositories/mongo/batchDownloadRepo.go

@@ -12,12 +12,16 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_BatchDownloadConfig = "BatchDownloadConfig"
+)
+
 // 从数据库获取批量下载数据列表
 func GetBatchDownloadManageList(page, size uint, sn, progress *string) (*dto.ResPage[domain.BatchDownloadInfo], error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	filter := bson.M{}
 	// 条件
@@ -80,7 +84,7 @@ func GetBatchDownloadConfig(id primitive.ObjectID) (*domain.BatchDownloadConfig,
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 	var cfg domain.BatchDownloadConfig
 	err := collection.FindOne(ctx, bson.M{"_id": id}).Decode(&cfg)
 	if err != nil {
@@ -105,7 +109,7 @@ func InsertBatchDownloadConfig(writeConfig domain.BatchDownloadConfigWrite) (pri
 		FailReasons: []string{},
 	}
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	res, err := collection.InsertOne(ctx, config)
 	if err != nil {
@@ -120,7 +124,7 @@ func UpdateBatchDownloadDescription(id primitive.ObjectID, desc string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -146,7 +150,7 @@ func UpdateBatchDownloadOwner(id primitive.ObjectID, owner []string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -172,7 +176,7 @@ func UpdateBatchDownloadResult(id primitive.ObjectID, result domain.Progress, re
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -200,7 +204,7 @@ func DeleteBatchDownloadConfig(id primitive.ObjectID) error {
 	defer cancel()
 
 	filter := bson.M{"_id": id} // 删除条件
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 	_, err := collection.DeleteOne(ctx, filter)
 	if err != nil {
 		return err
@@ -214,7 +218,7 @@ func InsertBatchDownloadData(id primitive.ObjectID, data domain.BatchDownloadDat
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -240,7 +244,7 @@ func GetBatchDownloadData(id primitive.ObjectID) (map[string]domain.BatchDownloa
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 	var cfg domain.BatchDownloadConfig
 	err := collection.FindOne(ctx, bson.M{"_id": id}).Decode(&cfg)
 	if err != nil {
@@ -255,7 +259,7 @@ func InsertBatchDownloadPathPowerFlow(id primitive.ObjectID, sn string, date str
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -281,7 +285,7 @@ func InsertBatchDownloadPathMqttLogs(id primitive.ObjectID, sn string, date stri
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -307,7 +311,7 @@ func InsertBatchDownloadPathEventAlarm(id primitive.ObjectID, sn string, date st
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -333,7 +337,7 @@ func InsertBatchDownloadPathAlarmSnapshot(id primitive.ObjectID, sn string, date
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -359,7 +363,7 @@ func InsertBatchDownloadPathEmsLogs(id primitive.ObjectID, sn string, date strin
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -385,7 +389,7 @@ func InsertBatchDownloadPathFaultRecord(id primitive.ObjectID, sn string, fileNa
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -411,7 +415,7 @@ func InsertBatchDownloadPathUpdateLog(id primitive.ObjectID, sn string, path str
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -437,7 +441,7 @@ func InsertBatchDownloadPathSettings(id primitive.ObjectID, sn string, path stri
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("BatchDownloadConfig")
+	collection := database.GetCollection(CollectionName_BatchDownloadConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配

+ 283 - 0
service/repositories/mongo/siteInfoManageRepo.go

@@ -0,0 +1,283 @@
+package mongo
+
+import (
+	"context"
+	"errors"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const (
+	CollectionName_SiteInfoManageRecord = "SiteInfoManageRecord"
+	CollectionName_SiteInfoManageData   = "SiteInfoManageData"
+)
+
+// 更新站点信息获取结果
+func CreateSiteInfoUpdateInfo() (*primitive.ObjectID, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageRecord)
+
+	result, err := collection.InsertOne(ctx, domain.SiteInfoUpdateRecord{
+		Progress:   domain.ProgressPending,
+		CreateTime: time.Now(),
+		UpdateTime: time.Now(),
+		Total:      0,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	id := result.InsertedID.(primitive.ObjectID)
+	return &id, nil
+}
+
+// 更新站点信息获取结果
+func UpdateSiteInfoUpdateResult(id primitive.ObjectID, progress domain.Progress, total uint64) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageRecord)
+
+	filter := bson.M{"_id": id}
+
+	var update map[string]interface{}
+	if progress == domain.ProgressComplete {
+		update = bson.M{
+			"$set": bson.M{
+				"progress":   progress,
+				"updateTime": time.Now(),
+				"total":      total,
+			},
+		}
+	} else {
+		update = bson.M{
+			"$set": bson.M{
+				"progress": progress,
+			},
+		}
+	}
+
+	_, err := collection.UpdateOne(ctx, filter, update)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// 获取最新站点信息记录数据
+func GetSiteInfoUpdateNewRecord() (*domain.SiteInfoUpdateRecord, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageRecord)
+
+	opts := options.FindOne().SetSort(bson.D{{Key: "_id", Value: -1}})
+
+	var result domain.SiteInfoUpdateRecord
+	err := collection.FindOne(ctx, bson.M{}, opts).Decode(&result)
+	if err != nil {
+		if errors.Is(err, mongo.ErrNoDocuments) {
+			return nil, nil // 没有数据返回空指针
+		}
+		return nil, err
+	}
+	return &result, nil
+}
+
+// 从数据库获取站点信息列表
+func GetSiteInfoManageList(page, size uint, filter *domain.SiteInfoFilter, search string) (*dto.ResPage[domain.SiteInfoDomain], error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageData)
+
+	mainFilter := bson.M{}
+	// 条件
+	if filter != nil {
+		subFilterConfig := bson.M{}
+		mainFilterSet := []bson.M{}
+		for _, subFilter := range filter.SubFilter {
+			subFilterSet := []bson.M{}
+			filterConfigItem := bson.M{}
+			for _, filterItem := range subFilter.Config {
+				switch filterItem.Type {
+				case domain.FilterEqualInt:
+					// 等于指定值(整数)
+					if filterItem.Para1.Int != nil {
+						filterConfigItem[filterItem.Key] = bson.M{"$eq": *filterItem.Para1.Int}
+					}
+				case domain.FilterEqualFloat:
+					// 等于指定值(浮点数)
+					if filterItem.Para1.Float != nil {
+						filterConfigItem[filterItem.Key] = bson.M{"$eq": *filterItem.Para1.Float}
+					}
+				case domain.FilterEqualString:
+					// 等于指定字符串
+					filterConfigItem[filterItem.Key] = bson.M{"$eq": *filterItem.Para1.String}
+				case domain.FilterIncludeString:
+					// 包含指定字符串
+					filterConfigItem[filterItem.Key] = bson.M{"$regex": *filterItem.Para1.String, "$options": "i"} // 选项 $options:大小写不敏感 (i)
+				case domain.FilterRangeInt:
+					// 数值范围检索(整数)
+					if filterItem.Para2 != nil {
+						if filterItem.Para1.Int != nil && filterItem.Para2 != nil && filterItem.Para2.Int != nil {
+							filterConfigItem[filterItem.Key] = bson.M{"$gte": *filterItem.Para1.Int, "$lte": *filterItem.Para2.Int}
+						}
+					}
+				case domain.FilterRangeFloat:
+					// 数值范围检索(浮点数)
+					if filterItem.Para2 != nil {
+						if filterItem.Para1.Float != nil && filterItem.Para2 != nil && filterItem.Para2.Float != nil {
+							filterConfigItem[filterItem.Key] = bson.M{"$gte": *filterItem.Para1.Float, "$lte": *filterItem.Para2.Float}
+						}
+					}
+				case domain.FilterRangeTime:
+					// 数值范围检索(时间)
+					if filterItem.Para1.String != nil && filterItem.Para2 != nil && filterItem.Para2.String != nil {
+						// start, e1 := time.Parse(time.DateTime, *filterItem.Para1.String)
+						// end, e2 := time.Parse(time.DateTime, *filterItem.Para2.String)
+						// if e1 == nil && e2 == nil {
+						// 	filterConfigItem[filterItem.Key] = bson.M{"$gte": start, "$lte": end}
+						// }
+						start := *filterItem.Para1.String
+						end := *filterItem.Para2.String
+						filterConfigItem[filterItem.Key] = bson.M{"$gte": start, "$lte": end}
+					}
+				case domain.FilterExists:
+					// 判断字段是否存在或不为空
+					// filterConfigItem[filterItem.Key] = bson.M{"$exists": true}
+					value := *filterItem.Para1.String
+					switch value {
+					case "yes":
+						filterConfigItem[filterItem.Key] = bson.M{"$exists": true, "$ne": nil}
+					case "no":
+						filterConfigItem["$or"] = bson.M{
+							filterItem.Key: bson.M{"$exists": false},
+							filterItem.Key: bson.M{"$eq": nil},
+						}
+					}
+
+				}
+
+				subFilterSet = append(subFilterSet, filterConfigItem)
+			}
+
+			if len(subFilterSet) > 0 {
+				switch subFilter.Logic {
+				case "and":
+					subFilterConfig["$and"] = subFilterSet
+				case "or":
+					subFilterConfig["$or"] = subFilterSet
+				}
+			}
+
+			mainFilterSet = append(mainFilterSet, subFilterConfig)
+		}
+		if len(mainFilterSet) > 0 {
+			switch filter.Logic {
+			case "and":
+				mainFilter["$and"] = mainFilterSet
+			case "or":
+				mainFilter["$or"] = mainFilterSet
+			}
+
+		}
+	}
+
+	dbFilter := bson.M{}
+	if len(search) > 0 {
+		dbFilter["$and"] = []bson.M{
+			mainFilter,
+			{
+				"$or": []bson.M{
+					{"_id": bson.M{"$regex": search, "$options": "i"}}, // 选项 $options:大小写不敏感 (i)
+					{"name": bson.M{"$regex": search, "$options": "i"}},
+					{"distributorName": bson.M{"$regex": search, "$options": "i"}},
+					{"installerName": bson.M{"$regex": search, "$options": "i"}},
+					{"maintName": bson.M{"$regex": search, "$options": "i"}},
+					{"accountName": bson.M{"$regex": search, "$options": "i"}},
+					{"iccId": bson.M{"$regex": search, "$options": "i"}},
+					{"imei": bson.M{"$regex": search, "$options": "i"}},
+					{"fhpSn": bson.M{"$regex": search, "$options": "i"}},
+					{"version": bson.M{"$regex": search, "$options": "i"}},
+					{"protocolVer": bson.M{"$regex": search, "$options": "i"}},
+					{"realSysHdVersion": bson.M{"$regex": search, "$options": "i"}},
+					{"financierName": bson.M{"$regex": search, "$options": "i"}},
+					{"remark": bson.M{"$regex": search, "$options": "i"}},
+					{"country": bson.M{"$regex": search, "$options": "i"}},
+					{"province": bson.M{"$regex": search, "$options": "i"}},
+					{"city": bson.M{"$regex": search, "$options": "i"}},
+					{"site": bson.M{"$regex": search, "$options": "i"}},
+					{"jobCode": bson.M{"$regex": search, "$options": "i"}},
+				},
+			},
+		}
+	} else {
+		dbFilter = mainFilter
+	}
+
+	var findOptions *options.FindOptions
+	if size > 0 {
+		findOptions = options.Find().
+			SetSkip(int64((page - 1) * size)).
+			SetLimit(int64(size))
+	}
+
+	cursor, err := collection.Find(ctx, dbFilter, findOptions)
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+
+	var infoList []domain.SiteInfoDomain
+	for cursor.Next(ctx) {
+		var info domain.SiteInfoDomain
+		if err := cursor.Decode(&info); err != nil {
+			return nil, err
+		}
+		infoList = append(infoList, info)
+	}
+
+	// 查询总数
+	total, err := collection.CountDocuments(ctx, dbFilter)
+	if err != nil {
+		return nil, err
+	}
+
+	res := dto.ResPage[domain.SiteInfoDomain]{
+		Data:     infoList,
+		PageSize: size,
+		PageNum:  page,
+		Total:    uint(total),
+	}
+
+	return &res, nil
+}
+
+// 插入站点信息数据
+func InsertSiteInfoData(data domain.SiteInfoDomain) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_SiteInfoManageData)
+
+	filter := bson.M{"_id": data.Sn} // 按ID匹配
+
+	opts := options.Replace().SetUpsert(true)
+
+	_, err := collection.ReplaceOne(ctx, filter, data, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 15 - 10
service/repositories/mongo/sitesAnalysisRepo.go

@@ -12,12 +12,16 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_SiteAnalysisConfig = "SiteAnalysisConfig"
+)
+
 // 从数据库获取站点分析列表
 func GetSiteAnalysisManageList(page, size uint, sn *string, timePeriod *[2]string, progress *string, trigger *domain.Trigger) (*dto.ResPage[domain.SiteAnalysisInfo], error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	filter := bson.M{}
 	// 条件
@@ -38,6 +42,7 @@ func GetSiteAnalysisManageList(page, size uint, sn *string, timePeriod *[2]strin
 	}
 
 	findOptions := options.Find().
+		SetSort(bson.D{{Key: "_id", Value: -1}}).
 		SetProjection(bson.M{
 			"_id":         1,
 			"sn":          1,
@@ -86,7 +91,7 @@ func GetSiteAnalysisConfig(id primitive.ObjectID) (*domain.SiteAnalysisConfig, e
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 	var cfg domain.SiteAnalysisConfig
 	err := collection.FindOne(ctx, bson.M{"_id": id}).Decode(&cfg)
 	if err != nil {
@@ -126,7 +131,7 @@ func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite, auto b
 		config.Item = append(config.Item, "运行时")
 	}
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	res, err := collection.InsertOne(ctx, config)
 	if err != nil {
@@ -158,7 +163,7 @@ func UpdateSiteAnalysisConfig(id primitive.ObjectID, writeConfig domain.SiteAnal
 		config.Item = append(config.Item, "运行时")
 	}
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -182,7 +187,7 @@ func UpdateSiteAnalysisDescription(id primitive.ObjectID, desc string) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -208,7 +213,7 @@ func UpdateSiteAnalysisResult(id primitive.ObjectID, result domain.Progress) err
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -235,7 +240,7 @@ func DeleteSiteAnalysisConfig(id primitive.ObjectID) error {
 	defer cancel()
 
 	filter := bson.M{"_id": id} // 删除条件
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 	_, err := collection.DeleteOne(ctx, filter)
 	if err != nil {
 		return err
@@ -249,7 +254,7 @@ func InsertSiteAnalysisData(id primitive.ObjectID, data domain.SiteAnalysisData)
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -275,7 +280,7 @@ func InsertSiteAnalysisTimeline(id primitive.ObjectID, timeline []domain.Timelin
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配
@@ -301,7 +306,7 @@ func InsertSiteAnalysisRuntime(id primitive.ObjectID, runtime []domain.RuntimeIt
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteAnalysisConfig")
+	collection := database.GetCollection(CollectionName_SiteAnalysisConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"_id": id} // 按ID匹配

+ 11 - 7
service/repositories/mongo/sitesMonitorRepo.go

@@ -11,12 +11,16 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_SiteMonitorConfig = "SiteMonitorConfig"
+)
+
 // 从数据库获取站点监控配置列表
 func GetSiteMonitorConfig() ([]domain.SitesMonitorConfig, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 	cursor, err := collection.Find(ctx, bson.M{}) // 空条件,查全部
 	if err != nil {
 		return nil, err
@@ -40,7 +44,7 @@ func GetSiteMonitorNameList() ([]string, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 	cursor, err := collection.Find(
 		ctx,
 		bson.M{}, // 空条件,查全部
@@ -73,7 +77,7 @@ func GetOneSiteMonitorConfig(name string) ([]domain.SitesMonitorConfig, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 	var cfg domain.SitesMonitorConfig
 	err := collection.FindOne(ctx, bson.M{"name": name}).Decode(&cfg)
 	if err == mongo.ErrNoDocuments {
@@ -93,7 +97,7 @@ func InsertSiteMonitorConfig(config domain.SitesMonitorConfig) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"name": config.Name} // 按用户名匹配
@@ -116,7 +120,7 @@ func UpdateSiteMonitorConfig(name string, config domain.SitesMonitorConfig) erro
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"name": name} // 按用户名匹配
@@ -140,7 +144,7 @@ func DeleteSiteMonitorConfig(name string) error {
 	defer cancel()
 
 	filter := bson.M{"name": name} // 删除条件
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 	_, err := collection.DeleteOne(ctx, filter)
 	if err != nil {
 		return err
@@ -154,7 +158,7 @@ func UpdateSiteMonitorPageConfig(name string, config domain.MonitorPageConfig) e
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("SiteMonitorConfig")
+	collection := database.GetCollection(CollectionName_SiteMonitorConfig)
 
 	// _, err := collection.InsertOne(ctx, config)
 	filter := bson.M{"name": name} // 按用户名匹配

+ 8 - 4
service/repositories/mongo/statisticsAlarmRepo.go

@@ -11,12 +11,16 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_StatisticsAlarm = "StatisticsAlarm"
+)
+
 // 从数据库获取告警统计数据
 func GetStatisticsAlarm(date string) (*domain.AlarmStatisticsItem, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsAlarm")
+	collection := database.GetCollection(CollectionName_StatisticsAlarm)
 
 	filter := bson.M{}
 	// 条件
@@ -38,7 +42,7 @@ func GetLatestStatisticsAlarm() (*domain.AlarmStatisticsItem, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsAlarm")
+	collection := database.GetCollection(CollectionName_StatisticsAlarm)
 
 	// 按 date 字段倒序排序(最新日期在最前)
 	opts := options.FindOne().SetSort(bson.D{{Key: "date", Value: -1}})
@@ -56,7 +60,7 @@ func GetStatisticsAlarmByDateRange(startDate, endDate string) ([]domain.AlarmSta
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsAlarm")
+	collection := database.GetCollection(CollectionName_StatisticsAlarm)
 
 	var results []domain.AlarmStatisticsItem
 
@@ -87,7 +91,7 @@ func InsertStatisticsAlarm(data domain.AlarmStatisticsItem) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsAlarm")
+	collection := database.GetCollection(CollectionName_StatisticsAlarm)
 
 	// 创建日期字段唯一索引
 	collection.Indexes().CreateOne(ctx, mongo.IndexModel{

+ 8 - 4
service/repositories/mongo/statisticsShutdownRepo.go

@@ -11,12 +11,16 @@ import (
 	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
+const (
+	CollectionName_StatisticsShutdown = "StatisticsShutdown"
+)
+
 // 从数据库获取Shutdown统计数据
 func GetStatisticsShutdown(date string) (*domain.ShutdownStatisticsItem, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsShutdown")
+	collection := database.GetCollection(CollectionName_StatisticsShutdown)
 
 	filter := bson.M{}
 	// 条件
@@ -38,7 +42,7 @@ func GetLatestStatisticsShutdown() (*domain.ShutdownStatisticsItem, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsShutdown")
+	collection := database.GetCollection(CollectionName_StatisticsShutdown)
 
 	// 按 date 字段倒序排序(最新日期在最前)
 	opts := options.FindOne().SetSort(bson.D{{Key: "date", Value: -1}})
@@ -56,7 +60,7 @@ func GetStatisticsShutdownByDateRange(startDate, endDate string) ([]domain.Shutd
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsShutdown")
+	collection := database.GetCollection(CollectionName_StatisticsShutdown)
 
 	var results []domain.ShutdownStatisticsItem
 
@@ -87,7 +91,7 @@ func InsertStatisticsShutdown(data domain.ShutdownStatisticsItem) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
-	collection := database.GetCollection("StatisticsShutdown")
+	collection := database.GetCollection(CollectionName_StatisticsShutdown)
 
 	// 创建日期字段唯一索引
 	collection.Indexes().CreateOne(ctx, mongo.IndexModel{

+ 205 - 0
service/repositories/mongo/userInfoRepo.go

@@ -0,0 +1,205 @@
+package mongo
+
+import (
+	"context"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"time"
+
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
+)
+
+const (
+	CollectionName_UserInfo = "UserInfo"
+)
+
+// 创建新数据的结构体配置(文档不存在,插入新文档时使用)
+func createUserInfoNewStruct(name string) bson.M {
+	return bson.M{
+		"_id":    name,
+		"info":   domain.UserBaseInfoDomain{},
+		"config": domain.UserConfigDomain{},
+	}
+}
+
+// 读取指定用户的完整配置
+func UserInfoReadAllConfig(name string) (*domain.UserConfigDomain, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 只取 config 字段
+	projection := bson.M{"config": 1}
+
+	var config domain.UserConfigDomain
+	err := collection.FindOne(ctx, filter, options.FindOne().SetProjection(projection)).Decode(&config)
+	if err != nil {
+		if err == mongo.ErrNoDocuments {
+			// 未找到数据
+			return &domain.UserConfigDomain{}, nil
+		} else {
+			return nil, err
+		}
+	}
+
+	return &config, nil
+}
+
+// 覆盖更新指定用户的完整配置
+func UserInfoUpdateAllConfig(name string, config domain.UserConfigDomain) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 覆盖字段(无论文档是否存在都会更新)
+	updateFields := bson.M{
+		"config": config,
+	}
+
+	setOnInsertFields := createUserInfoNewStruct(name)
+	delete(setOnInsertFields, "config")
+
+	update := bson.M{
+		"$set":         updateFields,
+		"$setOnInsert": setOnInsertFields,
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 读取指定用户的SiteInfoManage配置
+func UserInfoReadSiteInfoManageConfig(name string) (*domain.SiteInfoManageConfig, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 只取 config 字段
+	projection := bson.M{"config.siteInfoManage": 1}
+
+	var config domain.UserInfoDomain
+	err := collection.FindOne(ctx, filter, options.FindOne().SetProjection(projection)).Decode(&config)
+	if err != nil {
+		if err == mongo.ErrNoDocuments {
+			// 未找到数据
+			return nil, nil
+		} else {
+			return nil, err
+		}
+	}
+
+	return config.Config.SiteInfoManage, nil
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件
+func UserInfoUpdateSiteInfoManageConfig(name string, config domain.SiteInfoManageConfig) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 覆盖字段(无论文档是否存在都会更新)
+	updateFields := bson.M{
+		"config.siteInfoManage": config,
+	}
+
+	setOnInsertFields := createUserInfoNewStruct(name)
+	delete(setOnInsertFields, "config")
+
+	update := bson.M{
+		"$set":         updateFields,
+		"$setOnInsert": setOnInsertFields,
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件中的TitleFilter
+func UserInfoUpdateSiteInfoManageConfigTitleFilter(name string, config []string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 覆盖字段(无论文档是否存在都会更新)
+	updateFields := bson.M{
+		"config.siteInfoManage.titleFilter": config,
+	}
+
+	setOnInsertFields := createUserInfoNewStruct(name)
+	delete(setOnInsertFields, "config")
+
+	update := bson.M{
+		"$set":         updateFields,
+		"$setOnInsert": setOnInsertFields,
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件中的CustomQueryFilter
+func UserInfoUpdateSiteInfoManageConfigCustomQueryFilter(name string, config []domain.SiteInfoQueryFilter) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection(CollectionName_UserInfo)
+
+	filter := bson.M{"_id": name}
+
+	// 覆盖字段(无论文档是否存在都会更新)
+	updateFields := bson.M{
+		"config.siteInfoManage.customQueryFilter": config,
+	}
+
+	setOnInsertFields := createUserInfoNewStruct(name)
+	delete(setOnInsertFields, "config")
+
+	update := bson.M{
+		"$set":         updateFields,
+		"$setOnInsert": setOnInsertFields,
+	}
+
+	opts := options.Update().SetUpsert(true)
+
+	_, err := collection.UpdateOne(ctx, filter, update, opts)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 262 - 0
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_client.go

@@ -0,0 +1,262 @@
+package fwh_mqtt
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/json"
+	"fmt"
+	"fwh-toolbox-server/config"
+	"fwh-toolbox-server/utils/mqttClient"
+	"os"
+	"strconv"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+const (
+	fwhClientIdPrefix  = "fwh_xlog_mqtt_client_"
+	sleepTimeout       = 5 * time.Minute
+	sendMessageTimeout = 1 * time.Minute
+)
+
+type FwhMqttResult struct {
+	Err  error
+	Data *FwhMqttMsg
+}
+
+type FwhMqttMsg struct {
+	CmdType   uint        `json:"cmdType"`   // 业务类型标识
+	Crc       string      `json:"crc"`       // dataArea 数据字符串的CRC
+	DataArea  interface{} `json:"dataArea"`  // 数据域
+	EquipNo   string      `json:"equipNo"`   // 设备编号 SN
+	Len       int         `json:"len"`       // dataArea 数据长度
+	Snno      uint        `json:"snno"`      // 序号
+	TimeStamp int64       `json:"timeStamp"` // 时间戳
+	Type      uint        `json:"type"`      // 设备类型 0:设备网关IBG
+}
+
+type MqttMsg struct {
+	topic    string // 发送 Topic
+	msg      FwhMqttMsg
+	subTopic string // 响应 Topic
+	resultCh chan FwhMqttResult
+	ctx      context.Context
+}
+
+type ResultChan struct {
+	ch  chan FwhMqttMsg
+	ctx context.Context
+}
+
+var (
+	taskCh = make(chan MqttMsg, 1000)
+	// resultChListMap = map[string][]*ResultChan{}
+)
+
+var fwhMqttClient *mqttClient.MqttClient
+
+func InitFwhMqttClient(cfg *config.FwhMqttConfig) error {
+	// 证书配置
+	caCert, err := os.ReadFile(cfg.FwhMqttCaUrl)
+	if err != nil {
+		panic(err)
+	}
+
+	caCertPool := x509.NewCertPool()
+	caCertPool.AppendCertsFromPEM(caCert)
+
+	cert, err := tls.LoadX509KeyPair(cfg.FwhMqttCertUrl, cfg.FwhMqttKeyUrl)
+	if err != nil {
+		panic(err)
+	}
+
+	client, err := mqttClient.CreateMqttClient(mqttClient.MqttClientOpt{
+		Broker:   cfg.FwhMqttBroker,
+		ClientID: fwhClientIdPrefix + strconv.FormatInt(time.Now().Unix(), 10),
+		TLS: &tls.Config{
+			Certificates: []tls.Certificate{cert},
+			RootCAs:      caCertPool,
+		},
+	})
+	if err != nil {
+		zap.L().Error("Init FWH MQTT client failed.")
+		return err
+	}
+	fwhMqttClient = client
+
+	zap.L().Info("Init FWH MQTT client success.")
+
+	go fwhMqttWorker(fwhMqttClient)
+
+	return nil
+}
+
+func fwhMqttWorker(client *mqttClient.MqttClient) {
+	for {
+		select {
+		case task, ok := <-taskCh:
+			if !ok {
+				zap.L().Error("Channel closed, worker exiting, recreate.")
+				taskCh = make(chan MqttMsg, 1000)
+				break
+			}
+
+			if err := client.Connect(); err != nil {
+				zap.L().Error("FWH MQTT connect failed.", zap.String("msg", fmt.Sprintf("%+v", task)))
+				task.resultCh <- FwhMqttResult{
+					Err:  fmt.Errorf("FWH MQTT connect failed."),
+					Data: nil,
+				}
+				break
+			}
+
+			// 订阅响应消息 - 请求响应无法一一对应,不支持并发请求,改成简单的队列请求
+			// if _, ok := resultChListMap[task.subTopic]; !ok {
+			// 	resultChListMap[task.subTopic] = []*ResultChan{}
+			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
+			// 	if err := client.SubscribeMessage(task.subTopic, func(payload []byte) {
+			// 		var newList []*ResultChan
+			// 		for _, ch := range resultChListMap[task.subTopic] {
+			// 			if err := ch.ctx.Err(); err != nil {
+			// 				switch err {
+			// 				case context.DeadlineExceeded:
+			// 					zap.L().Warn("Waiting for FWH MQTT message timeout.", zap.String("topic", task.topic))
+			// 				case context.Canceled:
+			// 					zap.L().Warn("Waiting for FWH MQTT message cancel.", zap.String("topic", task.topic))
+			// 				}
+			// 			} else {
+			// 				var fwhMqttMsg FwhMqttMsg
+			// 				err := json.Unmarshal(payload, &fwhMqttMsg)
+			// 				if err != nil {
+			// 					zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
+			// 					newList = append(newList, ch)
+			// 					continue
+			// 				}
+
+			// 				if task.msg.Snno != fwhMqttMsg.Snno {
+			// 					newList = append(newList, ch)
+			// 					continue
+			// 				}
+
+			// 				ch.ch <- fwhMqttMsg
+			// 			}
+			// 		}
+			// 		resultChListMap[task.subTopic] = newList
+			// 	}); err != nil {
+			// 		zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)))
+			// 		break
+			// 	}
+			// } else {
+			// 	resultChListMap[task.subTopic] = append(resultChListMap[task.subTopic], &ResultChan{ch: task.resultCh, ctx: task.ctx})
+			// }
+
+			resultCh := make(chan FwhMqttResult)
+
+			if err := client.SubscribeMessage(task.subTopic, func(payload []byte) {
+				var fwhMqttMsg FwhMqttMsg
+				if err := json.Unmarshal(payload, &fwhMqttMsg); err != nil {
+					zap.L().Error("Result Unmarshal failed.(FwhMqttMsg)", zap.String("topic", task.topic), zap.String("payload", string(payload)), zap.String("err", err.Error()))
+					resultCh <- FwhMqttResult{
+						Err:  err,
+						Data: nil,
+					}
+					return
+				}
+
+				resultCh <- FwhMqttResult{
+					Err:  nil,
+					Data: &fwhMqttMsg,
+				}
+			}); err != nil {
+				zap.L().Error("FWH MQTT subscribe message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
+				task.resultCh <- FwhMqttResult{
+					Err:  err,
+					Data: nil,
+				}
+				break
+			}
+
+			msgByte, err := json.Marshal(task.msg)
+			if err != nil {
+				zap.L().Error("FWH MQTT message marshal failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
+				task.resultCh <- FwhMqttResult{
+					Err:  err,
+					Data: nil,
+				}
+				break
+			}
+
+			if err := client.SendMessageOnce(task.topic, msgByte); err != nil {
+				zap.L().Error("FWH MQTT send message failed.", zap.String("msg", fmt.Sprintf("%+v", task)), zap.String("err", err.Error()))
+				task.resultCh <- FwhMqttResult{
+					Err:  err,
+					Data: nil,
+				}
+				break
+			}
+
+			select {
+			case res, ok := <-resultCh:
+				if !ok {
+					task.resultCh <- FwhMqttResult{
+						Err:  fmt.Errorf("FWH MQTT received message channel closed."),
+						Data: nil,
+					}
+					break
+				}
+
+				task.resultCh <- res
+			case <-task.ctx.Done():
+				task.resultCh <- FwhMqttResult{
+					Err:  fmt.Errorf("FWH MQTT received message timeout."),
+					Data: nil,
+				}
+			}
+
+		case <-time.After(sleepTimeout):
+			if client.IsConnected() {
+				zap.L().Info("FWH MQTT no task received within timeout, disconnect.")
+				client.Disconnect()
+			}
+		}
+	}
+}
+
+func sendMessage(ctx context.Context, topic string, msg FwhMqttMsg, subTopic string) (*FwhMqttMsg, error) {
+	if fwhMqttClient == nil {
+		return nil, fmt.Errorf("Fwh Mqtt Client Uninitialized.")
+	}
+
+	ctxSend, cancel := context.WithTimeout(ctx, sendMessageTimeout)
+	defer cancel()
+
+	taskMsg := MqttMsg{
+		topic:    topic,
+		msg:      msg,
+		subTopic: subTopic,
+		resultCh: make(chan FwhMqttResult, 1),
+		ctx:      ctxSend,
+	}
+	taskCh <- taskMsg
+
+	for {
+		select {
+		case result, ok := <-taskMsg.resultCh:
+			if !ok {
+				zap.L().Error("Result channel closed, worker exiting, recreate.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
+				return nil, fmt.Errorf("Result channel closed, worker exiting, recreate.")
+			}
+
+			if result.Err != nil {
+				return nil, result.Err
+			}
+
+			return result.Data, nil
+		case <-ctxSend.Done():
+			zap.L().Info("Result received timeout.", zap.String("topic", topic), zap.String("msg", fmt.Sprintf("%+v", msg)))
+			return nil, fmt.Errorf("Result received timeout.")
+		}
+	}
+}

+ 43 - 0
service/repositories/mqtt/fwh_mqtt/fwh_mqtt_debug.go

@@ -0,0 +1,43 @@
+// debug 设备调试数据
+package fwh_mqtt
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"hash/crc32"
+	"strings"
+	"time"
+)
+
+func EnableXlog(ctx context.Context, sn string) (string, error) {
+	dataArea := map[string]string{
+		"xlogaddr": "18.188.130.67:9081",
+	}
+
+	dataStr, err := json.Marshal(dataArea)
+	if err != nil {
+		return "", err
+	}
+
+	res, err := sendMessage(ctx, "fwh/s2c/debug/"+sn, FwhMqttMsg{
+		CmdType:   2000, // xlog请求
+		Type:      0,    // IBG
+		Snno:      123,
+		TimeStamp: time.Now().Unix(),
+		EquipNo:   sn,
+		DataArea:  dataArea,
+		Len:       len(dataStr),
+		Crc:       strings.ToUpper(fmt.Sprintf("%08X", crc32.ChecksumIEEE(dataStr))),
+	}, "fwh/c2s/debug/"+sn)
+	if err != nil {
+		return "", err
+	}
+
+	b, err := json.Marshal(res.DataArea)
+	if err != nil {
+		return "", err
+	}
+
+	return string(b), nil
+}

+ 30 - 4
service/repositories/repositories.go

@@ -2,7 +2,10 @@ package repositories
 
 import (
 	"fwh-toolbox-server/repositories/api/fwh_pro"
+	"fwh-toolbox-server/repositories/api/smartHub"
+	"fwh-toolbox-server/repositories/events"
 	"fwh-toolbox-server/repositories/mongo"
+	"fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
 	pgsql_alarmSnapshot "fwh-toolbox-server/repositories/pgsql/alarmSnapshot"
 	pgsql_original "fwh-toolbox-server/repositories/pgsql/original"
 	"fwh-toolbox-server/repositories/redis"
@@ -11,7 +14,6 @@ import (
 // 聚合包
 
 /*************** API ***************/
-
 // fwh_pro
 type EventAlarmType = fwh_pro.EventAlarmType
 
@@ -58,10 +60,22 @@ var QuerySystemUpdateLog = fwh_pro.QuerySystemUpdateLog             // 查询系
 var QuerySystemUpdateLogDetail = fwh_pro.QuerySystemUpdateLogDetail // 查询系统升级记录详细信息
 var QueryAHubUpdateLog = fwh_pro.QueryAHubUpdateLog                 // 查询aHub升级记录
 
-/*************** MONGO ***************/
+// SmartHub
+var QuerySmartHubBatchSimCardInfo = smartHub.QueryBatchSimCardInfo // 批量查询SIM卡信息
+
+/*************** EVENTS ***************/
 // Xlog
-var GetXlogManageList = mongo.GetXlogManageList
-var SendXlogCmd = mongo.SendXlogCmd
+var GetXlogManageList = events.GetXlogManageList
+var SendXlogCmd = events.SendXlogCmd
+
+/*************** MONGO ***************/
+// UserInfo
+var UserInfoReadAllConfig = mongo.UserInfoReadAllConfig
+var UserInfoUpdateAllConfig = mongo.UserInfoUpdateAllConfig
+var UserInfoReadSiteInfoManageConfig = mongo.UserInfoReadSiteInfoManageConfig
+var UserInfoUpdateSiteInfoManageConfig = mongo.UserInfoUpdateSiteInfoManageConfig
+var UserInfoUpdateSiteInfoManageConfigTitleFilter = mongo.UserInfoUpdateSiteInfoManageConfigTitleFilter
+var UserInfoUpdateSiteInfoManageConfigCustomQueryFilter = mongo.UserInfoUpdateSiteInfoManageConfigCustomQueryFilter
 
 // SiteMonitor
 var GetSiteMonitorConfig = mongo.GetSiteMonitorConfig
@@ -130,6 +144,13 @@ var DeleteAutoUpdateConfig = mongo.DeleteAutoUpdateConfig
 var CreateAutoUpdateSpecialConfig = mongo.CreateAutoUpdateSpecialConfig
 var DeleteAutoUpdateSpecialConfig = mongo.DeleteAutoUpdateSpecialConfig
 
+// SiteInfoManage
+var CreateSiteInfoUpdateInfo = mongo.CreateSiteInfoUpdateInfo
+var UpdateSiteInfoUpdateResult = mongo.UpdateSiteInfoUpdateResult
+var GetSiteInfoUpdateNewRecord = mongo.GetSiteInfoUpdateNewRecord
+var GetSiteInfoManageList = mongo.GetSiteInfoManageList
+var InsertSiteInfoData = mongo.InsertSiteInfoData
+
 /*************** PGSQL ***************/
 
 // Alarm Snapshot
@@ -170,3 +191,8 @@ var GetEmsparaPidInfo = redis.GetEmsparaPidInfo
 var RefreshAutoUpdateConfigFileInfo = redis.RefreshAutoUpdateConfigFileInfo
 var GetAutoUpdateConfigFileInfoTotal = redis.GetAutoUpdateConfigFileInfoTotal
 var GetAutoUpdateConfigFileInfoByPage = redis.GetAutoUpdateConfigFileInfoByPage
+
+/*************** MQTT ***************/
+// FwhMqtt
+var FwhMqttInit = fwh_mqtt.InitFwhMqttClient
+var FwhMqttEnableXlog = fwh_mqtt.EnableXlog

+ 0 - 0
service/routes/AlarmManageRoutes.go → service/routes/alarmManageRoutes.go


+ 2 - 0
service/routes/routes.go

@@ -20,6 +20,7 @@ func Setup(app *fiber.App) {
 	// api.Get("/keyAccounts/sunrun/state", controllers.GetUsers)
 	// api.Get("/keyAccounts/sunrun/abnormalList", controllers.GetUsers)
 	// …
+	UserInfoSetup(api)
 	ShutdownStatisticsSetup(api)
 	ShutdownManageSetup(api)
 	SitesMonitorSetup(api)
@@ -28,6 +29,7 @@ func Setup(app *fiber.App) {
 	BackgroundTaskSetup(api)
 	AutoUpdateConfigSetup(api)
 	XlogSetup(api)
+	SiteInfoManageSetup(api)
 
 	app.Get("/swagger/*", swagger.HandlerDefault)
 

+ 35 - 0
service/routes/siteInfoManageRoutes.go

@@ -0,0 +1,35 @@
+/*
+ * Site Info Manage API List
+ * /siteInfoManage/manage/update - GET & POST 获取更新状态 & 请求更新站点信息表
+ * /siteInfoManage/dataList - POST 站点信息表
+ * /siteInfoManage/dataListExport - POST 导出站点信息表
+ * /siteInfoManage/siteInfo/query - GET 获取单个站点实时信息(同时更新单个站点数据)
+ * /siteInfoManage/operate/enableXlog - POST 使能站点xlog
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func SiteInfoManageSetup(api fiber.Router) {
+	// 获取更新状态
+	api.Get("/siteInfoManage/manage/update", controllers.GetSiteInfoUpdateStatus)
+
+	// 请求更新站点信息表
+	api.Post("/siteInfoManage/manage/update", controllers.UpdateSiteInfoDate)
+
+	// 获取站点信息表
+	api.Post("/siteInfoManage/dataList", controllers.QuerySiteInfoDataList)
+
+	// 导出站点信息表
+	api.Post("/siteInfoManage/dataListExport", controllers.ExportSiteInfoDataList)
+
+	// 查询单个站点实时信息(同时更新单个站点数据)
+	api.Get("/siteInfoManage/siteInfo/query", controllers.GetSiteInfoData)
+
+	// 使能站点xlog
+	api.Post("/siteInfoManage/operate/enableXlog", controllers.OperateSiteInfoEnableXlog)
+}

+ 34 - 0
service/routes/userInfoRoutes.go

@@ -0,0 +1,34 @@
+/*
+ * User Info API List
+ * /userInfo/config - GET & POST 获取&更新用户的完整配置
+ * /userInfo/config/siteInfoManage - GET & POST 获取&更新用户的SiteInfoManage配置
+ * /userInfo/config/siteInfoManage/titleFilter - POST 获取&更新用户的SiteInfoManage - TitleFilter配置
+ * /userInfo/config/siteInfoManage/customQueryFilter - POST 获取&更新用户的SiteInfoManage - CustomQueryFilter配置
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func UserInfoSetup(api fiber.Router) {
+	// 获取用户的完整配置
+	api.Get("/userInfo/config", controllers.GetUserInfoAllConfig)
+
+	// 更新用户的完整配置
+	api.Post("/userInfo/config", controllers.UpdateUserInfoAllConfig)
+
+	// 获取用户的SiteInfoManage配置
+	api.Get("/userInfo/config/siteInfoManage", controllers.GetUserInfoSiteInfoManageConfig)
+
+	// 更新用户的SiteInfoManage配置
+	api.Post("/userInfo/config/siteInfoManage", controllers.UpdateUserInfoSiteInfoManageConfig)
+
+	// 更新用户的SiteInfoManage - TitleFilter配置
+	api.Post("/userInfo/config/siteInfoManage/titleFilter", controllers.UpdateUserInfoSiteInfoManageConfigTitleFilter)
+
+	// 更新用户的SiteInfoManage - CustomQueryFilter配置
+	api.Post("/userInfo/config/siteInfoManage/customQueryFilter", controllers.UpdateUserInfoSiteInfoManageConfigCustomQueryFilter)
+}

+ 181 - 0
service/services/siteInfoManageServices.go

@@ -0,0 +1,181 @@
+package services
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/repositories"
+	"fwh-toolbox-server/taskQueue"
+	"fwh-toolbox-server/utils/global"
+	"strconv"
+	"time"
+
+	"github.com/xuri/excelize/v2"
+	"go.uber.org/zap"
+)
+
+// 获取更新状态
+func GetSiteInfoUpdateStatus() (*domain.SiteInfoUpdateRecord, error) {
+	return repositories.GetSiteInfoUpdateNewRecord()
+}
+
+// 请求更新站点信息表
+func UpdateSiteInfoDate() error {
+	id, err := repositories.CreateSiteInfoUpdateInfo()
+	if err != nil {
+		return err
+	}
+
+	if err := taskQueue.EnqueueSiteInfoUpdateTask(domain.TaskSiteInfoUpdate{
+		Id: *id,
+	}); err != nil {
+		return err
+	}
+	return nil
+}
+
+// 获取站点信息表
+func QuerySiteInfoDataList(para dto.SiteInfoRequest) (*dto.ResPage[dto.SiteInfoDTO], error) {
+	res, err := repositories.GetSiteInfoManageList(para.PageNum, para.PageSize, para.Filter, para.Search)
+	if err != nil {
+		return nil, err
+	}
+
+	result := dto.ResPage[dto.SiteInfoDTO]{
+		Data:     []dto.SiteInfoDTO{},
+		PageSize: res.PageSize,
+		PageNum:  res.PageNum,
+		Total:    res.Total,
+	}
+
+	for _, d := range res.Data {
+		result.Data = append(result.Data, *dto.SiteInfoDomainToSiteInfoDTO(&d))
+	}
+
+	return &result, nil
+}
+
+// 导出站点信息表
+func ExportSiteInfoDataList(filer *domain.SiteInfoFilter) (*excelize.File, error) {
+	res, err := repositories.GetSiteInfoManageList(0, 0, filer, "")
+	if err != nil {
+		return nil, err
+	}
+
+	record, err := repositories.GetSiteInfoUpdateNewRecord()
+	if err != nil {
+		return nil, err
+	}
+
+	// 创建新的excel文件
+	f := excelize.NewFile()
+
+	// 写标题行
+	f.SetCellValue("Sheet1", "A1", "SN")
+	f.SetCellValue("Sheet1", "B1", "Installer")
+	f.SetCellValue("Sheet1", "C1", "ActiveTime")
+	f.SetCellValue("Sheet1", "D1", "Status")
+	f.SetCellValue("Sheet1", "E1", "OnLineTime")
+	f.SetCellValue("Sheet1", "F1", "OffLineTime")
+	f.SetCellValue("Sheet1", "G1", "FhpSn")
+	f.SetCellValue("Sheet1", "H1", "Version")
+	f.SetCellValue("Sheet1", "I1", "SlVersion")
+	f.SetCellValue("Sheet1", "J1", "SlVersionJson")
+	f.SetCellValue("Sheet1", "K1", "MeterVersion")
+	f.SetCellValue("Sheet1", "L1", "MbVersion")
+	f.SetCellValue("Sheet1", "M1", "AwsVersion")
+	f.SetCellValue("Sheet1", "N1", "AppVersion")
+	f.SetCellValue("Sheet1", "O1", "ProtocolVer")
+	f.SetCellValue("Sheet1", "P1", "SysHdVersion")
+	f.SetCellValue("Sheet1", "Q1", "Remark")
+	f.SetCellValue("Sheet1", "R1", "Country")
+	f.SetCellValue("Sheet1", "S1", "Province")
+	f.SetCellValue("Sheet1", "T1", "ManufactureDate")
+	f.SetCellValue("Sheet1", "U1", "HardwareVersion")
+	f.SetCellValue("Sheet1", "V1", "CveStatus")
+	f.SetCellValue("Sheet1", "W1", "SecurityPatchLevel")
+	f.SetCellValue("Sheet1", "X1", "TargetVersion")
+	f.SetCellValue("Sheet1", "Y1", "LastUpgradeTime")
+	f.SetCellValue("Sheet1", "Z1", "UpgradeStatus")
+	f.SetCellValue("Sheet1", "AA1", "Owner/Maintainer")
+	f.SetCellValue("Sheet1", "AB1", "DataSource")
+	f.SetCellValue("Sheet1", "AC1", "LastUpdated")
+
+	// 写入数据
+	for i, d := range res.Data {
+		f.SetCellValue("Sheet1", "A"+strconv.Itoa(i+2), d.Sn)
+		f.SetCellValue("Sheet1", "B"+strconv.Itoa(i+2), global.Deref(d.InstallerName, ""))
+		f.SetCellValue("Sheet1", "C"+strconv.Itoa(i+2), d.ActiveTime)
+		f.SetCellValue("Sheet1", "D"+strconv.Itoa(i+2), d.Status)
+		f.SetCellValue("Sheet1", "E"+strconv.Itoa(i+2), global.Deref(d.OnLineTime, ""))
+		f.SetCellValue("Sheet1", "F"+strconv.Itoa(i+2), global.Deref(d.OffLineTime, ""))
+		f.SetCellValue("Sheet1", "G"+strconv.Itoa(i+2), d.FhpSn)
+		f.SetCellValue("Sheet1", "H"+strconv.Itoa(i+2), d.Version)
+		f.SetCellValue("Sheet1", "I"+strconv.Itoa(i+2), d.SlVersion)
+		f.SetCellValue("Sheet1", "J"+strconv.Itoa(i+2), d.SlVersionJson)
+		f.SetCellValue("Sheet1", "K"+strconv.Itoa(i+2), global.Deref(d.MeterVersion, ""))
+		f.SetCellValue("Sheet1", "L"+strconv.Itoa(i+2), global.Deref(d.MbVersion, ""))
+		f.SetCellValue("Sheet1", "M"+strconv.Itoa(i+2), global.Deref(d.AwsVersion, ""))
+		f.SetCellValue("Sheet1", "N"+strconv.Itoa(i+2), global.Deref(d.AppVersion, ""))
+		f.SetCellValue("Sheet1", "O"+strconv.Itoa(i+2), d.ProtocolVer)
+		f.SetCellValue("Sheet1", "P"+strconv.Itoa(i+2), d.SysHdVersion)
+		f.SetCellValue("Sheet1", "Q"+strconv.Itoa(i+2), global.Deref(d.Remark, ""))
+		f.SetCellValue("Sheet1", "R"+strconv.Itoa(i+2), d.Country)
+		f.SetCellValue("Sheet1", "S"+strconv.Itoa(i+2), d.Province)
+		var manufactureDate string = ""
+		if len(d.Sn) > 9 {
+			year := d.Sn[len(d.Sn)-8 : len(d.Sn)-6]
+			week := d.Sn[len(d.Sn)-6 : len(d.Sn)-4]
+			manufactureDate = fmt.Sprintf("%s年%s周", year, week)
+		}
+		f.SetCellValue("Sheet1", "T"+strconv.Itoa(i+2), manufactureDate)
+		f.SetCellValue("Sheet1", "U"+strconv.Itoa(i+2), "V"+strconv.Itoa(d.SysHdVersion))
+		f.SetCellValue("Sheet1", "V"+strconv.Itoa(i+2), "无")
+		f.SetCellValue("Sheet1", "W"+strconv.Itoa(i+2), "安全")
+		f.SetCellValue("Sheet1", "X"+strconv.Itoa(i+2), d.Version)
+		f.SetCellValue("Sheet1", "Y"+strconv.Itoa(i+2), global.Deref(d.FirmUpgradeTime, ""))
+		f.SetCellValue("Sheet1", "Z"+strconv.Itoa(i+2), "成功")
+		f.SetCellValue("Sheet1", "AA"+strconv.Itoa(i+2), "Jerry Liang/LMT")
+		f.SetCellValue("Sheet1", "AB"+strconv.Itoa(i+2), "Fleetview")
+		f.SetCellValue("Sheet1", "AC"+strconv.Itoa(i+2), record.UpdateTime.Format(time.DateTime))
+	}
+
+	return f, err
+}
+
+// 查询单个站点实时信息(同时更新单个站点数据)
+func GetSiteInfoData(sn string) (*dto.SiteInfoDTO, error) {
+	res, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{
+		SN:       &sn,
+		PageSize: 10,
+		PageNum:  1,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	if res.Result == nil {
+		return nil, fmt.Errorf("code: %s, msg: %s", strconv.Itoa(res.Code), res.Message)
+	}
+
+	if len(*res.Result) == 0 {
+		return nil, fmt.Errorf("code: %s, msg: %s", strconv.Itoa(res.Code), res.Message)
+	}
+
+	domainData := domain.FwhSiteInfoToSiteInfoDomain(&(*res.Result)[0])
+
+	// 数据插入数据库
+	if err := repositories.InsertSiteInfoData(*domainData); err != nil {
+		zap.L().Error("保存站点信息失败", zap.String("err", err.Error()), zap.String("sn", sn))
+	}
+
+	dtoData := dto.SiteInfoDomainToSiteInfoDTO(domainData)
+
+	return dtoData, nil
+}
+
+// 使能站点xlog
+func OperateSiteInfoEnableXlog(ctx context.Context, sn string) (string, error) {
+	return repositories.FwhMqttEnableXlog(ctx, sn)
+}

+ 17 - 2
service/services/sitesMonitorServices.go

@@ -48,8 +48,23 @@ func QuerySiteBaseInfoList(para dto.SiteBaseInfoPara) (*domain.FwhResponse[[]dto
 }
 
 // 从fwh_pro获取站点基本信息表
-func QuerySiteInfoList(para dto.SiteInfoPara) (*domain.FwhPageResponse[[]dto.SiteInfo], error) {
-	return repositories.QuerySiteInfoList(para)
+func QuerySiteInfoList(para dto.SiteInfoPara) (*domain.FwhPageResponse[[]dto.MonitorSiteInfo], error) {
+	response, err := repositories.QuerySiteInfoList(para)
+	if err != nil {
+		return nil, err
+	}
+
+	resultInfo := []dto.MonitorSiteInfo{}
+	for _, info := range *response.Result {
+		resultInfo = append(resultInfo, *dto.FwhSiteInfoToMonitorSiteInfo(&info))
+	}
+	return &domain.FwhPageResponse[[]dto.MonitorSiteInfo]{
+		Code:    response.Code,
+		Message: response.Message,
+		Result:  &resultInfo,
+		Success: response.Success,
+		Total:   response.Total,
+	}, nil
 }
 
 // 从fwh_pro获取站点工作模式信息

+ 36 - 0
service/services/userInfoServices.go

@@ -0,0 +1,36 @@
+package services
+
+import (
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+)
+
+// 读取指定用户的完整配置
+func UserInfoReadAllConfig(name string) (*domain.UserConfigDomain, error) {
+	return repositories.UserInfoReadAllConfig(name)
+}
+
+// 覆盖更新指定用户的完整配置
+func UserInfoUpdateAllConfig(name string, config domain.UserConfigDomain) error {
+	return repositories.UserInfoUpdateAllConfig(name, config)
+}
+
+// 读取指定用户的SiteInfoManage配置
+func UserInfoReadSiteInfoManageConfig(name string) (*domain.SiteInfoManageConfig, error) {
+	return repositories.UserInfoReadSiteInfoManageConfig(name)
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件
+func UserInfoUpdateSiteInfoManageConfig(name string, config domain.SiteInfoManageConfig) error {
+	return repositories.UserInfoUpdateSiteInfoManageConfig(name, config)
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件中的TitleFilter
+func UserInfoUpdateSiteInfoManageConfigTitleFilter(name string, config []string) error {
+	return repositories.UserInfoUpdateSiteInfoManageConfigTitleFilter(name, config)
+}
+
+// 覆盖更新指定用户的SiteInfoManage配置文件中的CustomQueryFilter
+func UserInfoUpdateSiteInfoManageConfigCustomQueryFilter(name string, config []domain.SiteInfoQueryFilter) error {
+	return repositories.UserInfoUpdateSiteInfoManageConfigCustomQueryFilter(name, config)
+}

+ 76 - 1
service/taskQueue/taskQueue.go

@@ -17,7 +17,7 @@ import (
 
 var (
 	ctx        = context.Background()
-	streamName = "fwhtoolbox:taskQueueStream"
+	streamName = "fwhtoolbox:taskQueue:taskStream"
 	groupName  = "task_queue"
 	consumerID = "worker-taskQueue"
 
@@ -30,6 +30,9 @@ var (
 	runningID      = ""                 // 当前执行的任务ID
 	failedTaskList = []string{}         // 失败且未处理任务ID列表
 	errorTaskList  = []redis.XMessage{} // 超过重试次数仍失败的任务列表
+
+	// 唯一任务key前缀
+	uniqueTaskKeyPrefix = "fwhtoolbox:taskQueue:taskUnique:"
 )
 
 type TaskStreamInfo struct {
@@ -127,6 +130,28 @@ func handleTaskQueueMsg(message redis.XMessage) {
 	id := message.ID
 	rawPayload := message.Values["payload"].(string)
 
+	var rawLockKey *string
+	var rawLockToken *string
+
+	if val, ok := message.Values["uniqueLockKey"]; ok {
+		if str, ok := val.(string); ok {
+			rawLockKey = &str
+		}
+	}
+
+	if val, ok := message.Values["uniqueLockToken"]; ok {
+		if str, ok := val.(string); ok {
+			rawLockToken = &str
+		}
+	}
+
+	defer func() {
+		if rawLockKey != nil && rawLockToken != nil {
+			// 释放唯一任务锁
+			FinishUniqueTask(*rawLockKey, *rawLockToken)
+		}
+	}()
+
 	// 先反序列化成 map 确定 type
 	var base TaskPayloadBase
 	if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
@@ -203,6 +228,27 @@ func handleTaskQueueMsg(message redis.XMessage) {
 			}, false, fmt.Errorf("handle task BatchDownload error - %s", err.Error()))
 			return
 		}
+	case TaskTypeSiteInfoUpdate:
+		var payload TaskPayload[domain.TaskSiteInfoUpdate]
+		if err := json.Unmarshal([]byte(rawPayload), &payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("unmarshal payload error - %s", err.Error()))
+			return
+		}
+		logger.Std.Printf("Processing task %s: %+v\n", id, payload)
+		if err := handleTaskSiteInfoUpdate(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task SiteInfoUpdate error - %s", err.Error()))
+			return
+		}
 	default:
 		logger.Std.Println("Unknown task type:", base.Type)
 	}
@@ -248,6 +294,35 @@ func EnqueueTask[T any](payload TaskPayload[T]) error {
 	return nil
 }
 
+func EnqueueUniqueTask[T any](payload TaskPayload[T]) error {
+	data, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+
+	key := uniqueTaskKeyPrefix + payload.Type
+
+	lock, ok, err := cache.TryLock(ctx, key, 2*time.Hour)
+	if err != nil {
+		return err
+	}
+
+	if !ok {
+		return fmt.Errorf("task type already exists: %s", payload.Type)
+	}
+
+	if _, err := cache.XAdd(ctx, streamName, map[string]interface{}{"payload": string(data), "uniqueLockKey": lock.GetKey(), "uniqueLockToken": lock.GetToken()}); err != nil {
+		lock.Release(ctx)
+		return err
+	}
+
+	return nil
+}
+
+func FinishUniqueTask(key string, token string) {
+	cache.LockRelease(ctx, key, token)
+}
+
 func GetTaskInfoList() ([]dto.BackgroundTaskItem, error) {
 	c, cancel := context.WithTimeout(ctx, 15*time.Second)
 	defer cancel()

+ 97 - 0
service/taskQueue/taskSiteInfoUpdate.go

@@ -0,0 +1,97 @@
+package taskQueue
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+
+	"go.uber.org/zap"
+)
+
+var (
+	TaskTypeSiteInfoUpdate = "task_SiteInfoUpdate"
+)
+
+// type TaskSiteInfoUpdate = types.TaskSiteInfoUpdate
+
+func handleTaskSiteInfoUpdate(ctx context.Context, payload domain.TaskSiteInfoUpdate) error {
+	// 更新运行状态
+	fmt.Println("更新任务运行状态")
+	if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressRunning, 0); repoErr != nil {
+		zap.L().Error("更新站点信息Update结果状态失败(Running)", zap.String("err", repoErr.Error()))
+		return repoErr
+	}
+
+	fmt.Printf("开始执行任务 %v\n", payload)
+	total, err := executeTaskSiteInfoUpdate(ctx, payload)
+	// 记录任务执行结果
+	if err != nil {
+		fmt.Printf("任务执行失败 %s\n", err.Error())
+		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressError, 0); repoErr != nil {
+			zap.L().Error("更新站点信息Update结果状态失败(Error)", zap.String("err", repoErr.Error()))
+			return repoErr
+		}
+	} else {
+		fmt.Println("任务执行成功")
+		if repoErr := repositories.UpdateSiteInfoUpdateResult(payload.Id, domain.ProgressComplete, total); repoErr != nil {
+			zap.L().Error("更新站点信息Update结果状态失败(Complete)", zap.String("err", repoErr.Error()))
+			return repoErr
+		}
+	}
+
+	return err
+}
+
+var executeTaskSiteInfoUpdate = func(ctx context.Context, payload domain.TaskSiteInfoUpdate) (uint64, error) {
+	var pageSize uint = 50
+	var pageNum uint = 1
+	var total uint64 = 0
+	var realTotal uint64 = 0
+
+	fmt.Println("查询站点信息")
+	for {
+		resp, err := repositories.QuerySiteInfoList(dto.SiteInfoPara{
+			PageSize: pageSize,
+			PageNum:  pageNum,
+		})
+		if err != nil {
+			zap.L().Error("查询站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
+			return 0, err
+		}
+
+		for _, info := range *resp.Result {
+			if err := repositories.InsertSiteInfoData(*domain.FwhSiteInfoToSiteInfoDomain(&info)); err != nil {
+				zap.L().Error("保存站点信息失败", zap.String("err", err.Error()), zap.Uint("pageSize", pageSize), zap.Uint("pageNum", pageNum), zap.Uint64("total", total))
+				return 0, err
+			}
+			realTotal++
+		}
+
+		pageNum++
+
+		total = resp.Total
+		if (int64(pageNum)-1)*int64(pageSize) >= int64(total) {
+			break
+		}
+	}
+
+	return realTotal, nil
+}
+
+// 插入站点分析队列
+func EnqueueSiteInfoUpdateTask(payload domain.TaskSiteInfoUpdate) error {
+	p := TaskPayload[domain.TaskSiteInfoUpdate]{
+		TaskPayloadBase: TaskPayloadBase{
+			Type: TaskTypeSiteInfoUpdate,
+		},
+		Payload: payload,
+	}
+	if err := EnqueueUniqueTask[domain.TaskSiteInfoUpdate](p); err != nil {
+		logger.Std.Printf("EnqueueSiteInfoUpdateTask error : %s\n", err.Error())
+		return err
+	}
+	return nil
+}

+ 25 - 0
service/test/smartHub/apiTest.go

@@ -0,0 +1,25 @@
+package main
+
+import (
+	"fmt"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories/api/smartHub"
+)
+
+func main() {
+	infoList, err := smartHub.QueryBatchSimCardInfo(1, 10)
+	if err != nil {
+		panic(err)
+	}
+
+	for _, info := range infoList.Data {
+		fmt.Println(domain.SmartHubSimCardInfoItemToStorage(&info))
+	}
+
+	info, err := smartHub.QuerySimCardInfo("8944501503181710532")
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Println(domain.SmartHubSimCardInfoToStorage(info))
+}

+ 17 - 0
service/utils/global/global.go

@@ -0,0 +1,17 @@
+package global
+
+// 三元运算(three-operand operator)
+func Ternary[T any](cond bool, a, b T) T {
+	if cond {
+		return a
+	}
+	return b
+}
+
+// Dereference(解引用)
+func Deref[T any](p *T, defaultValue T) T {
+	if p == nil {
+		return defaultValue
+	}
+	return *p
+}

+ 24 - 13
service/utils/httpClient/httpClient.go

@@ -18,6 +18,10 @@ const (
 	DefaultTimeout time.Duration = 5 // 默认超时时间5s
 )
 
+type HttpClient struct {
+	reLogin *func() string
+}
+
 type HttpClientOptions struct {
 	Timeout *time.Duration // 单位:s
 	Header  *http.Header
@@ -59,12 +63,12 @@ type Response struct {
 	ContentLength int64
 }
 
-var reLogin = func() string {
-	// empty function
-	return ""
-}
+// var reLogin = func() string {
+// 	// empty function
+// 	return ""
+// }
 
-func responseInterceptor(response *Response, retry func(*string) (*Response, error)) *Response {
+func (c *HttpClient) responseInterceptor(response *Response, retry func(*string) (*Response, error)) *Response {
 	// 判断是否回复code 401
 	var data ResponseData
 	if err := json.Unmarshal(response.Body, &data); err != nil {
@@ -72,7 +76,10 @@ func responseInterceptor(response *Response, retry func(*string) (*Response, err
 		return response
 	}
 	if data.Code == 401 {
-		tokenStr := reLogin()
+		if c.reLogin == nil {
+			return response
+		}
+		tokenStr := (*c.reLogin)()
 		retryBody, err := retry(&tokenStr)
 		if err != nil {
 			zap.L().Warn("http retry 失败", zap.String("token", tokenStr), zap.String("err", err.Error()))
@@ -83,13 +90,17 @@ func responseInterceptor(response *Response, retry func(*string) (*Response, err
 	return response
 }
 
-func RegisterLogin(login func() string) {
-	reLogin = login
+func (c *HttpClient) RegisterLogin(login func() string) {
+	c.reLogin = &login
 }
 
-func HttpGet(url string, params url.Values, opt *HttpClientOptions) (*Response, error) {
+func (c *HttpClient) HttpGet(url string, params *url.Values, opt *HttpClientOptions) (*Response, error) {
 	// 拼接参数到 URL
-	finalURL := url + "?" + params.Encode()
+	finalURL := url
+	if params != nil {
+		finalURL = finalURL + "?" + params.Encode()
+	}
+
 	zap.L().Debug("HttpGet", zap.String("url", finalURL))
 
 	// 创建请求
@@ -151,7 +162,7 @@ func HttpGet(url string, params url.Values, opt *HttpClientOptions) (*Response,
 		return response, fmt.Errorf("[HttpGet] Response failed")
 	}
 
-	return responseInterceptor(response, reqFunc), nil
+	return c.responseInterceptor(response, reqFunc), nil
 }
 
 // toPostBody 将任意 body 转换为 http POST 可用的 io.Reader
@@ -193,7 +204,7 @@ func toPostBody(body any) (*PostBody, error) {
 	}
 }
 
-func HttpPost(url string, body any, opt *HttpClientOptions) (*Response, error) {
+func (c *HttpClient) HttpPost(url string, body any, opt *HttpClientOptions) (*Response, error) {
 	// 转换body数据
 	payload, err := toPostBody(body)
 	if err != nil {
@@ -264,5 +275,5 @@ func HttpPost(url string, body any, opt *HttpClientOptions) (*Response, error) {
 		return response, fmt.Errorf("[HttpPost] Response failed")
 	}
 
-	return responseInterceptor(response, reqFunc), nil
+	return c.responseInterceptor(response, reqFunc), nil
 }

+ 1 - 1
service/utils/minio/minio.go

@@ -20,7 +20,7 @@ var BucketName string
 
 type ObjectInfo = minio.ObjectInfo
 
-func InitClient(cfg *config.Config) {
+func InitClient(cfg *config.AppConfig) {
 	var err error
 	Client, err = minio.New(cfg.MinIoEndpoint, &minio.Options{
 		Creds:  credentials.NewStaticV4(cfg.MinioAccessKey, cfg.MinioSecretKey, ""),

+ 238 - 0
service/utils/mqttClient/mqttClient.go

@@ -0,0 +1,238 @@
+package mqttClient
+
+import (
+	"crypto/tls"
+	"fmt"
+	"time"
+
+	mqtt "github.com/eclipse/paho.mqtt.golang"
+	"go.uber.org/zap"
+)
+
+type MqttClientOpt struct {
+	Broker   string // 服务端地址
+	ClientID string // (可选) 客户端名称
+	TLS      *tls.Config
+}
+
+type MqttClient struct {
+	client       mqtt.Client
+	subscribeMap map[string]func(payload []byte)
+	clientID     string // 客户端名称
+}
+
+const (
+	connectTimeout      = 30 * time.Second // 30s
+	subscribeMsgTimeout = 30 * time.Second // 30s
+	sendMsgTimeout      = 30 * time.Second // 30s
+)
+
+// 创建客户端
+func CreateMqttClient(opt MqttClientOpt) (*MqttClient, error) {
+	// 创建客户端选项
+	opts := mqtt.NewClientOptions()
+	opts.AddBroker(opt.Broker)
+	clientID := "go_mqtt_client"
+	if len(opt.ClientID) > 0 {
+		clientID = opt.ClientID
+	}
+	opts.SetClientID(clientID)
+
+	opts.SetAutoReconnect(true) // 自动重连
+	opts.SetCleanSession(true)  // 不保留会话
+	opts.SetTLSConfig(opt.TLS)
+
+	mqttClient := MqttClient{
+		subscribeMap: map[string]func(payload []byte){},
+	}
+
+	// 连接成功回调
+	opts.OnConnect = func(c mqtt.Client) {
+		zap.L().Info("Connected to MQTT broker", zap.String("clientID", clientID))
+
+		// 重新订阅丢失的 Subscribe
+		mqttClient.ResumeSubscribe()
+	}
+
+	// 连接丢失回调
+	opts.OnConnectionLost = func(c mqtt.Client, err error) {
+		fmt.Printf("%s Connection lost: %s\n", clientID, err.Error())
+		zap.L().Info("Connection lost", zap.String("clientID", clientID), zap.String("err", err.Error()))
+	}
+
+	// 创建客户端
+	mqttClient.client = mqtt.NewClient(opts)
+
+	return &mqttClient, nil
+}
+
+func (c *MqttClient) Connect() error {
+	if c.client.IsConnected() {
+		return nil
+	}
+
+	// 连接
+	token := c.client.Connect()
+	if !token.WaitTimeout(connectTimeout) {
+		return fmt.Errorf("connect timeout (%s)", c.clientID)
+	}
+
+	return token.Error()
+}
+
+// 断开连接
+func (c *MqttClient) Disconnect() {
+	if !c.client.IsConnected() {
+		return
+	}
+
+	// 清空订阅
+	c.CleanSubscribe()
+
+	// 安全断开,等待最多 2 秒让队列消息发送完
+	c.client.Disconnect(2000)
+}
+
+// 检查是否连接
+func (c *MqttClient) IsConnected() bool {
+	return c.client.IsConnected()
+}
+
+// 恢复订阅消息
+func (c *MqttClient) ResumeSubscribe() {
+	for k, v := range c.subscribeMap {
+		c.subscribeMessage(k, v)
+	}
+}
+
+// 清空所有订阅
+func (c *MqttClient) CleanSubscribe() {
+	for k := range c.subscribeMap {
+		delete(c.subscribeMap, k)
+	}
+}
+
+// 订阅消息
+func (c *MqttClient) subscribeMessage(topic string, callback func(payload []byte)) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("subscribe failed. (client not connected)")
+	}
+
+	token := c.client.Subscribe(topic, 1, func(c mqtt.Client, m mqtt.Message) {
+		callback(m.Payload())
+	})
+	if !token.WaitTimeout(subscribeMsgTimeout) {
+		return fmt.Errorf("subscribe timeout (topic:%s)", topic)
+	}
+
+	err := token.Error()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// 订阅消息
+func (c *MqttClient) SubscribeMessage(topic string, callback func(payload []byte)) error {
+	err := c.subscribeMessage(topic, callback)
+	if err != nil {
+		return err
+	}
+
+	// 备份subscribe
+	c.subscribeMap[topic] = callback
+
+	return nil
+}
+
+// 取消订阅
+func (c *MqttClient) UnSubscribeMessage(topic string) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("unsubscribe failed. (client not connected)")
+	}
+
+	token := c.client.Unsubscribe(topic)
+	if !token.WaitTimeout(subscribeMsgTimeout) {
+		return fmt.Errorf("unsubscribe timeout (topic:%s)", topic)
+	}
+
+	err := token.Error()
+	if err != nil {
+		return err
+	}
+
+	delete(c.subscribeMap, topic)
+
+	return nil
+}
+
+// 发送消息,只尝试一次,不管是否送达
+func (c *MqttClient) SendMessageOnce(topic string, msg []byte) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("send message once failed. (client not connected)")
+	}
+
+	token := c.client.Publish(topic, 0, false, msg)
+	if !token.WaitTimeout(sendMsgTimeout) {
+		return fmt.Errorf("send message once timeout (topic:%s, msg:%s)", topic, msg)
+	}
+
+	return token.Error()
+}
+
+// 发送消息,没收到 ACK 就重发,至少送到一次,可能收到多次
+func (c *MqttClient) SendMessage(topic string, msg []byte, retained bool) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("send message failed. (client not connected)")
+	}
+
+	token := c.client.Publish(topic, 1, retained, msg)
+	if !token.WaitTimeout(sendMsgTimeout) {
+		return fmt.Errorf("send message timeout (topic:%s, msg:%s)", topic, msg)
+	}
+
+	return token.Error()
+}
+
+// 发送消息,严格一次,不重复,不丢失
+func (c *MqttClient) SendMessageMustOnce(topic string, msg []byte, retained bool) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("send message must once failed. (client not connected)")
+	}
+
+	token := c.client.Publish(topic, 2, retained, msg)
+	if !token.WaitTimeout(sendMsgTimeout) {
+		return fmt.Errorf("send message must once timeout (topic:%s, msg:%s)", topic, msg)
+	}
+
+	return token.Error()
+}
+
+// 发送消息,没收到 ACK 就重发,至少送到一次,可能收到多次,并保留消息在Broker,新定义的设备会收到消息
+func (c *MqttClient) SendMessageRetained(topic string, msg []byte) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("send message retained failed. (client not connected)")
+	}
+
+	token := c.client.Publish(topic, 1, true, msg)
+	if !token.WaitTimeout(sendMsgTimeout) {
+		return fmt.Errorf("send message retained timeout (topic:%s, msg:%s)", topic, msg)
+	}
+
+	return token.Error()
+}
+
+// 清除保留在Broker的消息
+func (c *MqttClient) CleanMessageRetained(topic string) error {
+	if !c.client.IsConnected() {
+		return fmt.Errorf("clean message retained failed. (client not connected)")
+	}
+
+	token := c.client.Publish(topic, 1, true, nil)
+	if !token.WaitTimeout(sendMsgTimeout) {
+		return fmt.Errorf("clean message retained timeout (topic:%s)", topic)
+	}
+
+	return token.Error()
+}

+ 1 - 1
service/utils/snowflakeID/snowflakeID.go

@@ -1,4 +1,4 @@
-package snowflakeid
+package snowflakeID
 
 import (
 	"fwh-toolbox-server/logger"

+ 138 - 6
service/utils/xlog/xlog_package.go

@@ -22,14 +22,146 @@ const (
 	_XLOG_P2P_BASH_CMD_RESULT_  uint8 = 65 // xlogv3-> SERNAME_UB_SERVER -> pc      返回远程命令执行结果
 )
 
+// xlog1.0相关配置
+const (
+	// Magic word
+	_MAGIC_XLOG_1_0_SYSTEM_ uint32 = 0xAA3355BB
+
+	// CmdType 命令类型
+	_XLOG_1_0_CMD_LOG_TO_PC_       uint32 = 0 // 心跳内容, 内容为"[x]\n"
+	_XLOG_1_0_CMD_EXEC_BASH_CMD_   uint32 = 1 // pc    -> SERNAME_UB_SERVER -> xlog 发送命令给ARM执行
+	_XLOG_1_0_CMD_BASH_CMD_RESULT_ uint32 = 2 // xlog  -> SERNAME_UB_SERVER -> pc      返回远程命令执行结果
+)
+
 func NewXlogPackageParser(ctx *ConnectContext) *XlogPackageParser {
 	return &XlogPackageParser{
-		crypt:        NewXlogEncrypt(),
-		ctx:          ctx,
-		cmdResultStr: "",
-		Handshake:    false,
-		Buffer:       make([]byte, 0),
+		crypt:         NewXlogEncrypt(),
+		ctx:           ctx,
+		cmdResultStr:  "",
+		Handshake:     false,
+		Buffer:        make([]byte, 0),
+		IsOldProtocol: true,
+	}
+}
+
+// 判断协议版本,是否是xlog1.0
+func (parser *XlogPackageParser) ParseXlogCheckVersionIsOld(raw []byte) (bool, error) {
+	reader := bytes.NewReader(raw)
+
+	// 数据长度校验
+	if len(raw) < 4 {
+		return false, fmt.Errorf("invalid packet: data length error, expected > 4, got %d", len(raw))
+	}
+
+	var magicWord uint32
+	err := binary.Read(reader, binary.LittleEndian, &magicWord)
+	if err != nil {
+		return false, err
+	}
+
+	return magicWord == _MAGIC_XLOG_1_0_SYSTEM_, nil
+}
+
+// 预解析xlog1.0数据包,数据提取,提取一个完整数据包
+func (parser *XlogPackageParser) PreparserXlog10Package(raw []byte) {
+	// 累积数据
+	parser.Buffer = append(parser.Buffer, raw...)
+	buffer := parser.Buffer
+
+	for {
+		// 不够一个包头和长度字段,继续收
+		if len(buffer) < 12 {
+			return
+		}
+
+		// 查找合法包头
+		header := binary.LittleEndian.Uint32(buffer[:4])
+		if header != _MAGIC_XLOG_1_0_SYSTEM_ {
+			// 丢掉 1 字节,继续找包头
+			buffer = buffer[1:]
+			continue
+		}
+
+		// 读取命令类型
+		// cmdType := binary.LittleEndian.Uint32(buffer[4:8])
+
+		// 读取长度
+		totalLen := binary.LittleEndian.Uint32(buffer[8:12])
+
+		// 数据是否完整
+		if len(buffer) < int(totalLen) {
+			break
+		}
+
+		// 提取完整包
+		packet := buffer[:totalLen]
+
+		// 异步处理
+		go parser.ParseXlog10Package(packet)
+
+		// 移除已处理的数据
+		parser.Buffer = buffer[totalLen:]
+		buffer = parser.Buffer
+	}
+}
+
+// 解析xlog1.0设备数据包
+func (parser *XlogPackageParser) ParseXlog10Package(raw []byte) {
+	// 数据长度校验
+	headerPackageSize := binary.Size(Xlog10PackageHeader{})
+	if len(raw) < headerPackageSize {
+		logger.Std.Printf("invalid packet: data length error, expected >= %d, got %d\n", headerPackageSize, len(raw))
+		return
+	}
+
+	headerRaw := raw[:headerPackageSize]
+	headerReader := bytes.NewReader(headerRaw)
+
+	// 解析头部
+	var h Xlog10PackageHeader
+	if err := binary.Read(headerReader, binary.LittleEndian, &h); err != nil {
+		logger.Std.Printf("Xlog1.0Package header parsing error: %s\n", err.Error())
+		return
 	}
+
+	if len(raw) > headerPackageSize {
+		payloadRaw := raw[headerPackageSize:] // payload数据
+
+		// 判断包类型
+		switch h.CmdType {
+		case _XLOG_1_0_CMD_LOG_TO_PC_:
+			parser.handleHeartbeat(payloadRaw)
+		case _XLOG_1_0_CMD_BASH_CMD_RESULT_:
+			parser.handleCmdResult(payloadRaw)
+		}
+
+	}
+}
+
+// 创建xlog1.0发送命令数据包
+func (parser XlogPackageParser) CreateXlog10CmdPackage(payload []byte) ([]byte, error) {
+	h := Xlog10PackageHeader{
+		MagicWord: _MAGIC_XLOG_1_0_SYSTEM_,
+		CmdType:   _XLOG_1_0_CMD_EXEC_BASH_CMD_,
+		CmdLen:    uint32(12 + len(payload)),
+	}
+
+	p := Xlog10Package{
+		Header:  h,
+		Payload: payload,
+	}
+
+	buf := new(bytes.Buffer)
+	// 按小端序写入,先写头部
+	if err := binary.Write(buf, binary.LittleEndian, p.Header); err != nil {
+		return nil, fmt.Errorf("failed to write Xlog10Package header: %w", err)
+	}
+	// 再写 payload
+	if _, err := buf.Write(p.Payload); err != nil {
+		return nil, fmt.Errorf("failed to write Xlog10Package payload: %w", err)
+	}
+
+	return buf.Bytes(), nil
 }
 
 // 解析设备握手包
@@ -124,7 +256,7 @@ func (parser *XlogPackageParser) PreparserXlogPackage(raw []byte) {
 
 // 处理设备心跳信息
 func (parser XlogPackageParser) handleHeartbeat(raw []byte) {
-	logger.Std.Printf("%s - 接收到心跳:%s", parser.ctx.UUID, string(raw))
+	// logger.Std.Printf("%s - 接收到心跳:%s", parser.ctx.UUID, string(raw))
 }
 
 // 处理设备基本信息

+ 20 - 5
service/utils/xlog/xlog_types.go

@@ -7,9 +7,11 @@ import (
 type ConnectContext struct {
 	UUID              string
 	SN                string
+	Version           string
 	XlogPackageParser *XlogPackageParser
 	SendBuffer        atomic.Value
 	DoneChan          chan string // 用来回传结果的 channel
+	ChanIsRunning     bool        // 结果是否在传输中
 }
 
 // Site基本信息
@@ -52,11 +54,24 @@ type XlogPackage struct {
 	EncryptPayload []byte // 数据
 }
 
+// xlog1.0数据包结构
+type Xlog10PackageHeader struct {
+	MagicWord uint32 // Magic word
+	CmdType   uint32 // 命令类型
+	CmdLen    uint32 // 数据包总长度(包含MagicWord、CmdType、CmdLen,实际数据长度为CmdLen-12)
+}
+
+type Xlog10Package struct {
+	Header  Xlog10PackageHeader
+	Payload []byte // 数据
+}
+
 // 必须用构造函数NewXlogPackageParser创建
 type XlogPackageParser struct {
-	crypt        XlogEncrypt
-	ctx          *ConnectContext
-	cmdResultStr string
-	Handshake    bool   // 连接是否已握手
-	Buffer       []byte // 接收数据缓存buffer,数据可能不完整,需预处理
+	crypt         XlogEncrypt
+	ctx           *ConnectContext
+	cmdResultStr  string
+	Handshake     bool   // 连接是否已握手
+	Buffer        []byte // 接收数据缓存buffer,数据可能不完整,需预处理
+	IsOldProtocol bool   // 是否是xlog1.0协议
 }

+ 75 - 0
src/event/eventEmitter.ts

@@ -0,0 +1,75 @@
+type Listener<T = any> = (payload: T) => void | Promise<void>
+
+interface ListenerOptions {
+  once?: boolean
+  priority?: number // 优先级,数值越大越先触发
+}
+
+type EventMap = Record<string, any> // 用户自定义事件类型映射
+
+export class EventEmitter<Events extends EventMap = Record<string, any>> {
+  private listeners = new Map<keyof Events, Array<{ fn: Listener; options: ListenerOptions }>>()
+
+  /**
+   * 注册事件
+   */
+  on<K extends keyof Events>(event: K, fn: Listener<Events[K]>, options: ListenerOptions = {}) {
+    const arr = this.listeners.get(event) || []
+    arr.push({ fn, options })
+    // 按优先级排序,数值大优先
+    arr.sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0))
+    this.listeners.set(event, arr)
+    return () => this.off(event, fn)
+  }
+
+  /**
+   * 注册一次性事件
+   */
+  once<K extends keyof Events>(
+    event: K,
+    fn: Listener<Events[K]>,
+    options: Omit<ListenerOptions, 'once'> = {},
+  ) {
+    return this.on(event, fn, { ...options, once: true })
+  }
+
+  /**
+   * 注销事件
+   */
+  off<K extends keyof Events>(event: K, fn?: Listener<Events[K]>) {
+    if (!fn) {
+      this.listeners.delete(event)
+      return
+    }
+    const arr = this.listeners.get(event)
+    if (!arr) return
+    this.listeners.set(
+      event,
+      arr.filter((l) => l.fn !== fn),
+    )
+  }
+
+  /**
+   * 触发事件
+   */
+  async emit<K extends keyof Events>(event: K, payload: Events[K]) {
+    const arr = this.listeners.get(event)
+    if (!arr || arr.length === 0) return
+
+    for (const listener of [...arr]) {
+      try {
+        await listener.fn(payload)
+        if (listener.options.once) this.off(event, listener.fn)
+      } catch (err) {
+        console.error(`Error in event listener [${String(event)}]:`, err)
+      }
+    }
+  }
+
+  /**
+   * 获取监听器数量(调试用)
+   */
+  listenerCount<K extends keyof Events>(event: K) {
+    return this.listeners.get(event)?.length ?? 0
+  }
+}

+ 6 - 0
src/event/eventTypes.ts

@@ -0,0 +1,6 @@
+// APP事件类型
+export interface AppEvents {
+  'user:login': { userId: string; name: string }
+  'user:logout': { userId: string }
+  'order:created': { orderId: string; amount: number }
+}

+ 8 - 0
src/event/events.ts

@@ -0,0 +1,8 @@
+import { EventEmitter } from '@/event/eventEmitter'
+import type { AppEvents } from '@/event/eventTypes'
+
+if (!globalThis.AppEvents) {
+  globalThis.AppEvents = new EventEmitter<AppEvents>()
+}
+
+// export const appEvents = new EventEmitter<AppEvents>()

+ 1 - 0
src/main.ts

@@ -7,6 +7,7 @@ import App from '@/App.vue'
 import router from '@/router'
 // pinia store
 import pinia from '@/stores'
+import '@/event/events'
 
 // errorHandler & warnHandler
 import errorHandler from '@/utils/errorHandler'

+ 8 - 1
src/router/guard.ts

@@ -27,7 +27,7 @@ export function setupRouterGuard(router: Router) {
 
     const userStore = useUserStore()
 
-    if (to.name === 'cassdoor-callback') {
+    if (to.name === 'casdoor-callback') {
       const code = to.query.code
       const state = to.query.state
       if (typeof code === 'string' && typeof state === 'string') {
@@ -36,6 +36,13 @@ export function setupRouterGuard(router: Router) {
         // 保存token
         userStore.setToken(res)
 
+        // 触发登录事件
+        const payload = userStore.tokenPayload
+        globalThis.AppEvents.emit('user:login', {
+          userId: payload?.id ?? '',
+          name: payload?.name ?? '',
+        })
+
         next(from.fullPath)
       } else {
         // 🚨 登录参数错误时跳转到 errorPage

+ 1 - 1
src/router/index.ts

@@ -15,7 +15,7 @@ const routes: RouteRecordRaw[] = [
   },
   {
     path: '/casdoor/callback',
-    name: 'cassdoor-callback',
+    name: 'casdoor-callback',
     component: defineComponent({ template: '<div>登录中...</div>' }), // 占位组件
   },
   {

+ 52 - 41
src/router/record.ts

@@ -13,6 +13,17 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'dashboard/index',
   },
+  {
+    path: 'site-info-manage',
+    name: 'siteInfoManage',
+    icon: 'i-ph:address-book-duotone',
+    label: '站点信息管理',
+    meta: {
+      componentName: 'SiteInfoManage',
+      showTab: true,
+    },
+    component: 'site-info-manage/index',
+  },
   {
     path: 'shutdown-manage',
     name: 'shutdownManage',
@@ -45,7 +56,7 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
       showTab: true,
       enableMultiTab: true,
       renderTabLabel({ name }) {
-        return `站点监控${name ? '-' + name : ''}`
+        return `站点监控${name ? `-${name}` : ''}`
       },
     },
     component: 'sites-monitor/index',
@@ -288,17 +299,17 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'batch-download/index',
   },
-  {
-    path: 'background-task',
-    name: 'backgroundTask',
-    icon: 'i-ph:selection-background',
-    label: '后台任务管理',
-    meta: {
-      componentName: 'BackgroundTask',
-      showTab: true,
-    },
-    component: 'background-task/index',
-  },
+  // {
+  //   path: 'background-task',
+  //   name: 'backgroundTask',
+  //   icon: 'i-ph:selection-background',
+  //   label: '后台任务管理',
+  //   meta: {
+  //     componentName: 'BackgroundTask',
+  //     showTab: true,
+  //   },
+  //   component: 'background-task/index',
+  // },
   {
     path: 'xlog-manage',
     name: 'xlogManage',
@@ -310,23 +321,23 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'xlog/manage',
   },
-  {
-    path: 'xlog-terminal/:uuid',
-    name: 'xlogTerminal',
-    icon: 'i-carbon:terminal',
-    label: 'XLOG',
-    meta: {
-      componentName: 'XlogTerminal',
-      showTab: true,
-      enableMultiTab: true,
-      renderTabLabel({ uuid }) {
-        // TODO 后续考虑如何显示SN
-        return `XLOG${uuid ? '-' + uuid : ''}`
-      },
-    },
-    component: 'xlog/index',
-    show: false,
-  },
+  // {
+  //   path: 'xlog-terminal/:uuid',
+  //   name: 'xlogTerminal',
+  //   icon: 'i-carbon:terminal',
+  //   label: 'XLOG',
+  //   meta: {
+  //     componentName: 'XlogTerminal',
+  //     showTab: true,
+  //     enableMultiTab: true,
+  //     renderTabLabel({ uuid }) {
+  //       // TODO 后续考虑如何显示SN
+  //       return `XLOG${uuid ? '-' + uuid : ''}`
+  //     },
+  //   },
+  //   component: 'xlog/index',
+  //   show: false,
+  // },
   {
     path: 'local-utils',
     key: 'local-utils',
@@ -429,16 +440,16 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
       enableMultiTab: true,
     },
   },
-  {
-    path: 'test',
-    key: 'test',
-    name: 'test',
-    icon: 'i-ph:test-tube-duotone',
-    label: '测试页',
-    component: 'test/index',
-    meta: {
-      showTab: true,
-      enableMultiTab: true,
-    },
-  },
+  // {
+  //   path: 'test',
+  //   key: 'test',
+  //   name: 'test',
+  //   icon: 'i-ph:test-tube-duotone',
+  //   label: '测试页',
+  //   component: 'test/index',
+  //   meta: {
+  //     showTab: true,
+  //     enableMultiTab: true,
+  //   },
+  // },
 ]

+ 8 - 3
src/service/fetchClient.ts

@@ -205,7 +205,7 @@ export class FetchClient {
     return this.request<D>(url, 'DELETE', options, options?.queue, options?.key)
   }
 
-  async download(url: string, filename?: string, options?: FetchOptions) {
+  async download(url: string, filename?: string, options?: FetchOptions, data?: any) {
     const { baseURL = this.baseURL, params, headers = {}, signal } = options || {}
 
     // 构造完整 URL + 查询参数
@@ -221,13 +221,18 @@ export class FetchClient {
 
     // fetch config
     const config: RequestInit = {
-      method: 'GET',
+      method: data ? 'POST' : 'GET',
       headers: {
+        'Content-Type': 'application/json',
         ...headers,
       },
       signal: mergedSignal,
     }
 
+    if (data) {
+      config.body = JSON.stringify(data)
+    }
+
     // 请求拦截器
     const finalConfig = this.requestInterceptor ? await this.requestInterceptor(config) : config
 
@@ -338,7 +343,7 @@ export class FetchClient {
     let blob: Blob | undefined
     try {
       const reader = res.body.getReader()
-      const chunks: Uint8Array[] = []
+      const chunks: Uint8Array<ArrayBuffer>[] = []
 
       while (true) {
         const { value, done } = await reader.read()

+ 99 - 0
src/service/siteInfoManage/index.ts

@@ -0,0 +1,99 @@
+// Site Info Manage API List
+// /siteInfoManage/manage/update - GET & POST 获取更新状态 & 请求更新站点信息表
+// /siteInfoManage/dataList - POST 站点信息表
+// /siteInfoManage/dataListExport - POST 导出站点信息表
+// /siteInfoManage/siteInfo/query - GET 获取单个站点实时信息(同时更新单个站点数据)
+// /siteInfoManage/operate/enableXlog - POST 使能站点xlog
+
+import http from '@/service/serverReq'
+import type {
+  SiteInfo,
+  SiteInfoUpdateInfo,
+  SiteInfoPage,
+  SiteInfoRequest,
+  SiteInfoFilter,
+  SiteInfoConfig,
+  SiteInfoQueryFilter,
+} from './interface.ts'
+
+const API_List = {
+  siteInfoList: '/siteInfoManage/dataList',
+  siteInfoUpdateStatus: '/siteInfoManage/manage/update',
+  siteInfoUpdate: '/siteInfoManage/manage/update',
+  siteInfoListExport: '/siteInfoManage/dataListExport',
+  siteInfoDataQuery: '/siteInfoManage/siteInfo/query',
+
+  // 配置查询相关
+  siteInfoConfig: '/userInfo/config/siteInfoManage',
+  siteInfoConfigTitleFilter: '/userInfo/config/siteInfoManage/titleFilter',
+  siteInfoConfigCustomQueryFilter: '/userInfo/config/siteInfoManage/customQueryFilter',
+
+  // 操作相关接口
+  siteOperateEnableXlog: '/siteInfoManage/operate/enableXlog',
+}
+
+// POST 获取站点信息表
+export const getSiteInfoManageList = async (config: SiteInfoRequest): Promise<SiteInfoPage> => {
+  const url = API_List.siteInfoList
+  const res = await http.post<SiteInfoPage>(url, config)
+  return res
+}
+
+// GET 获取更新状态
+export const getSiteInfoUpdateStatus = async (): Promise<SiteInfoUpdateInfo> => {
+  const url = API_List.siteInfoUpdateStatus
+  const res = await http.get<SiteInfoUpdateInfo>(url)
+  return res
+}
+
+// POST 请求更新站点信息表
+export const updateSiteInfo = async () => {
+  const url = API_List.siteInfoUpdate
+  await http.post(url)
+}
+
+// POST 导出站点信息表(xlsx)
+export const exportSiteInfoManageList = async (config: { filter?: SiteInfoFilter }) => {
+  const url = API_List.siteInfoListExport
+  const res = await http.download(url, undefined, undefined, config)
+  return res
+}
+
+// GET 获取单个站点实时信息(同时更新单个站点数据)
+export const getSiteInfoData = async (sn: string) => {
+  const url = API_List.siteInfoDataQuery
+  const res = await http.get<SiteInfo>(url, { params: { sn } })
+  return res
+}
+
+// GET 获取站点信息配置
+export const getSiteInfoConfig = async () => {
+  const url = API_List.siteInfoConfig
+  const res = await http.get<SiteInfoConfig | null>(url)
+  return res
+}
+
+// POST 更新站点信息配置
+export const updateSiteInfoConfig = async (config: SiteInfoConfig) => {
+  const url = API_List.siteInfoConfig
+  const res = await http.post(url, config)
+}
+
+// POST 更新站点信息TitleFilter配置
+export const updateSiteInfoConfigTitleFilter = async (config: string[]) => {
+  const url = API_List.siteInfoConfigTitleFilter
+  const res = await http.post(url, config)
+}
+
+// POST 更新站点信息CustomQueryFilter配置
+export const updateSiteInfoConfigCustomQueryFilter = async (config: SiteInfoQueryFilter[]) => {
+  const url = API_List.siteInfoConfigCustomQueryFilter
+  const res = await http.post(url, config)
+}
+
+// POST 使能站点xlog
+export const operateSiteEnableXlog = async (sn: string): Promise<string> => {
+  const url = API_List.siteOperateEnableXlog
+  const res = await http.post<string>(url, sn)
+  return res
+}

+ 114 - 0
src/service/siteInfoManage/interface.ts

@@ -0,0 +1,114 @@
+import type { ResPage } from '../interface'
+
+export interface SiteInfoUpdateInfo {
+  id: string
+  progress: 'pending' | 'running' | 'error' | 'complete'
+  createTime: string
+  updateTime: string
+  total: number
+}
+
+export interface SiteInfo {
+  sn: string
+  name?: string
+  distributorName?: string // 经销商名称
+  installerAccount?: string // 安装商账号
+  installerName?: string // 安装商名称
+  maintName?: string // 维护商名称
+  installTime?: string // 安装时间 YYYY-MM-DD HH:mm:SS
+  activeStatus: number // 激活状态 0-未激活 1-激活
+  activeTime: string // 激活时间(北京时间)
+  localActiveTime: string // 激活时间(站点当地时间)
+  status: number // 网络状态 0-离线 1-在线
+  onLineTime?: string // 最后上线时间 YYYY-MM-DD HH:mm:SS
+  offLineTime?: string // 最后离线时间 YYYY-MM-DD HH:mm:SS
+  signal?: string // 信号强度
+  iccId: string
+  firmUpgradeTime?: string // 固件最后更新时间 YYYY-MM-DD HH:mm:SS
+  batterySoc?: number // 系统电池SOC
+  runStatus: number // 设备运行状态 0-待机 1-充电 2-放电 3-故障
+  emsModel?: number // EMS调度运行模式(策略) 参考调度ID
+  electricityType: number // 电网类型 0-离网运行 1-并网运行
+  fhpSn: string // aPower SN 多设备用,分割
+  timeZone: number // 时区数值
+  version: string // 设备固件版本
+  slVersion: string // EMS-DSP版本
+  slVersionJson: string // EMS-DSP版本详细信息JSON
+  meterVersion?: string // 电表版本
+  mbVersion?: string // MODBUS版本
+  awsVersion?: string // AWS版本
+  appVersion?: string // APP版本(与APP通讯的模块软件)
+  elecnetStatus: number // 电网状态 0-离网 1-并网
+  slaverStat: number // EMS-DSP状态
+  dst?: number // 夏令时 0-否 1-是
+  zoneInfo: string // 时区信息 America/Los_Angeles
+  deviceTime?: string // 设备时间 YYYY-MM-DD HH:mm:SS
+  protocolVer: string // 协议版本
+  sysHdVersion: number // aGate硬件版本
+  realSysHdVersion?: string // aGate系统版本名称
+  wifiSignal: string // WIFI信号
+  connType: number // 网络连接类型 0-未连接 1-eth0 2-eth1 3-WIFI 4-4G
+  financierName?: string // 大客户名称
+  remark?: string // 备注信息
+  genEn: number // 发电机是否使能 0-不使能 1-使能
+  country: string // 国家
+  province: string // 州/省
+  city: string // 城市
+  emsDeviceVer?: number // EMS版本
+  site?: string // 站点ID名
+  siteId?: number // 站点ID号
+  jobCode?: string // Job Code (Sunrun相关)
+}
+
+export type SiteInfoPage = ResPage<SiteInfo[]>
+
+// 过滤类型定义
+export enum SiteInfoFilterType {
+  FilterUnknown = 0, // 0 - 未知类型
+  FilterEqualInt, // 1 - 等于指定值(整数)
+  FilterEqualFloat, // 2 - 等于指定值(浮点数)
+  FilterEqualString, // 3 - 等于指定字符串
+  FilterIncludeString, // 4 - 包含指定字符串
+  FilterRangeInt, // 5 - 数值范围检索(整数)
+  FilterRangeFloat, // 6 - 数值范围检索(浮点数)
+  FilterRangeTime, // 7 - 数值范围检索(时间)
+  FilterExists, // 8 - 判断字段是否存在或不为空
+}
+
+type FilterLogicType = 'and' | 'or'
+
+export interface SiteInfoFilterConfig {
+  id: string
+  key: string // 过滤的关键字
+  type: SiteInfoFilterType // 过滤类型
+  para1: string | number // 参数1
+  para2?: string | number // 参数2
+}
+
+export interface SiteInfoSubFilter {
+  id: string
+  logic: FilterLogicType
+  config: SiteInfoFilterConfig[]
+}
+
+export interface SiteInfoFilter {
+  logic: FilterLogicType
+  subFilter: SiteInfoSubFilter[]
+}
+
+export interface SiteInfoRequest {
+  search: string
+  filter?: SiteInfoFilter
+  pageSize: number
+  pageNum: number
+}
+
+export interface SiteInfoConfig {
+  titleFilter?: string[]
+  customQueryFilter?: SiteInfoQueryFilter[]
+}
+
+export interface SiteInfoQueryFilter {
+  name: string
+  filter: SiteInfoFilter
+}

+ 11 - 6
src/service/taskQueue.ts

@@ -46,32 +46,35 @@ export class TaskQueue {
     if (this.activeCount >= this.maxConcurrent) return
     const item = this.queue.shift()
     if (!item) return
-    if (item.key)
+    if (item.key) {
       this.resultCache.set(item.key, {
         state: 'pending',
         result: undefined,
         updateTime: 0,
       })
+    }
 
     this.activeCount++
     const { task, resolve, reject, key } = item
     task()
       .then((result) => {
-        if (key)
+        if (key) {
           this.resultCache.set(key, {
             state: 'fulfilled',
             result,
             updateTime: Date.now(),
-          }) // 缓存结果
+          })
+        } // 缓存结果
         resolve(result)
       })
       .catch((err) => {
-        if (key)
+        if (key) {
           this.resultCache.set(key, {
             state: 'rejected',
             result: err,
             updateTime: 0,
-          }) // 缓存结果
+          })
+        } // 缓存结果
         reject(err)
       })
       .finally(() => {
@@ -127,8 +130,10 @@ export class TaskQueue {
               reject(new Error(`Task with key=${key} cache failure`))
             } else if (resultCache.state === 'fulfilled') {
               resolve(resultCache.result)
+            } else if (resultCache.state === 'pending') {
+              reject(new Error(`Task with key=${key} result error (state === 'pending')`))
             } else {
-              reject(resultCache.result as Error)
+              reject(resultCache.result)
             }
           })
         }

+ 1 - 0
src/service/xlog/index.ts

@@ -22,6 +22,7 @@ export const getManageList = async (
       pageNum,
       sn: filter ? filter.sn : undefined,
       uuid: filter ? filter.uuid : undefined,
+      version: filter ? filter.version : undefined,
     },
   })
   return res

+ 2 - 0
src/service/xlog/interface.ts

@@ -3,11 +3,13 @@ import type { ResPage } from '../interface'
 export interface XlogSiteItemInfoFilter {
   sn?: string
   uuid?: string
+  version?: string
 }
 
 export interface XlogSiteItemInfo {
   sn: string
   uuid: string
+  version: string
 }
 
 export interface XlogSiteItemInfoPage extends ResPage<XlogSiteItemInfo[]> {}

+ 47 - 17
src/stores/initStores.ts

@@ -1,43 +1,73 @@
-import type { CheckVersionFn, WithVersionedStore } from '@/stores/public/template'
 import type {
+  CheckVersionFn,
+  WithVersionedStore,
+  VersionedStateTree,
+  VersionedActions,
+} from '@/stores/public/template'
+import type {
+  Store,
   StoreDefinition,
   SetupStoreDefinition,
   StateTree,
   _GettersTree,
   _ActionsTree,
+  StoreOnActionListener,
 } from 'pinia'
 
 // 引入Store用于初始化
 import { useSiteInfoDataStore } from '@/stores/modules/data/siteInfoData'
 import { useSiteModeDataStore } from '@/stores/modules/data/siteModeData'
 import { useSitePowerFlowDataStore } from '@/stores/modules/data/sitePowerFlowData'
+import { useSiteAlarmListDataStore } from '@/stores/modules/data/siteAlarmList'
 import { useSitesMonitorStateStore } from './modules/views/sitesMonitorState'
 import { useSitesMonitorConfigStore } from './modules/views/sitesMonitorConfig'
+import { useSiteRuntimeChartConfigStore } from './modules/views/siteRuntimeChartConfig'
+import { useXlogViewsStateStore } from './modules/views/xlogViewsState'
+import { useSiteInfoManageStateStore } from './modules/views/siteInfoManageState'
 
 // 全局注册表
-type StoreDefinitionFn =
-  | StoreDefinition<
-      string,
-      StateTree & { __version: string },
-      _GettersTree<StateTree & { __version: string }>,
-      _ActionsTree & CheckVersionFn
-    >
-  | SetupStoreDefinition<string, WithVersionedStore<Record<string, any>>>
+// type StoreDefinitionFn =
+//   | StoreDefinition<
+//       string,
+//       StateTree & { __version: string },
+//       _GettersTree<StateTree & { __version: string }>,
+//       _ActionsTree & CheckVersionFn
+//     >
+//   | SetupStoreDefinition<string, WithVersionedStore<any>>
 
 export const InitStores = () => {
-  InitOneStore(useSiteInfoDataStore)
-  InitOneStore(useSiteModeDataStore)
-  InitOneStore(useSitePowerFlowDataStore)
-  InitOneStore(useSitesMonitorStateStore)
-  InitOneStore(useSitesMonitorConfigStore)
+  InitOneStore(useSiteInfoDataStore())
+  InitOneStore(useSiteModeDataStore())
+  InitOneStore(useSitePowerFlowDataStore())
+  InitOneStore(useSiteAlarmListDataStore())
+  InitOneStore(useSitesMonitorStateStore())
+  InitOneStore(useSitesMonitorConfigStore())
+  InitOneStore(useSiteRuntimeChartConfigStore())
+  InitOneStore(useXlogViewsStateStore())
+  InitOneStore(useSiteInfoManageStateStore())
+
+  // 监听登录事件,重置Store
+  globalThis.AppEvents.on('user:login', () => {
+    const siteInfoManageStateStore = useSiteInfoManageStateStore()
+    siteInfoManageStateStore.$reset()
+  })
 }
 
-const InitOneStore = (useStore: StoreDefinitionFn) => {
-  const store = useStore()
+// const InitOneStore = (useStore: StoreDefinitionFn) => {
+//   const store = useStore()
+//   if (!store.__checkVersion()) {
+//     // 清理旧数据
+//     store.$reset()
+//     // 更新版本号
+//     store.__updateVersion()
+//   }
+// }
+
+const InitOneStore = (store: WithVersionedStore<{ $reset: () => void }>) => {
   if (!store.__checkVersion()) {
     // 清理旧数据
     store.$reset()
     // 更新版本号
-    store.updateVersion()
+    store.__updateVersion()
   }
 }

+ 1 - 1
src/stores/modules/data/siteModeData.ts

@@ -17,7 +17,7 @@ export interface SiteModeDataSet {
 
 export const useSiteModeDataStore = defineSetupStore(STORES_KEY, CURRENT_VERSION, () => {
   // 站点模式数据
-  let siteModeData: SiteModeDataSet = {}
+  const siteModeData: SiteModeDataSet = {}
 
   // 获取某个站点的工作模式信息
   function siteModeInfoGet(sn: string): SiteModeInfo | undefined {

+ 120 - 0
src/stores/modules/views/siteInfoManageState.ts

@@ -0,0 +1,120 @@
+import { defineSetupStore } from '@/stores/public/template'
+import type { SiteInfo, SiteInfoFilter } from '@/service/siteInfoManage/interface'
+import {
+  getSiteInfoConfig,
+  updateSiteInfoConfigTitleFilter,
+  updateSiteInfoConfigCustomQueryFilter,
+} from '@/service/siteInfoManage'
+
+const STORES_KEY = 'SiteInfoManageStateStore'
+const CURRENT_VERSION = '0.0.0'
+
+const defaultTitleSelectOption: (keyof SiteInfo)[] = [
+  'sn', // SN
+  'name',
+  'timeZone',
+  'slVersion',
+  'meterVersion',
+  'electricityType', // 电网类型
+  'connType',
+  'signal', // 信号
+  'wifiSignal', // WIFI信号
+  'iccId', // ICCID
+  'version', // 固件版本
+  'remark', // 备注
+]
+
+interface CustomSiteInfoFilter {
+  name: string
+  filter: SiteInfoFilter
+}
+
+interface SiteInfoStateSet {
+  titleFilter: Ref<string[]> // 站点信息列配置
+  queryFilter: Ref<SiteInfoFilter> // 当前查询过滤器
+  customQueryFilter: Ref<CustomSiteInfoFilter[]> // 自定义查询过滤器
+}
+
+export const useSiteInfoManageStateStore = defineSetupStore(STORES_KEY, CURRENT_VERSION, () => {
+  const siteInfoState: SiteInfoStateSet = {
+    titleFilter: ref<string[]>(defaultTitleSelectOption),
+    queryFilter: ref<SiteInfoFilter>({
+      logic: 'or',
+      subFilter: [],
+    }),
+    customQueryFilter: ref<CustomSiteInfoFilter[]>([]),
+  }
+
+  const getTitleFilterRef = () => {
+    return siteInfoState.titleFilter
+  }
+
+  const getQueryFilterRef = () => {
+    return siteInfoState.queryFilter
+  }
+
+  const getCustomQueryFilterRef = () => {
+    return siteInfoState.customQueryFilter
+  }
+
+  const titleFilterComputed = computed(() => {
+    return siteInfoState.titleFilter.value
+  })
+
+  const queryFilterComputed = computed(() => {
+    return siteInfoState.queryFilter.value
+  })
+
+  const customQueryFilterComputed = computed(() => {
+    return siteInfoState.customQueryFilter.value
+  })
+
+  const setTitleFilter = async (config: string[]) => {
+    siteInfoState.titleFilter.value = config
+    await updateSiteInfoConfigTitleFilter(config)
+  }
+
+  const setQueryFilter = (filter: SiteInfoFilter) => {
+    siteInfoState.queryFilter.value = filter
+  }
+
+  const setCustomQueryFilter = async (config: CustomSiteInfoFilter[]) => {
+    siteInfoState.customQueryFilter.value = config
+    await updateSiteInfoConfigCustomQueryFilter(config)
+  }
+
+  // 查询用户配置
+  const queryConfig = async () => {
+    const config = await getSiteInfoConfig()
+    if (config != null) {
+      if (config.titleFilter !== undefined) {
+        siteInfoState.titleFilter.value = config.titleFilter
+      }
+      siteInfoState.customQueryFilter.value = config.customQueryFilter ?? []
+    }
+  }
+
+  return {
+    // normal state
+    siteInfoState,
+
+    // reactive state - ref() 建议不要使用ref
+    getTitleFilterRef,
+    getQueryFilterRef,
+    getCustomQueryFilterRef,
+
+    // reactive getters - computed()
+    titleFilterComputed,
+    queryFilterComputed,
+    customQueryFilterComputed,
+
+    // normal getters - function()
+
+    // actions - function()
+    setTitleFilter,
+    setQueryFilter,
+    setCustomQueryFilter,
+
+    queryConfig, // 查询用户配置
+  }
+})

+ 67 - 0
src/stores/modules/views/xlogViewsState.ts

@@ -0,0 +1,67 @@
+import { defineSetupStore } from '@/stores/public/template'
+
+const STORES_KEY = 'XlogViewsStateStore'
+const CURRENT_VERSION = '0.0.0'
+
+export interface XlogCmdListData {
+  recv: string
+  send: string
+  cmdType: string
+}
+
+export interface XlogViewsStateData {
+  cmdList?: Ref<XlogCmdListData[]> // 命令列表
+}
+
+export interface XlogViewsStateSet {
+  [uuid: string]: XlogViewsStateData
+}
+
+export const useXlogViewsStateStore = defineSetupStore(STORES_KEY, CURRENT_VERSION, () => {
+  const xlogViewsState: XlogViewsStateSet = {}
+
+  // 初始化(懒创建)
+  function ensure(uuid: string): Required<XlogViewsStateData> {
+    xlogViewsState[uuid] ??= {}
+    xlogViewsState[uuid].cmdList ??= ref<XlogCmdListData[]>([])
+    return {
+      cmdList: xlogViewsState[uuid].cmdList,
+    }
+  }
+
+  // 获取某个UUID页面命令数据列表响应式
+  const getCmdListRef = (uuid: string): Ref<XlogCmdListData[]> => {
+    const stateData = ensure(uuid)
+    return stateData.cmdList
+  }
+
+  // 获取某个UUID页面命令数据列表
+  const cmdListGet = (uuid: string): Ref<XlogCmdListData[]> => {
+    const stateData = ensure(uuid)
+    return computed(() => {
+      return stateData.cmdList.value
+    })
+  }
+
+  // 设置某个UUID页面命令数据列表
+  function setCmdListDate(uuid: string, date: XlogCmdListData[]) {
+    const stateData = ensure(uuid)
+    stateData.cmdList.value = date
+  }
+
+  return {
+    // normal state
+    xlogViewsState,
+
+    // reactive state - ref()
+    getCmdListRef,
+
+    // reactive getters - computed()
+    cmdListGet,
+
+    // normal getters - function()
+
+    // actions - function()
+    setCmdListDate,
+  }
+})

+ 17 - 15
src/stores/public/template.ts

@@ -29,26 +29,26 @@ const storeSetupTemp = (version: string) => {
   }
 }
 
-type StoreStateTemp<S extends object> = S & {
+type VersionedStateTree<S extends object> = S & {
   __version: string
 }
 
 interface CheckVersionFn {
-  __checkVersion(): boolean
-  __updateVersion(): void
+  __checkVersion: () => boolean
+  __updateVersion: () => void
 }
 
-type VersionedActions<A> = A & CheckVersionFn
+type VersionedActions<A extends object> = A & CheckVersionFn
 
 const storeOptionsTemp = <
   Id extends string,
-  S extends Omit<StateTree, '__version'> = {},
-  G extends _GettersTree<S> = {},
-  A extends Omit<object, '__checkVersion' | '__updateVersion'> = {},
+  S extends Omit<StateTree, '__version'> = object,
+  G extends _GettersTree<S> = Record<string, never>,
+  A extends Omit<object, '__checkVersion' | '__updateVersion'> = object,
 >(
   options: Omit<DefineStoreOptions<Id, S, G, A>, 'id'>,
   version: string,
-): Omit<DefineStoreOptions<Id, StoreStateTemp<S>, G, VersionedActions<A>>, 'id'> => {
+): Omit<DefineStoreOptions<Id, VersionedStateTree<S>, G, VersionedActions<A>>, 'id'> => {
   return {
     ...options,
     state: () => ({
@@ -80,15 +80,15 @@ type SetupStoreHelpers = StoreSetup extends (helpers: infer S) => any ? S : neve
 
 type ExtStateTree<S extends Omit<S, '__version'>> = S & { __version: string }
 type ExtActionsTree<A extends Omit<A, '__checkVersion' | '__updateVersion'>> = A & {
-  __checkVersion(): boolean
-  __updateVersion(): void
+  __checkVersion: () => boolean
+  __updateVersion: () => void
 }
 
 function defineOptionsStore<
   Id extends string,
-  S extends Omit<StateTree, '__version'> = {},
-  G extends _GettersTree<S> = {},
-  A extends Omit<_ActionsTree, '__checkVersion' | '__updateVersion'> = {},
+  S extends Omit<StateTree, '__version'> = object,
+  G extends _GettersTree<S> = Record<string, never>,
+  A extends Omit<_ActionsTree, '__checkVersion' | '__updateVersion'> = Record<string, never>,
 >(
   id: Id,
   version: string,
@@ -118,8 +118,8 @@ function defineOptionsStore<
 // 定义一个扩展后的类型,包含 __version, __checkVersion, __updateVersion
 type WithVersionedStore<SS> = SS & {
   __version: string
-  __checkVersion(): boolean
-  __updateVersion(): void
+  __checkVersion: () => boolean
+  __updateVersion: () => void
 }
 
 function defineSetupStore<Id extends string, SS>(
@@ -157,6 +157,8 @@ function defineSetupStore<Id extends string, SS>(
 export {
   type CheckVersionFn,
   type WithVersionedStore,
+  type VersionedStateTree,
+  type VersionedActions,
   storeSetupTemp,
   storeOptionsTemp,
   defineOptionsStore,

+ 52 - 69
src/stores/user.ts

@@ -9,7 +9,7 @@ import { routeRecordRaw } from '@/router/record'
 import type { MergeMenuMixedOptions } from '@/router/helper'
 import type { MenuProps } from 'naive-ui'
 import type { RouteRecordRaw } from 'vue-router'
-import { jwtDecode } from 'jwt-decode'
+import { parseJwtToken, type JwtPayload } from '@/utils/casdoor/token'
 
 interface User {
   avatar?: string
@@ -25,45 +25,17 @@ const DEFAULT_USER: User = {
   role: 'admin',
 }
 
-interface JwtPayload {
-  iss: string
-  id: string
-  sub: string
-  aud: string[] | string
-  name: string
-  displayName: string
-  nbf: number
-  iat: number
-  jti: string
-  exp: number // 过期时间
-  isAdmin: boolean
-  isDeleted: boolean
-  isForbidden: boolean
-  permissions: JwtPayloadPermission[]
-  roles: JwtPayloadRole[]
-}
-
-interface JwtPayloadPermission {
-  name: string
-  displayName: string
-  description: string
-  effect: string
-  isEnabled: boolean
-  state: string
-}
-
-interface JwtPayloadRole {
-  name: string
-  displayName: string
-  description: string
-  isEnabled: string
-  domains: string[]
-}
-
 export const useUserStore = defineStore('userStore', () => {
   const user = useStorage<User>('user', DEFAULT_USER)
 
-  const token = useStorage<string | undefined>('token', '')
+  const token = useStorage<string | undefined>('token', undefined)
+
+  const tokenPayload = useStorage<JwtPayload | undefined>('tokenPayload', undefined, undefined, {
+    serializer: {
+      read: (v: string) => (v ? (v !== '' ? JSON.parse(v) : undefined) : undefined),
+      write: (v: JwtPayload | undefined) => (v ? JSON.stringify(v) : ''),
+    },
+  })
 
   const menuList = ref<MenuProps['options']>([])
 
@@ -71,41 +43,51 @@ export const useUserStore = defineStore('userStore', () => {
 
   const setToken = (value: string | undefined) => {
     token.value = value
+    if (value !== undefined) {
+      const jwtData = parseJwtToken(value)
+      console.log({ jwtData, jwtDataStr: JSON.stringify(jwtData) })
+      tokenPayload.value = jwtData
+    } else {
+      tokenPayload.value = undefined
+    }
   }
 
   async function resolveMenuList() {
     const res = await new Promise<MergeMenuMixedOptions[]>((resolve) => {
-      if (token.value !== undefined) {
-        const payload = jwtDecode<JwtPayload>(token.value)
-        if (payload.isAdmin) {
-          resolve(routeRecordRaw)
-        } else {
-          const allowedRoutes = payload.roles.map((role) => role.domains).flat()
-          const filterRoutes = (routeRecord: MergeMenuMixedOptions[]) => {
-            return routeRecord
-              .map((route): MergeMenuMixedOptions | undefined => {
-                const selfAllowed =
-                  !route.type && route.name && allowedRoutes.includes(route.name as string)
-                if (selfAllowed) return route
-
-                // 先递归处理 children
-                if (!('children' in route) || route.children === undefined) {
-                  return undefined
-                }
-                const result = { ...route }
-                result.children = filterRoutes(route.children)
-                return result
-              })
-              .filter((item): item is MergeMenuMixedOptions => Boolean(item))
-          }
-          // const filteredRoutes = routeRecordRaw.filter((route) => {
-          //   return !route.type && route.name && allowedRoutes.includes(route.name as string)
-          // })
-          resolve(filterRoutes(routeRecordRaw))
-        }
-      } else {
-        resolve([])
-      }
+      // TODO 暂时不限制权限
+      resolve(routeRecordRaw)
+      // if (tokenPayload.value !== undefined) {
+      //   if (tokenPayload.value.isAdmin) {
+      //     resolve(routeRecordRaw)
+      //   } else {
+      //     const allowedRoutes = tokenPayload.value.roles.map((role) => role.domains).flat()
+      //     const filterRoutes = (routeRecord: MergeMenuMixedOptions[]) => {
+      //       return routeRecord
+      //         .map((route): MergeMenuMixedOptions | undefined => {
+      //           const selfAllowed =
+      //             !route.type && route.name && allowedRoutes.includes(route.name as string)
+      //           if (selfAllowed) return route
+
+      //           // 先递归处理 children
+      //           if (!('children' in route) || route.children === undefined) {
+      //             return undefined
+      //           }
+      //           const result = { ...route }
+      //           result.children = filterRoutes(route.children)
+      //           return result
+      //         })
+      //         .filter((item): item is MergeMenuMixedOptions => Boolean(item))
+      //     }
+      //     // const filteredRoutes = routeRecordRaw.filter((route) => {
+      //     //   return !route.type && route.name && allowedRoutes.includes(route.name as string)
+      //     // })
+      //     resolve(filterRoutes(routeRecordRaw))
+      //   }
+      // } else {
+      //   cleanup()
+      //   resolve([])
+      // }
+
       // if (token.value?.includes('admin')) {
       //   resolve(routeRecordRaw)
       // } else {
@@ -129,7 +111,7 @@ export const useUserStore = defineStore('userStore', () => {
       ...(redirectPath ? { query: { r: redirectPath } } : {}),
     })
 
-    token.value = null
+    token.value = undefined
 
     if (router.hasRoute('layout')) {
       router.removeRoute('layout')
@@ -143,6 +125,7 @@ export const useUserStore = defineStore('userStore', () => {
   return {
     user,
     token,
+    tokenPayload,
     menuList,
     routeList,
     resolveMenuList,

+ 0 - 46
src/types/components.d.ts

@@ -1,46 +0,0 @@
-/* eslint-disable */
-// @ts-nocheck
-// Generated by unplugin-vue-components
-// Read more: https://github.com/vuejs/core/pull/3399
-// biome-ignore lint: disable
-export {}
-
-/* prettier-ignore */
-declare module 'vue' {
-  export interface GlobalComponents {
-    Avatar: typeof import('./../components/Avatar.vue')['default']
-    ButtonAnimation: typeof import('./../components/ButtonAnimation.vue')['default']
-    EmptyPlaceholder: typeof import('./../components/EmptyPlaceholder.vue')['default']
-    HintHelp: typeof import('./../components/HintHelp.vue')['default']
-    NAlert: typeof import('naive-ui')['NAlert']
-    NAutoComplete: typeof import('naive-ui')['NAutoComplete']
-    NButton: typeof import('naive-ui')['NButton']
-    NButtonGroup: typeof import('naive-ui')['NButtonGroup']
-    NCard: typeof import('naive-ui')['NCard']
-    NCol: typeof import('naive-ui')['NCol']
-    NCollapse: typeof import('naive-ui')['NCollapse']
-    NCollapseItem: typeof import('naive-ui')['NCollapseItem']
-    NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
-    NDivider: typeof import('naive-ui')['NDivider']
-    NDropdown: typeof import('naive-ui')['NDropdown']
-    NEllipsis: typeof import('naive-ui')['NEllipsis']
-    NFormItem: typeof import('naive-ui')['NFormItem']
-    NInput: typeof import('naive-ui')['NInput']
-    Noise: typeof import('./../components/Noise.vue')['default']
-    NPagination: typeof import('naive-ui')['NPagination']
-    NPerformantEllipsis: typeof import('naive-ui')['NPerformantEllipsis']
-    NPopover: typeof import('naive-ui')['NPopover']
-    NResult: typeof import('naive-ui')['NResult']
-    NRow: typeof import('naive-ui')['NRow']
-    NSelect: typeof import('naive-ui')['NSelect']
-    NSpace: typeof import('naive-ui')['NSpace']
-    NSpin: typeof import('naive-ui')['NSpin']
-    NStatistic: typeof import('naive-ui')['NStatistic']
-    NTag: typeof import('naive-ui')['NTag']
-    NThing: typeof import('naive-ui')['NThing']
-    NTimeline: typeof import('naive-ui')['NTimeline']
-    NTimelineItem: typeof import('naive-ui')['NTimelineItem']
-    RouterLink: typeof import('vue-router')['RouterLink']
-    RouterView: typeof import('vue-router')['RouterView']
-  }
-}

+ 8 - 0
src/types/events.d.ts

@@ -0,0 +1,8 @@
+// events.d.ts
+import type { EventEmitter } from '@/event/eventEmitter'
+import type { AppEvents } from '@/event/eventTypes'
+
+declare global {
+  /* eslint-disable-next-line vars-on-top */
+  var AppEvents: EventEmitter<AppEvents>
+}

+ 1 - 1
src/utils/casdoor/casdoor.ts

@@ -8,7 +8,7 @@ const OrganizationName = 'organization_franklinwh_toolbox'
 const RedirectPath = '/fwh-toolbox/casdoor/callback'
 
 export function setupCasdoor(app: App) {
-  app.use(casdoor, {
+  app.use(casdoor.default, {
     serverUrl: ServerUrl,
     clientId: ClientId,
     appName: AppName,

+ 40 - 0
src/utils/casdoor/token.ts

@@ -0,0 +1,40 @@
+import { jwtDecode } from 'jwt-decode'
+
+export interface JwtPayload {
+  iss: string
+  id: string
+  sub: string
+  aud: string[] | string
+  name: string
+  displayName: string
+  nbf: number
+  iat: number
+  jti: string
+  exp: number // 过期时间
+  isAdmin: boolean
+  isDeleted: boolean
+  isForbidden: boolean
+  permissions: JwtPayloadPermission[]
+  roles: JwtPayloadRole[]
+}
+
+export interface JwtPayloadPermission {
+  name: string
+  displayName: string
+  description: string
+  effect: string
+  isEnabled: boolean
+  state: string
+}
+
+export interface JwtPayloadRole {
+  name: string
+  displayName: string
+  description: string
+  isEnabled: string
+  domains: string[]
+}
+
+export const parseJwtToken = (token: string): JwtPayload => {
+  return jwtDecode<JwtPayload>(token)
+}

+ 228 - 0
src/views/site-info-manage/component/filterNodeComp/index.vue

@@ -0,0 +1,228 @@
+<template>
+  <div v-if="isFilterable(props.filterKey)" class="flex flex-1 min-w-100">
+    <NSelect
+      v-model:value="filterType"
+      class="w-28"
+      :options="generateFilterTypeSelectOption(props.filterKey)"
+      size="small"
+      @update:value="filterTypeUpdate"
+    />
+    <div v-if="filterType === SiteInfoFilterType.FilterEqualInt" class="flex flex-1">
+      <NInputNumber
+        :value="getParaToNumber(para1)"
+        button-placement="right"
+        size="small"
+        @update:value="
+          (value) => {
+            const originalValue = value ?? 0
+            para1 = Number(originalValue.toFixed(0)) // 不保留小数位
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterEqualFloat" class="flex flex-1">
+      <NInputNumber
+        :value="getParaToNumber(para1)"
+        size="small"
+        @update:value="
+          (value) => {
+            para1 = value ?? 0
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterEqualString" class="flex flex-1">
+      <NInput
+        :value="getParaToString(para1)"
+        size="small"
+        @update:value="
+          (value: string) => {
+            para1 = value
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterIncludeString" class="flex flex-1">
+      <NInput
+        :value="getParaToString(para1)"
+        size="small"
+        @update:value="
+          (value: string) => {
+            para1 = value
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterRangeInt" class="flex flex-1">
+      <NInputNumber
+        :value="getParaToNumber(para1)"
+        button-placement="right"
+        size="small"
+        @update:value="
+          (value) => {
+            const originalValue = value ?? 0
+            para1 = Number(originalValue.toFixed(0)) // 不保留小数位
+          }
+        "
+      />
+      <span>-</span>
+      <NInputNumber
+        :value="getParaToNumber(para2)"
+        button-placement="right"
+        size="small"
+        @update:value="
+          (value) => {
+            const originalValue = value ?? 0
+            para2 = Number(originalValue.toFixed(0)) // 不保留小数位
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterRangeFloat" class="flex flex-1">
+      <NInputNumber
+        :value="getParaToNumber(para1)"
+        size="small"
+        @update:value="
+          (value) => {
+            para1 = value ?? 0
+          }
+        "
+      />
+      <span>-</span>
+      <NInputNumber
+        :value="getParaToNumber(para2)"
+        size="small"
+        @update:value="
+          (value) => {
+            para2 = value ?? 0
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterRangeTime" class="flex flex-1">
+      <!-- TODO: 暂未确定后端是用string好还是number好 -->
+      <NDatePicker
+        :formatted-value="[getParaToString(para1), getParaToString(para2)]"
+        value-format="yyyy-MM-dd HH:mm:ss"
+        type="datetimerange"
+        clearable
+        size="small"
+        @confirm="
+          (value: [number, number] | null, formattedValue: [string, string] | null) => {
+            if (
+              formattedValue != null &&
+              Array.isArray(formattedValue) &&
+              formattedValue.length === 2
+            ) {
+              para1 = formattedValue[0]
+              para2 = formattedValue[1]
+            }
+          }
+        "
+      />
+    </div>
+    <div v-else-if="filterType === SiteInfoFilterType.FilterExists" class="flex flex-1">
+      <NSelect
+        v-model:value="para1"
+        :options="[
+          { label: '存在', value: 'yes' },
+          { label: '不存在', value: 'no' },
+        ]"
+        size="small"
+      />
+    </div>
+    <span v-else>请选择过滤类型</span>
+  </div>
+  <span v-else>不支持过滤</span>
+</template>
+
+<script setup lang="ts">
+import { NSelect, NInput, NInputNumber, NDatePicker } from 'naive-ui'
+import type { SelectOption } from 'naive-ui'
+import { SiteInfoFilterType } from '@/service/siteInfoManage/interface'
+import { getKeyFilterType, getKeyFilterTypeName } from '../../config'
+import dayjs from 'dayjs'
+
+const props = defineProps<{
+  filterKey: string
+}>()
+
+const filterType = defineModel<SiteInfoFilterType>('type', { required: true })
+const para1 = defineModel<string | number>('para1', { required: true })
+const para2 = defineModel<string | number>('para2')
+
+const isFilterable = (key: string): boolean => {
+  const filterTypes = getKeyFilterType(key)
+  return filterTypes !== undefined && filterTypes.length > 0
+}
+
+const generateFilterTypeSelectOption = (key: string): SelectOption[] | undefined => {
+  const filterTypes = getKeyFilterType(key)
+  if (filterTypes === undefined || filterTypes.length === 0) {
+    return undefined
+  } else {
+    const filterSelectOption: SelectOption[] = [
+      {
+        label: getKeyFilterTypeName(SiteInfoFilterType.FilterUnknown),
+        value: SiteInfoFilterType.FilterUnknown,
+        disabled: true,
+      },
+    ]
+    filterSelectOption.push(
+      ...filterTypes.map((item) => {
+        return {
+          label: getKeyFilterTypeName(item),
+          value: item,
+        }
+      }),
+    )
+    return filterSelectOption
+  }
+}
+
+const filterTypeUpdate = (type: SiteInfoFilterType | undefined) => {
+  if (type != null) {
+    const nowTime = dayjs()
+    switch (type) {
+      case SiteInfoFilterType.FilterUnknown:
+      case SiteInfoFilterType.FilterEqualString:
+      case SiteInfoFilterType.FilterIncludeString:
+        para1.value = ''
+        para2.value = undefined
+        break
+      case SiteInfoFilterType.FilterExists:
+        para1.value = 'yes'
+        para2.value = undefined
+        break
+      case SiteInfoFilterType.FilterEqualInt:
+      case SiteInfoFilterType.FilterEqualFloat:
+        para1.value = 0
+        para2.value = undefined
+        break
+      case SiteInfoFilterType.FilterRangeInt:
+      case SiteInfoFilterType.FilterRangeFloat:
+        para1.value = 0
+        para2.value = 0
+        break
+      case SiteInfoFilterType.FilterRangeTime:
+        para1.value = nowTime.startOf('day').format('YYYY-MM-DD HH:mm:ss')
+        para2.value = nowTime.add(1, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss')
+        break
+    }
+  }
+}
+
+const getParaToNumber = (para: string | number | undefined): number => {
+  if (para === undefined || typeof para === 'string') {
+    return 0
+  }
+  return para
+}
+
+const getParaToString = (para: string | number | undefined): string => {
+  if (para === undefined || typeof para === 'number') {
+    return ''
+  }
+  return para
+}
+</script>

+ 64 - 0
src/views/site-info-manage/component/siteInfoFields.vue

@@ -0,0 +1,64 @@
+<template>
+  <div ref="transferContainerRef">
+    <NTransfer
+      :style="{ height: `${transferHeight}px` }"
+      :value="fieldsValue"
+      virtual-scroll
+      :options="fieldsOptions"
+      source-filterable
+      @update:value="onFieldsValueUpdate"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { NTransfer } from 'naive-ui'
+import { getTitleOption } from '../config'
+
+const transferHeight = ref(300) // 默认高度
+const transferContainerRef = ref<HTMLElement | null>(null)
+const bottomMargin = 120 // 离底部预留 120px
+
+function updateMaxHeight() {
+  if (!transferContainerRef.value) return
+  const rect = transferContainerRef.value.getBoundingClientRect()
+  const distanceToTop = rect.top
+  const availableHeight = window.innerHeight - distanceToTop - bottomMargin
+  transferHeight.value = availableHeight
+}
+
+const fieldsValue = defineModel<string[]>('value')
+
+interface TransferOption {
+  label: string
+  value: string
+  disabled?: boolean
+}
+
+const fieldsOptions: TransferOption[] = getTitleOption().map((item) => {
+  return {
+    label: item.label,
+    value: item.value,
+  }
+})
+
+const onFieldsValueUpdate = (newValue: (string | number)[]) => {
+  // 按 options 的顺序排序
+  fieldsValue.value = fieldsOptions.map((o) => o.value).filter((k) => newValue.includes(k))
+}
+
+onMounted(() => {
+  // 更新控件高度
+  nextTick(() => {
+    setTimeout(() => {
+      updateMaxHeight()
+
+      window.addEventListener('resize', updateMaxHeight)
+    }, 100)
+  })
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateMaxHeight)
+})
+</script>

+ 439 - 0
src/views/site-info-manage/component/siteInfoFilter.vue

@@ -0,0 +1,439 @@
+<template>
+  <NList class="filter-compact-list min-w-600px" bordered>
+    <template #footer>
+      <div class="flex items-center">
+        <NButton text :focusable="false" @click="clickAddSubFilter">
+          <template #icon>
+            <span class="i-material-symbols:add-notes-outline-rounded" />
+          </template>
+          添加子过滤器
+        </NButton>
+        <NButton
+          v-show="siteInfoFilter.subFilter.length > 0"
+          class="ml-auto"
+          text
+          :focusable="false"
+          @click="clickCleanAllSubFilter"
+        >
+          <template #icon>
+            <span class="i-material-symbols:cleaning-services-outline-rounded" />
+          </template>
+          清空过滤器
+        </NButton>
+      </div>
+    </template>
+    <div ref="scrollbarContainerRef">
+      <NScrollbar :style="{ maxHeight: `${scrollbarHeight}px` }">
+        <div v-if="siteInfoFilter">
+          <NListItem
+            v-for="(subFilter, subIndex) of siteInfoFilter.subFilter"
+            :key="subFilter.id"
+            class="filter-compact-list-item !px-10px !py-4px"
+          >
+            <template #prefix>
+              <div class="flex w-18 items-center">
+                <span v-if="siteInfoFilter.subFilter.length > 0 && subIndex === 0" class="ml-auto"
+                  >Where</span
+                >
+                <span v-if="subIndex > 1" class="ml-auto">{{
+                  siteInfoFilter.logic.toLocaleUpperCase()
+                }}</span>
+                <NSwitch
+                  v-if="subIndex === 1"
+                  v-model:value="siteInfoFilter.logic"
+                  class="ml-auto"
+                  unchecked-value="or"
+                  checked-value="and"
+                  :rail-style="andOrRailStyle"
+                  size="small"
+                >
+                  <template #checked> AND </template>
+                  <template #unchecked> OR </template>
+                  <template #checked-icon>
+                    <span class="i-mdi:set-and" />
+                  </template>
+                  <template #unchecked-icon>
+                    <span class="i-mdi:set-or" />
+                  </template>
+                </NSwitch>
+              </div>
+            </template>
+            <template #suffix>
+              <NButton text :focusable="false" @click="() => clickDeleteSubFilter(subIndex)">
+                <template #icon>
+                  <span class="i-material-symbols:delete-outline-rounded" />
+                </template>
+              </NButton>
+            </template>
+            <NList class="filter-compact-list bg-gray-100" bordered>
+              <template #footer>
+                <div class="flex items-center">
+                  <NButton text :focusable="false" @click="() => clickAddFilterConfig(subIndex)">
+                    <template #icon>
+                      <span class="i-material-symbols:add-box-outline-rounded" />
+                    </template>
+                    添加过滤配置
+                  </NButton>
+                  <NButton
+                    v-show="subFilter.config.length > 0"
+                    class="ml-auto"
+                    text
+                    :focusable="false"
+                    @click="() => clickCleanAllFilterConfig(subIndex)"
+                  >
+                    <template #icon>
+                      <span class="i-material-symbols:delete-sweep-outline-rounded" />
+                    </template>
+                    清空配置
+                  </NButton>
+                </div>
+              </template>
+              <NListItem
+                v-for="(filterConfig, configIndex) of subFilter.config"
+                :key="filterConfig.id"
+                class="filter-compact-list-item flex"
+              >
+                <template #prefix>
+                  <div class="flex w-18 items-center">
+                    <span v-if="subFilter.config.length > 0 && configIndex === 0" class="ml-auto"
+                      >Where</span
+                    >
+                    <span v-if="configIndex > 1" class="ml-auto">{{
+                      subFilter.logic.toLocaleUpperCase()
+                    }}</span>
+                    <NSwitch
+                      v-if="configIndex === 1"
+                      v-model:value="subFilter.logic"
+                      unchecked-value="or"
+                      checked-value="and"
+                      :rail-style="andOrRailStyle"
+                      size="small"
+                    >
+                      <template #checked> AND </template>
+                      <template #unchecked> OR </template>
+                      <template #checked-icon>
+                        <span class="i-mdi:set-and" />
+                      </template>
+                      <template #unchecked-icon>
+                        <span class="i-mdi:set-or" />
+                      </template>
+                    </NSwitch>
+                  </div>
+                </template>
+                <template #suffix>
+                  <NButton
+                    text
+                    :focusable="false"
+                    @click="() => clickDeleteFilterConfig(subIndex, configIndex)"
+                  >
+                    <template #icon>
+                      <span class="i-material-symbols:delete-outline-rounded" />
+                    </template>
+                  </NButton>
+                </template>
+                <div class="flex">
+                  <NSelect
+                    v-model:value="filterConfig.key"
+                    class="w-30"
+                    :options="generateKeySelectOption"
+                    filterable
+                    size="small"
+                  />
+                  <!-- key相关的过滤配置 -->
+                  <FilterNodeComp
+                    v-model:type="filterConfig.type"
+                    v-model:para1="filterConfig.para1"
+                    v-model:para2="filterConfig.para2"
+                    :filter-key="filterConfig.key"
+                  />
+                </div>
+              </NListItem>
+            </NList>
+          </NListItem>
+        </div>
+      </NScrollbar>
+    </div>
+  </NList>
+</template>
+
+<script setup lang="ts">
+import type { CSSProperties } from 'vue'
+import {
+  NButton,
+  NSwitch,
+  NList,
+  NListItem,
+  NScrollbar,
+  NSelect,
+  NInput,
+  NInputNumber,
+  NDatePicker,
+} from 'naive-ui'
+import type { SelectOption } from 'naive-ui'
+import { SiteInfoFilterType } from '@/service/siteInfoManage/interface'
+import type { SiteInfoFilter } from '@/service/siteInfoManage/interface'
+import { getTitleOption, getKeyFilterType, getKeyFilterTypeName } from '../config'
+import FilterNodeComp from './filterNodeComp/index.vue'
+
+const props = defineProps<{
+  initValue: SiteInfoFilter
+}>()
+const emit = defineEmits<(e: 'update:value', value: SiteInfoFilter) => void>()
+// const modelVal = defineModel<SiteInfoFilter>('value', { required: true })
+
+const scrollbarHeight = ref(300) // 默认高度
+const scrollbarContainerRef = ref<HTMLElement | null>(null)
+const bottomMargin = 120 // 离底部预留 120px
+
+function updateMaxHeight() {
+  if (!scrollbarContainerRef.value) return
+  const rect = scrollbarContainerRef.value.getBoundingClientRect()
+  const distanceToTop = rect.top
+  const availableHeight = window.innerHeight - distanceToTop - bottomMargin
+  scrollbarHeight.value = availableHeight
+}
+
+// 当前组件不适合用Model,因为Filter必须使用reactive
+// const siteInfoFilter = defineModel<SiteInfoFilter>('value')
+
+// const siteInfoFilter = reactive<SiteInfoFilter>({
+//   logic: 'or',
+//   subFilter: [],
+// })
+const siteInfoFilter = reactive<SiteInfoFilter>(props.initValue)
+
+// const siteInfoFilter = reactive<SiteInfoFilter>({
+//   logic: 'or',
+//   subFilter: [
+//     {
+//       id: crypto.randomUUID(),
+//       logic: 'and',
+//       config: [
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'sn',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'name',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'iccId',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         }
+//       ]
+//     },
+//     {
+//       id: crypto.randomUUID(),
+//       logic: 'and',
+//       config: [
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'sn',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'name',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'iccId',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         }
+//       ]
+//     },
+//     {
+//       id: crypto.randomUUID(),
+//       logic: 'and',
+//       config: [
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'sn',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'name',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         },
+//         {
+//           id: crypto.randomUUID(),
+//           key: 'iccId',
+//           type: SiteInfoFilterType.FilterEqualString,
+//           para1: ""
+//         }
+//       ]
+//     }
+//   ]
+// })
+
+function andOrRailStyle({ focused, checked }: { focused: boolean; checked: boolean }) {
+  const style: CSSProperties = {}
+  if (checked) {
+    // 绿色
+    style.background = '#18a058'
+    if (focused) {
+      style.boxShadow = '0 0 0 2px #18a05840'
+    }
+  } else {
+    // 蓝色
+    style.background = '#2080f0'
+    if (focused) {
+      style.boxShadow = '0 0 0 2px #2080f040'
+    }
+  }
+  return style
+}
+
+const generateKeySelectOption: SelectOption[] = getTitleOption().map((item) => {
+  return {
+    label: item.label,
+    value: item.value,
+  }
+})
+
+const isFilterable = (key: string): boolean => {
+  const filterTypes = getKeyFilterType(key)
+  return filterTypes !== undefined && filterTypes.length > 0
+}
+
+const generateFilterTypeSelectOption = (key: string): SelectOption[] | undefined => {
+  const filterTypes = getKeyFilterType(key)
+  if (filterTypes === undefined || filterTypes.length === 0) {
+    return undefined
+  } else {
+    const filterSelectOption: SelectOption[] = [
+      {
+        label: getKeyFilterTypeName(SiteInfoFilterType.FilterUnknown),
+        value: SiteInfoFilterType.FilterUnknown,
+        disabled: true,
+      },
+    ]
+    filterSelectOption.push(
+      ...filterTypes.map((item) => {
+        return {
+          label: getKeyFilterTypeName(item),
+          value: item,
+        }
+      }),
+    )
+    return filterSelectOption
+  }
+}
+
+const getParaToNumber = (para: string | number | undefined): number => {
+  if (para === undefined || typeof para === 'string') {
+    return 0
+  }
+  return para
+}
+
+const getParaToString = (para: string | number | undefined): string => {
+  if (para === undefined || typeof para === 'number') {
+    return ''
+  }
+  return para
+}
+
+// 新增子过滤器
+const clickAddSubFilter = () => {
+  siteInfoFilter.subFilter.push(
+    reactive({
+      id: crypto.randomUUID(),
+      logic: 'and',
+      config: [],
+    }),
+  )
+}
+
+// 清空过滤器
+const clickCleanAllSubFilter = () => {
+  siteInfoFilter.subFilter.length = 0
+}
+
+// 删除指定子过滤器
+const clickDeleteSubFilter = (subFilterIndex: number) => {
+  siteInfoFilter.subFilter.splice(subFilterIndex, 1)
+}
+
+// 添加过滤配置
+const clickAddFilterConfig = (subFilterIndex: number) => {
+  siteInfoFilter.subFilter[subFilterIndex].config.push(
+    reactive({
+      id: crypto.randomUUID(),
+      key: '',
+      type: SiteInfoFilterType.FilterUnknown,
+      para1: '',
+    }),
+  )
+}
+
+// 清空子过滤器
+const clickCleanAllFilterConfig = (subFilterIndex: number) => {
+  siteInfoFilter.subFilter[subFilterIndex].config.length = 0
+}
+
+// 删除指定过滤配置
+const clickDeleteFilterConfig = (subFilterIndex: number, configIndex: number) => {
+  siteInfoFilter.subFilter[subFilterIndex].config.splice(configIndex, 1)
+}
+
+// 更新过滤配置
+const setFilter = (filter: SiteInfoFilter) => {
+  siteInfoFilter.logic = filter.logic
+  siteInfoFilter.subFilter = reactive(filter.subFilter)
+}
+
+defineExpose({
+  setFilter,
+})
+
+onMounted(() => {
+  watch(siteInfoFilter, (newVal) => {
+    emit('update:value', structuredClone(toRaw(newVal)))
+    // modelVal.value = newVal
+  })
+
+  // 更新控件高度
+  nextTick(() => {
+    updateMaxHeight()
+
+    window.addEventListener('resize', updateMaxHeight)
+  })
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateMaxHeight)
+})
+</script>
+
+<style scoped>
+:deep(.filter-compact-list-item .n-list-item__prefix) {
+  margin-right: 8px;
+}
+
+:deep(.filter-compact-list-item .n-list-item__suffix) {
+  margin-left: 8px;
+}
+
+:deep(.filter-compact-list .n-list-item) {
+  padding: 2px 20px;
+}
+
+:deep(.filter-compact-list .n-list__footer) {
+  padding: 2px 20px;
+}
+</style>

+ 194 - 0
src/views/site-info-manage/component/siteInfoView.vue

@@ -0,0 +1,194 @@
+<template>
+  <NDrawer
+    v-model:show="drawerShow"
+    :width="drawerWidthComputed"
+    :min-width="minWidth"
+    :max-width="drawerMaxWidth"
+    resizable
+    @update:show="updateDrawerShow"
+    @update:width="updateDrawerWidth"
+  >
+    <NDrawerContent :title="infoTitle" closable>
+      <NSpin
+        v-show="showSpin"
+        class="left-1/2 top-1/2 absolute -translate-x-1/2 -translate-y-1/2"
+        :show="showSpin"
+        :delay="1000"
+        size="large"
+      />
+      <NForm
+        class="space-y-16px"
+        label-placement="left"
+        label-width="auto"
+        :show-feedback="false"
+        size="small"
+      >
+        <NFormItem v-for="info of siteInfo" :key="info.label" :label="info.label">
+          <component :is="info.value" />
+        </NFormItem>
+      </NForm>
+    </NDrawerContent>
+  </NDrawer>
+</template>
+
+<script setup lang="ts">
+import { isVNode } from 'vue'
+import type { VNode } from 'vue'
+import { useMessage, NDrawer, NDrawerContent, NSpin, NForm, NFormItem, NInput } from 'naive-ui'
+import { getSiteInfoData } from '@/service/siteInfoManage/index'
+import type { SiteInfo } from '@/service/siteInfoManage/interface'
+import {
+  renderActStatus,
+  renderEmsModel,
+  renderGridType,
+  renderGridStatus,
+  renderRunStatus,
+  renderDspStatus,
+  renderAPowerSn,
+  render4gSignal,
+  renderWifiSignal,
+  renderNetStatus,
+  renderConnType,
+} from '../infoRender'
+
+const message = useMessage()
+
+const drawerShow = ref<boolean>(false)
+
+const infoTitle = ref<string>('')
+const siteInfo = ref<SiteInfoDataItem[]>()
+const showSpin = ref<boolean>(true)
+
+const leftMargin = 20 // 离左边预留 20px
+const defaultWidth = 600 // drawer默认宽度
+const minWidth = 150 // drawer最小宽度
+const drawerMaxWidth = ref<number>(window.innerWidth - leftMargin)
+const drawerWidth = ref<number>(defaultWidth)
+const drawerWidthComputed = computed(() => Math.min(drawerWidth.value, drawerMaxWidth.value))
+const updateDrawerWidth = (value: number) => {
+  drawerWidth.value = value
+}
+
+const updateDrawerMaxWidth = () => {
+  drawerMaxWidth.value = window.innerWidth - leftMargin
+}
+
+interface SiteInfoDataItem {
+  label: string
+  value?: number | string | VNode
+}
+
+interface SiteInfoViewItem {
+  label: string
+  value: VNode
+}
+
+const createDataList = (data: SiteInfo): SiteInfoViewItem[] => {
+  const dataList: SiteInfoDataItem[] = [
+    { label: 'SN', value: data.sn },
+    { label: '设备名', value: data.name },
+    { label: '经销商', value: data.distributorName },
+    { label: '安装商账号', value: data.installerAccount },
+    { label: '安装商', value: data.installerName },
+    { label: '维护商', value: data.maintName },
+    { label: '安装时间', value: data.installTime },
+    { label: '激活状态', value: renderActStatus(data) },
+    { label: '网络状态', value: renderNetStatus(data) },
+    { label: '最后上线时间', value: data.onLineTime },
+    { label: '最后离线时间', value: data.offLineTime },
+    { label: '信号', value: render4gSignal(data) },
+    { label: 'ICCID', value: data.iccId },
+    { label: '固件更新时间', value: data.firmUpgradeTime },
+    { label: 'SOC', value: `${data.batterySoc}%` },
+    { label: '运行状态', value: renderRunStatus(data) },
+    { label: 'EMS调度模式', value: renderEmsModel(data) },
+    { label: '电网类型', value: renderGridType(data) },
+    { label: 'aPower SN', value: renderAPowerSn(data) },
+    { label: '时区数值', value: data.timeZone },
+    { label: '固件版本', value: data.version },
+    { label: 'EMS-DSP版本', value: data.slVersion },
+    { label: 'EMS-DSP版本JSON', value: data.slVersionJson },
+    { label: '电表版本', value: data.meterVersion },
+    { label: 'MODBUS版本', value: data.mbVersion },
+    { label: 'AWS版本', value: data.awsVersion },
+    { label: 'APP版本', value: data.appVersion },
+    { label: '电网状态', value: renderGridStatus(data) },
+    { label: 'EMS-DSP状态', value: renderDspStatus(data) },
+    { label: '夏令时', value: data.dst },
+    { label: '时区信息', value: data.zoneInfo },
+    { label: '设备时间', value: data.deviceTime },
+    { label: '协议版本', value: data.protocolVer },
+    { label: 'aGate硬件版本', value: data.sysHdVersion },
+    { label: 'aGate系统版本', value: data.realSysHdVersion },
+    { label: 'WIFI信号', value: renderWifiSignal(data) },
+    { label: '网络连接', value: renderConnType(data) },
+    { label: '大客户', value: data.financierName },
+    { label: '备注', value: data.remark },
+    { label: '发电机使能', value: data.genEn },
+    { label: '国家', value: data.country },
+    { label: '州/省', value: data.province },
+    { label: '城市', value: data.city },
+    { label: 'EMS版本', value: data.emsDeviceVer },
+    { label: '站点ID名', value: data.site },
+    { label: '站点ID', value: data.siteId },
+    { label: 'Job Code', value: data.jobCode },
+  ]
+
+  return dataList
+    .map<SiteInfoViewItem | undefined>((item) => {
+      if (typeof item.value === 'number' || typeof item.value === 'string') {
+        // return { label: item.label, value: h('span', item.value) }
+        return {
+          label: item.label,
+          value: h(NInput, {
+            readonly: true,
+            value: String(item.value),
+          }),
+        }
+      } else if (isVNode(item.value)) {
+        return { label: item.label, value: item.value }
+      } else {
+        return undefined
+      }
+    })
+    .filter<SiteInfoViewItem>((item) => item !== undefined)
+}
+
+const show = async (sn: string) => {
+  infoTitle.value = sn
+  showSpin.value = true
+  drawerShow.value = true
+  await getSiteInfoData(sn)
+    .then((siteInfoData) => {
+      siteInfo.value = createDataList(siteInfoData)
+      showSpin.value = false
+    })
+    .catch((err) => {
+      drawerShow.value = false
+      message.error(String(err))
+    })
+}
+
+const close = () => {
+  drawerShow.value = false
+}
+
+const updateDrawerShow = (show: boolean) => {
+  if (show === false && siteInfo.value !== undefined) {
+    siteInfo.value.length = 0
+  }
+}
+
+defineExpose({
+  show,
+  close,
+})
+
+onMounted(() => {
+  window.addEventListener('resize', updateDrawerMaxWidth)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', updateDrawerMaxWidth)
+})
+</script>

+ 547 - 0
src/views/site-info-manage/config.ts

@@ -0,0 +1,547 @@
+import type { VNodeChild } from 'vue'
+import type { DataTableBaseColumn, SelectOption } from 'naive-ui'
+import { SiteInfoFilterType } from '@/service/siteInfoManage/interface'
+import type { SiteInfo } from '@/service/siteInfoManage/interface'
+import {
+  render4gSignal,
+  renderActStatus,
+  renderAPowerSn,
+  renderConnType,
+  renderDspStatus,
+  renderEmsModel,
+  renderGridStatus,
+  renderGridType,
+  renderNetStatus,
+  renderRunStatus,
+  renderWifiSignal,
+} from './infoRender'
+
+// interface TitleSelectOption {
+//   value: keyof SiteInfo
+//   label: string
+// }
+
+// export const titleSelectOption: TitleSelectOption[] = [
+//   { label: 'SN', value: 'sn' },
+//   { label: '设备名', value: 'name' },
+//   { label: '经销商', value: 'distributorName' },
+//   { label: '安装商账号', value: 'installerAccount' },
+//   { label: '安装商', value: 'installerName' },
+//   { label: '维护商', value: 'maintName' },
+//   { label: '安装时间', value: 'installTime' },
+//   { label: '激活状态', value: 'activeStatus' },
+//   { label: '激活时间(北京时间)', value: 'activeTime' },
+//   { label: '激活时间(站点当地时间)', value: 'localActiveTime' },
+//   { label: '网络状态', value: 'status' },
+//   { label: '最后上线时间', value: 'onLineTime' },
+//   { label: '最后离线时间', value: 'offLineTime' },
+//   { label: '信号', value: 'signal' },
+//   { label: 'ICCID', value: 'iccId' },
+//   { label: '固件更新时间', value: 'firmUpgradeTime' },
+//   { label: 'SOC', value: 'batterySoc' },
+//   { label: '运行状态', value: 'runStatus' },
+//   { label: 'EMS调度模式', value: 'emsModel' },
+//   { label: '电网类型', value: 'electricityType' },
+//   { label: 'aPower SN', value: 'fhpSn' },
+//   { label: '时区数值', value: 'timeZone' },
+//   { label: '固件版本', value: 'version' },
+//   { label: 'EMS-DSP版本', value: 'slVersion' },
+//   { label: 'EMS-DSP版本JSON', value: 'slVersionJson' },
+//   { label: '电表版本', value: 'meterVersion' },
+//   { label: 'MODBUS版本', value: 'mbVersion' },
+//   { label: 'AWS版本', value: 'awsVersion' },
+//   { label: 'APP版本', value: 'appVersion' },
+//   { label: '电网状态', value: 'elecnetStatus' },
+//   { label: 'EMS-DSP状态', value: 'slaverStat' },
+//   { label: '夏令时', value: 'dst' },
+//   { label: '时区信息', value: 'zoneInfo' },
+//   { label: '设备时间', value: 'deviceTime' },
+//   { label: '协议版本', value: 'protocolVer' },
+//   { label: 'aGate硬件版本', value: 'sysHdVersion' },
+//   { label: 'aGate系统版本', value: 'realSysHdVersion' },
+//   { label: 'WIFI信号', value: 'wifiSignal' },
+//   { label: '网络连接', value: 'connType' },
+//   { label: '大客户', value: 'financierName' },
+//   { label: '备注', value: 'remark' },
+//   { label: '发电机使能', value: 'genEn' },
+//   { label: '国家', value: 'country' },
+//   { label: '州/省', value: 'province' },
+//   { label: '城市', value: 'city' },
+//   { label: 'EMS版本', value: 'emsDeviceVer' },
+//   { label: '站点ID名', value: 'site' },
+//   { label: '站点ID', value: 'siteId' },
+//   { label: 'Job Code', value: 'jobCode' },
+// ]
+
+interface SiteInfoConfig {
+  title: string
+  width: number
+  fixed?: 'left' | 'right'
+  filterType?: SiteInfoFilterType[]
+  render?: (rowData: SiteInfo, rowIndex: number) => VNodeChild
+}
+
+const siteInfoConfigMap: Record<keyof SiteInfo, SiteInfoConfig> = {
+  sn: {
+    title: 'SN',
+    width: 240,
+    fixed: 'left',
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  name: {
+    title: '设备名',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  distributorName: {
+    title: '经销商',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  installerAccount: {
+    title: '安装商账号',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  installerName: {
+    title: '安装商',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  maintName: {
+    title: '维护商',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  installTime: {
+    title: '安装时间',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  activeStatus: {
+    title: '激活状态',
+    width: 150,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+    render: renderActStatus,
+  },
+  activeTime: {
+    title: '激活时间(北京时间)',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  localActiveTime: {
+    title: '激活时间(站点当地时间)',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  status: {
+    title: '网络状态',
+    width: 100,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+    render: renderNetStatus,
+  },
+  onLineTime: {
+    title: '最后上线时间',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  offLineTime: {
+    title: '最后离线时间',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  signal: {
+    title: '信号',
+    width: 250,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: render4gSignal,
+  },
+  iccId: {
+    title: 'ICCID',
+    width: 200,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  firmUpgradeTime: {
+    title: '固件更新时间',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  batterySoc: {
+    title: 'SOC',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  runStatus: {
+    title: '运行状态',
+    width: 80,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: renderRunStatus,
+  },
+  emsModel: {
+    title: 'EMS调度模式',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: renderEmsModel,
+  },
+  electricityType: {
+    title: '电网类型',
+    width: 100,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+    render: renderGridType,
+  },
+  fhpSn: {
+    title: 'aPower SN',
+    width: 250,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: renderAPowerSn,
+  },
+  timeZone: {
+    title: '时区数值',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  version: {
+    title: '固件版本',
+    width: 350,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  slVersion: {
+    title: 'EMS-DSP版本',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  slVersionJson: {
+    title: 'EMS-DSP版本JSON',
+    width: 300,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  meterVersion: {
+    title: '电表版本',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  mbVersion: {
+    title: 'MODBUS版本',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  awsVersion: {
+    title: 'AWS版本',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  appVersion: {
+    title: 'APP版本',
+    width: 150,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  elecnetStatus: {
+    title: '电网状态',
+    width: 80,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+    render: renderGridStatus,
+  },
+  slaverStat: {
+    title: 'EMS-DSP状态',
+    width: 120,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: renderDspStatus,
+  },
+  dst: {
+    title: '夏令时',
+    width: 100,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+  },
+  zoneInfo: {
+    title: '时区信息',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  deviceTime: {
+    title: '设备时间',
+    width: 180,
+    filterType: [SiteInfoFilterType.FilterRangeTime, SiteInfoFilterType.FilterExists],
+  },
+  protocolVer: {
+    title: '协议版本',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  sysHdVersion: {
+    title: 'aGate硬件版本',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  realSysHdVersion: {
+    title: 'aGate系统版本',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  wifiSignal: {
+    title: 'WIFI信号',
+    width: 250,
+    render: renderWifiSignal,
+  },
+  connType: {
+    title: '网络连接',
+    width: 120,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+    render: renderConnType,
+  },
+  financierName: {
+    title: '大客户',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  remark: {
+    title: '备注',
+    width: 300,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  genEn: {
+    title: '发电机使能',
+    width: 100,
+    filterType: [SiteInfoFilterType.FilterEqualInt, SiteInfoFilterType.FilterExists],
+  },
+  country: {
+    title: '国家',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  province: {
+    title: '州/省',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  city: {
+    title: '城市',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  emsDeviceVer: {
+    title: 'EMS版本',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  site: {
+    title: '站点ID名',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  siteId: {
+    title: '站点ID',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualInt,
+      SiteInfoFilterType.FilterRangeInt,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+  jobCode: {
+    title: 'Job Code',
+    width: 100,
+    filterType: [
+      SiteInfoFilterType.FilterEqualString,
+      SiteInfoFilterType.FilterIncludeString,
+      SiteInfoFilterType.FilterExists,
+    ],
+  },
+}
+
+const siteInfoFilterTypeNameMap: Record<SiteInfoFilterType, string> = {
+  [SiteInfoFilterType.FilterUnknown]: 'Unknown',
+  [SiteInfoFilterType.FilterEqualInt]: '等于',
+  [SiteInfoFilterType.FilterEqualFloat]: '等于',
+  [SiteInfoFilterType.FilterEqualString]: '等于',
+  [SiteInfoFilterType.FilterIncludeString]: '包含',
+  [SiteInfoFilterType.FilterRangeInt]: '范围',
+  [SiteInfoFilterType.FilterRangeFloat]: '范围',
+  [SiteInfoFilterType.FilterRangeTime]: '范围',
+  [SiteInfoFilterType.FilterExists]: '是否存在',
+}
+
+function isSiteInfoKey(key: string): key is keyof SiteInfo {
+  return key in siteInfoConfigMap
+}
+
+export const siteInfoKeyList = Object.keys(siteInfoConfigMap) as (keyof SiteInfo)[]
+
+// 获取表格列配置
+export const getSiteInfoTableColumnsConfig = (
+  opt: (keyof SiteInfo)[],
+): DataTableBaseColumn<SiteInfo>[] => {
+  return opt.map((key) => {
+    return {
+      title: siteInfoConfigMap[key].title,
+      key,
+      width: siteInfoConfigMap[key].width,
+      minWidth: siteInfoConfigMap[key].width,
+      fixed: siteInfoConfigMap[key].fixed,
+      render: siteInfoConfigMap[key].render,
+    }
+  })
+}
+
+interface TitleOption {
+  label: string
+  value: string
+}
+
+// 获取表格标题选项
+export const getTitleOption = (): TitleOption[] => {
+  const result: TitleOption[] = []
+  Object.entries(siteInfoConfigMap).forEach(([key, value]) => {
+    result.push({
+      label: value.title,
+      value: key,
+    })
+  })
+  return result
+}
+
+// 获取过滤类型配置
+export const getKeyFilterType = (key: string): SiteInfoFilterType[] | undefined => {
+  if (isSiteInfoKey(key)) {
+    const config = siteInfoConfigMap[key]
+    return config.filterType
+  } else {
+    return undefined
+  }
+}
+
+// 获取过滤类型名称
+export const getKeyFilterTypeName = (type: SiteInfoFilterType): string => {
+  return siteInfoFilterTypeNameMap[type]
+}

+ 692 - 0
src/views/site-info-manage/index.vue

@@ -0,0 +1,692 @@
+<template>
+  <div class="main-wrap p-4 flex flex-col gap-y-4">
+    <NCard
+      ref="mainCardRef"
+      class="h-full"
+      content-class="h-full flex flex-col"
+      :style="cardHeight"
+    >
+      <div class="flex gap-2 h-6 items-center">
+        <NTooltip
+          :show-arrow="false"
+          placement="bottom-start"
+          trigger="hover"
+          @update:show="updateInfoShow"
+        >
+          <template #trigger>
+            <NButton text :focusable="false" style="font-size: 18px" @click="clickUpdateSiteInfo">
+              <span class="i-material-symbols:update-rounded" />
+            </NButton>
+          </template>
+          <div>
+            <div class="font-bold">数据状态</div>
+            <div>更新状态:{{ siteInfoUpdateStatus.progress }}</div>
+            <div>更新时间:{{ siteInfoUpdateStatus.updateTime }}</div>
+          </div>
+        </NTooltip>
+        <NPopover
+          ref="filterPopoverRef"
+          :style="filterPopoverStyle"
+          :arrow-wrapper-style="filterPopoverArrowStyle"
+          placement="bottom"
+          trigger="manual"
+          :show="showFilterMenu"
+          :flip="false"
+        >
+          <template #trigger>
+            <NButton text :focusable="false" style="font-size: 18px" @click="openFilterMenu">
+              <span class="i-mdi:filter-cog-outline" />
+            </NButton>
+          </template>
+          <div ref="filterPopoverContentRef" class="flex flex-col">
+            <SiteInfoFilterComp
+              ref="siteInfoFilterCompRef"
+              :init-value="siteInfoFilterConfig"
+              class="flex-1"
+              @update:value="siteInfoFilterUpdate"
+            />
+            <NDivider style="margin: 8px 0" />
+            <div class="flex gap-4 justify-end">
+              <NButton
+                class="px-3 py-1 border rounded"
+                text
+                :focusable="false"
+                style="font-size: 18px"
+                @click="closeFilterMenu"
+              >
+                <span class="i-mdi:close" />
+              </NButton>
+              <NButton
+                class="px-3 py-1 border rounded"
+                text
+                :focusable="false"
+                style="font-size: 18px"
+                @click="submitFilterMenu"
+              >
+                <span class="i-mdi:check" />
+              </NButton>
+            </div>
+          </div>
+        </NPopover>
+        <NButton
+          text
+          :focusable="false"
+          style="font-size: 18px"
+          class="transition-colors duration-200"
+          :class="cancelAllFilter ? 'text-green-500' : 'text-gray-400'"
+          @click="clickCancelAllFilter"
+        >
+          <span class="i-mdi:filter-off-outline" />
+        </NButton>
+        <NPopover placement="bottom" trigger="manual" :show="showFieldsMenu">
+          <template #trigger>
+            <NButton text :focusable="false" style="font-size: 18px" @click="openFieldsMenu">
+              <span class="i-ph:faders" />
+            </NButton>
+          </template>
+          <div class="flex flex-col">
+            <SiteInfoFieldsComp v-model:value="siteInfoFieldsValue" class="flex-1" />
+            <NDivider style="margin: 8px 0" />
+            <div class="flex gap-4 justify-end">
+              <NButton
+                class="px-3 py-1 border rounded"
+                text
+                :focusable="false"
+                style="font-size: 18px"
+                @click="closeFieldsMenu"
+              >
+                <span class="i-mdi:close" />
+              </NButton>
+              <NButton
+                class="px-3 py-1 border rounded"
+                text
+                :focusable="false"
+                style="font-size: 18px"
+                @click="submitFieldsMenu"
+              >
+                <span class="i-mdi:check" />
+              </NButton>
+            </div>
+          </div>
+        </NPopover>
+        <div class="flex items-center">
+          <!-- 滑出输入框 -->
+          <div
+            class="transition-all duration-250 ease-in-out overflow-hidden"
+            :class="showSearch ? 'w-200px opacity-100 pr-2' : 'w-0 ml-0 opacity-0 pr-0'"
+          >
+            <NInput
+              ref="showSearchRef"
+              v-model:value="searchKeyword"
+              round
+              clearable
+              placeholder="搜索..."
+              style="max-width: 200px"
+              size="small"
+              @keydown="handleSearchKey"
+              @blur="handleSearchBlur"
+            />
+          </div>
+          <NButton text :focusable="false" style="font-size: 18px" @click="clickSearch">
+            <span class="i-mdi:search" />
+          </NButton>
+        </div>
+        <NButton
+          class="ml-auto"
+          text
+          :focusable="false"
+          style="font-size: 18px"
+          @click="clickExportSiteInfo"
+        >
+          <span class="i-material-symbols:download-rounded" />
+        </NButton>
+      </div>
+      <NDivider style="margin: 16px 0" />
+      <div class="flex flex-1 flex-col min-h-0">
+        <div ref="tableWrapper" class="flex-1 min-h-0 overflow-hidden">
+          <NDataTable
+            ref="dataTableRef"
+            v-model:checked-row-keys="checkedRowKeys"
+            class="max-height-auto"
+            :remote="true"
+            size="small"
+            :columns="tableColumns"
+            :data="tableData"
+            :row-key="tableRowKey"
+            :loading="isRequestLoading"
+            striped
+            :single-line="false"
+            :style="{ height: `${tableHeight}px` }"
+            flex-height
+            :scroll-x="dataTableMaxWidth"
+          />
+        </div>
+        <div class="mt-3 flex items-end justify-between">
+          <div class="flex gap-x-3 items-center">
+            <span>已选择&nbsp;{{ checkedRowKeys.length }}&nbsp; 条</span>
+          </div>
+          <NPagination v-bind="tablePagination" :prefix="paginationPrefix" />
+        </div>
+      </div>
+    </NCard>
+    <SiteInfoView ref="siteInfoViewRef" />
+  </div>
+</template>
+
+<script setup lang="tsx">
+import type { CSSProperties } from 'vue'
+import {
+  useDialog,
+  useMessage,
+  NCard,
+  NButton,
+  NInput,
+  NDivider,
+  NTooltip,
+  NPopover,
+  NDataTable,
+  NNumberAnimation,
+} from 'naive-ui'
+import type { PaginationProps, DataTableColumns } from 'naive-ui'
+import { useFullCard } from '@/composable/comp/useFullCard'
+import {
+  getSiteInfoManageList,
+  getSiteInfoUpdateStatus,
+  updateSiteInfo,
+  exportSiteInfoManageList,
+  operateSiteEnableXlog,
+} from '@/service/siteInfoManage'
+import type {
+  SiteInfo,
+  SiteInfoFilter,
+  SiteInfoRequest,
+  SiteInfoUpdateInfo,
+} from '@/service/siteInfoManage/interface'
+import SiteInfoFilterComp from './component/siteInfoFilter.vue'
+import SiteInfoFieldsComp from './component/siteInfoFields.vue'
+import SiteInfoView from './component/siteInfoView.vue'
+import * as siteTabCfg from './config'
+import { useSiteInfoManageStateStore } from '@/stores/modules/views/siteInfoManageState'
+
+const dialog = useDialog()
+const message = useMessage()
+
+const siteInfoManageStateStore = useSiteInfoManageStateStore()
+
+const mainCardRef = useTemplateRef<InstanceType<typeof NCard>>('mainCardRef')
+const dataTableRef = useTemplateRef<InstanceType<typeof NDataTable>>('dataTableRef')
+const siteInfoViewRef = useTemplateRef<InstanceType<typeof SiteInfoView>>('siteInfoViewRef')
+
+const filterPopoverRef = useTemplateRef<InstanceType<typeof NPopover>>('filterPopoverRef')
+const filterPopoverContentRef = ref<HTMLDivElement | undefined>(undefined)
+
+const { height } = useFullCard(mainCardRef)
+
+const showSearch = ref(false)
+const searchKeyword = ref('')
+const showSearchRef = useTemplateRef<InstanceType<typeof NInput>>('showSearchRef')
+const handleSearchKey = (e: KeyboardEvent) => {
+  if (e.key === 'Escape') {
+    showSearch.value = false
+  }
+
+  if (e.key === 'Enter') {
+    // search()
+  }
+}
+const clickSearch = async () => {
+  if (searchKeyword.value.length === 0) {
+    showSearch.value = !showSearch.value
+
+    if (showSearch.value) {
+      nextTick(() => {
+        showSearchRef.value?.focus()
+      })
+    }
+  } else {
+    // 请求查询数据
+    handleListQuery()
+  }
+}
+const handleSearchBlur = () => {
+  if (searchKeyword.value.length === 0) {
+    showSearch.value = !showSearch.value
+  }
+}
+
+const isRequestLoading = ref(false)
+const checkedRowKeys = ref<Array<number | string>>([])
+
+const tableWrapper = ref<HTMLDivElement | null>(null)
+const tableHeight = ref(100)
+
+const showFieldsMenu = ref(false)
+const siteInfoFieldsValue = ref<string[] | undefined>()
+
+const openFieldsMenu = () => {
+  showFieldsMenu.value = true
+
+  siteInfoFieldsValue.value = siteInfoManageStateStore.titleFilterComputed
+}
+
+const closeFieldsMenu = () => {
+  showFieldsMenu.value = false
+}
+
+const submitFieldsMenu = () => {
+  showFieldsMenu.value = false
+
+  // 更新表格显示列
+  if (siteInfoFieldsValue.value !== undefined) {
+    siteInfoManageStateStore.setTitleFilter(siteInfoFieldsValue.value)
+  }
+}
+
+// 禁用所有过滤
+const cancelAllFilter = ref(false)
+
+const clickCancelAllFilter = () => {
+  cancelAllFilter.value = !cancelAllFilter.value
+
+  // 请求查询数据
+  handleListQuery()
+}
+
+const cardHeight = computed(() => {
+  return { height: `${height.value === undefined || height.value === 0 ? 480 : height.value}px` }
+})
+
+const siteInfoFilterConfig = ref<SiteInfoFilter>(siteInfoManageStateStore.queryFilterComputed)
+const siteInfoFilterCompRef =
+  useTemplateRef<InstanceType<typeof SiteInfoFilterComp>>('siteInfoFilterCompRef')
+const siteInfoFilterUpdate = (filter: SiteInfoFilter) => {
+  siteInfoFilterConfig.value = filter
+}
+
+const showFilterMenu = ref(false)
+
+const openFilterMenu = () => {
+  showFilterMenu.value = true
+}
+
+const closeFilterMenu = () => {
+  showFilterMenu.value = false
+}
+
+const submitFilterMenu = () => {
+  showFilterMenu.value = false
+
+  // 保存当前过滤条件
+  siteInfoManageStateStore.setQueryFilter(siteInfoFilterConfig.value)
+
+  // 请求查询数据
+  handleListQuery()
+}
+
+const prevUserListTotal = ref(0)
+
+const paginationPrefix: PaginationProps['prefix'] = (info) => {
+  const { itemCount } = info
+  return (
+    itemCount && (
+      <div>
+        <span>总 &nbsp; </span>
+        <NNumberAnimation
+          from={prevUserListTotal.value}
+          to={itemCount}
+          onFinish={() => {
+            prevUserListTotal.value = itemCount
+          }}
+        />
+        <span> &nbsp; 条 </span>
+      </div>
+    )
+  )
+}
+
+const tableRowKey = (rowData: SiteInfo) => {
+  return rowData.sn
+}
+
+function createColumns(): DataTableColumns<SiteInfo> {
+  const filterSiteInfoKeys = (keys: string[]): (keyof SiteInfo)[] => {
+    return keys.filter((key): key is keyof SiteInfo =>
+      siteTabCfg.siteInfoKeyList.includes(key as keyof SiteInfo),
+    )
+  }
+
+  const result: DataTableColumns<SiteInfo> = [
+    {
+      type: 'selection',
+      fixed: 'left',
+      width: 30,
+      minWidth: 30,
+    },
+    {
+      title: 'No.',
+      key: '',
+      width: 50,
+      minWidth: 50,
+      fixed: 'left',
+      render(_, index) {
+        const page = tablePagination.page ?? 1
+        const pageSize = tablePagination.pageSize ?? 10
+        const n = (page - 1) * pageSize + index + 1
+        return `${n}`
+      },
+    },
+  ]
+
+  const columnsConfig = siteTabCfg.getSiteInfoTableColumnsConfig(
+    filterSiteInfoKeys(siteInfoManageStateStore.titleFilterComputed),
+  )
+  result.push(...columnsConfig)
+
+  // filterSiteInfoKeys(tabOpt, validKeys).map((item, index, array) => {
+  //   const columnConfig = siteTabCfg.columnTitleMap[item]
+  //   if (index === (array.length - 1)) {
+  //     columnConfig.width = undefined
+  //   }
+  //   result.push(columnConfig)
+  // })
+
+  dataTableMaxWidth.value = result
+    .map((item) => {
+      if (item.minWidth === undefined) {
+        return 0
+      } else if (typeof item.minWidth === 'string') {
+        return Number.parseFloat(item.minWidth)
+      } else {
+        return item.minWidth
+      }
+    })
+    .reduce((acc: number, cur: number) => acc + cur, 0)
+
+  // 插入操作选项
+  result.push({
+    title: '操作',
+    key: 'operate',
+    width: 200,
+    minWidth: 100,
+    fixed: 'right',
+    render(row) {
+      return h('div', { class: 'flex justify-center gap-4 p-x-4' }, [
+        h(
+          NButton,
+          {
+            type: 'info',
+            strong: true,
+            size: 'small',
+            disabled: true, // TODO 这个按钮需要做权限管控
+            onClick: () => {
+              dialog.warning({
+                title: '确认使能xLog',
+                content: `是否确定使能站点${row.sn}xLog?`,
+                positiveText: '确定',
+                negativeText: '取消',
+                maskClosable: false,
+                onPositiveClick: async () => {
+                  // const res = await operateSiteEnableXlog(row.sn)
+                  const res = await operateSiteEnableXlog('10060006A02X24330167') // TEST
+                  message.info(`使能xlog结果: ${res}`)
+                },
+                onNegativeClick: () => {
+                  console.log('取消使能')
+                },
+              })
+            },
+          },
+          {
+            default: () => '使能xLog',
+          },
+        ),
+        h(
+          NButton,
+          {
+            type: 'success',
+            strong: true,
+            size: 'small',
+            onClick: () => siteInfoViewRef.value?.show(row.sn),
+          },
+          {
+            default: () => '详情',
+          },
+        ),
+      ])
+    },
+  })
+
+  return result
+}
+
+const tableData = ref<SiteInfo[]>([])
+const dataTableMaxWidth = ref<number | undefined>(undefined)
+const tableColumns = computed<DataTableColumns<SiteInfo>>(createColumns)
+const tablePagination = reactive<PaginationProps>({
+  simple: false,
+  page: 1,
+  pageSize: 10,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50, 100],
+  pageSlot: 5,
+  itemCount: 0,
+  showQuickJumper: false,
+  showQuickJumpDropdown: true,
+  onUpdatePage: (page: number) => {
+    tablePagination.page = page
+    handleListQuery()
+  },
+  onUpdatePageSize: (pageSize: number) => {
+    tablePagination.pageSize = pageSize
+    tablePagination.page = 1
+    handleListQuery()
+  },
+})
+
+const clickUpdateSiteInfo = () => {
+  dialog.warning({
+    title: '确认更新',
+    content: '是否确定更新数据?',
+    positiveText: '确定',
+    negativeText: '取消',
+    maskClosable: false,
+    onPositiveClick: async () => {
+      console.log('开始更新数据')
+      await updateSiteInfo()
+      // 查询数据更新状态
+      await queryInfoUpdateStatus()
+    },
+    onNegativeClick: () => {
+      console.log('取消更新')
+    },
+  })
+}
+
+const clickExportSiteInfo = () => {
+  dialog.warning({
+    title: '确认导出',
+    content: `是否确定导出当前查询条件的${tablePagination.itemCount}条数据?`,
+    positiveText: '确定',
+    negativeText: '取消',
+    maskClosable: false,
+    onPositiveClick: () => {
+      console.log('开始导出')
+      // TODO 提取过滤条件
+      exportSiteInfoManageList({})
+    },
+    onNegativeClick: () => {
+      console.log('取消导出')
+    },
+  })
+}
+
+const querySiteInfoList = async (
+  pageSize: number,
+  pageNum: number,
+  search: string,
+  filter?: SiteInfoFilter,
+) => {
+  const para: SiteInfoRequest = {
+    pageSize,
+    pageNum,
+    filter,
+    search,
+  }
+
+  const responseList = await getSiteInfoManageList(para)
+  if (responseList.data != null) {
+    tableData.value = responseList.data
+  } else {
+    tableData.value = []
+  }
+  tablePagination.itemCount = responseList.total
+}
+
+const handleListQuery = async () => {
+  const pageSize = tablePagination.pageSize ?? 10
+  const pageNum = tablePagination.page ?? 1
+
+  await querySiteInfoList(
+    pageSize,
+    pageNum,
+    searchKeyword.value,
+    cancelAllFilter.value
+      ? undefined
+      : (() => {
+          const currFilter = siteInfoManageStateStore.queryFilterComputed
+          if (currFilter.subFilter.length === 0) {
+            return undefined
+          }
+          return currFilter
+        })(),
+  )
+}
+
+const siteInfoUpdateStatus = ref<SiteInfoUpdateInfo>({
+  id: '',
+  progress: 'pending',
+  createTime: '',
+  updateTime: '',
+  total: 0,
+})
+
+let siteInfoUpdateTime: number = Date.now()
+
+// 查询数据更新状态
+const queryInfoUpdateStatus = async () => {
+  siteInfoUpdateStatus.value = await getSiteInfoUpdateStatus()
+  siteInfoUpdateTime = Date.now()
+}
+
+// 数据更新信息显示触发
+const updateInfoShow = (show: boolean) => {
+  // 超过三分钟才再次刷新
+  if (show && Date.now() - siteInfoUpdateTime > 3 * 1000 * 60) {
+    queryInfoUpdateStatus()
+  }
+}
+
+/* NPopover溢出屏幕处理 */
+const filterPopoverStyle = ref<CSSProperties>({})
+const filterPopoverArrowStyle = ref<CSSProperties>({})
+const filterPopoverTranslate = { value: 0 }
+
+// 计算NPopover溢出信息
+const calcPopoverOverflowInfo = (defaultElement: HTMLElement) => {
+  const rect = defaultElement.getBoundingClientRect()
+
+  // 初始位置
+  const currX = rect.left
+
+  // 预留边框 单位:px
+  const padding = 8
+
+  return {
+    translateX: Math.max(0, padding - currX),
+    maxWidth: window.innerWidth - padding * 2,
+  }
+}
+
+const popoverResizeListenerList: (() => void)[] = []
+
+// 预防NPopover溢出屏幕
+const preventPopoverOverflow = (
+  popoverShow: Ref<boolean>,
+  popoverDataRef: Ref<any>,
+  popoverDefaultRef: Ref<HTMLElement | undefined>,
+  popoverStyle: Ref<CSSProperties>,
+  popoverArrowStyle: Ref<CSSProperties>,
+  popoverTranslate: { value: number },
+) => {
+  const updatePopover = (show: boolean) => {
+    if (show === true && popoverDefaultRef.value !== undefined) {
+      const data = calcPopoverOverflowInfo(popoverDefaultRef.value)
+
+      const t = popoverTranslate.value + data.translateX
+      popoverStyle.value.transform = `translateX(${t}px)`
+      popoverArrowStyle.value.transform = `translateX(-${t}px)`
+      popoverTranslate.value = t
+
+      popoverDefaultRef.value.style.maxWidth = `${data.maxWidth}px`
+    }
+  }
+
+  // 监听NPopover显示
+  watch(popoverShow, (newVal) => {
+    nextTick(() => {
+      updatePopover(newVal)
+    })
+  })
+
+  // 监听NPopover数据变化(可能引发宽度变化)
+  watch(popoverDataRef, () => {
+    nextTick(() => {
+      updatePopover(popoverShow.value)
+    })
+  })
+
+  const updatePopoverSize = () => {
+    updatePopover(popoverShow.value)
+  }
+
+  // 监听窗口变化
+  window.addEventListener('resize', updatePopoverSize)
+  popoverResizeListenerList.push(updatePopoverSize)
+}
+
+onMounted(async () => {
+  // 请求查询数据
+  await handleListQuery()
+  // 查询数据更新状态
+  await queryInfoUpdateStatus()
+
+  // 查询用户配置
+  await siteInfoManageStateStore.queryConfig()
+
+  nextTick(() => {
+    const observer = new ResizeObserver((entries) => {
+      tableHeight.value = entries[0].contentRect.height
+    })
+
+    if (tableWrapper.value) {
+      observer.observe(tableWrapper.value)
+    }
+  })
+
+  // 预防Filter NPopover溢出屏幕
+  preventPopoverOverflow(
+    showFilterMenu,
+    siteInfoFilterConfig,
+    filterPopoverContentRef,
+    filterPopoverStyle,
+    filterPopoverArrowStyle,
+    filterPopoverTranslate,
+  )
+})
+
+onBeforeUnmount(() => {
+  popoverResizeListenerList.forEach((l) => {
+    window.removeEventListener('resize', l)
+  })
+})
+</script>

+ 114 - 0
src/views/site-info-manage/infoRender/devRender.ts

@@ -0,0 +1,114 @@
+import { NTag } from 'naive-ui'
+import type { SiteInfo } from '@/service/siteInfoManage/interface'
+
+export const renderActStatus = (data: SiteInfo): string | VNode => {
+  if (data.activeStatus === 0) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'warning' }, // props
+      {
+        default: () => '未激活(0)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-ph:info-fill  text-gray-400' }), // icon 插槽
+      },
+    )
+  } else if (data.activeStatus === 1) {
+    const activeStatusNodes: VNode[] = [
+      h(
+        NTag,
+        { round: true, bordered: false, type: 'success' }, // props
+        {
+          default: () => '已激活(1)', // 默认插槽内容
+          icon: () => h('span', { class: 'i-ph:check-circle-fill  text-green-500' }), // icon 插槽
+        },
+      ),
+    ]
+
+    if (data.activeTime.length !== 0) {
+      activeStatusNodes.push(h('div', `${data.activeTime}(北京时间)`))
+    }
+
+    if (data.localActiveTime.length !== 0) {
+      activeStatusNodes.push(h('div', `${data.localActiveTime}(设备时间)`))
+    }
+
+    return h('div', activeStatusNodes)
+  } else {
+    return `unknown(${data.activeStatus})`
+  }
+}
+
+export const renderEmsModel = (data: SiteInfo): string => {
+  return `Model ID - ${data.emsModel}`
+}
+
+export const renderGridType = (data: SiteInfo): string => {
+  if (data.electricityType === 0) {
+    return '离网设备(0)'
+  } else if (data.electricityType === 1) {
+    return '并网设备(1)'
+  } else {
+    return `unknown(${data.electricityType})`
+  }
+}
+
+export const renderGridStatus = (data: SiteInfo): string => {
+  if (data.elecnetStatus === 0) {
+    return '离网(0)'
+  } else if (data.elecnetStatus === 1) {
+    return '并网(1)'
+  } else {
+    return `unknown(${data.elecnetStatus})`
+  }
+}
+
+export const renderRunStatus = (data: SiteInfo): string => {
+  if (data.runStatus === 0) {
+    return '待机(0)'
+  } else if (data.runStatus === 1) {
+    return '充电(1)'
+  } else if (data.runStatus === 2) {
+    return '放电(2)'
+  } else if (data.runStatus === 3) {
+    return '故障(3)'
+  } else {
+    return `unknown(${data.runStatus})`
+  }
+}
+
+export const renderDspStatus = (data: SiteInfo): string => {
+  if (data.slaverStat === 0) {
+    return '初始化(0)'
+  } else if (data.slaverStat === 1) {
+    return '参数获取(1)'
+  } else if (data.slaverStat === 2) {
+    return '监听(2)'
+  } else if (data.slaverStat === 3) {
+    return '待机(3)'
+  } else if (data.slaverStat === 4) {
+    return '并网运行(4)'
+  } else if (data.slaverStat === 5) {
+    return '离网运行(5)'
+  } else if (data.slaverStat === 6) {
+    return '油机接入运行'
+  } else if (data.slaverStat === 7) {
+    return '故障(7)'
+  } else {
+    return `unknown(${data.slaverStat})`
+  }
+}
+
+export const renderAPowerSn = (data: SiteInfo): VNode => {
+  return h(
+    'div',
+    { class: 'space-y-2' },
+    data.fhpSn.split(',').map((item) => {
+      return h(
+        NTag,
+        { round: true, bordered: false, type: 'info' }, // props
+        {
+          default: () => item, // 默认插槽内容
+        },
+      )
+    }),
+  )
+}

+ 10 - 0
src/views/site-info-manage/infoRender/index.ts

@@ -0,0 +1,10 @@
+export {
+  renderActStatus,
+  renderEmsModel,
+  renderGridType,
+  renderGridStatus,
+  renderRunStatus,
+  renderDspStatus,
+  renderAPowerSn,
+} from './devRender'
+export { render4gSignal, renderWifiSignal, renderNetStatus, renderConnType } from './netRender'

+ 264 - 0
src/views/site-info-manage/infoRender/netRender.ts

@@ -0,0 +1,264 @@
+import { NTag } from 'naive-ui'
+import type { SiteInfo } from '@/service/siteInfoManage/interface'
+
+// 模块作用域,只创建一次正则
+const VERSION_REGEX = /V(\d+)R(\d+)B(\d+)D(\d+)/
+
+export const render4gSignal = (data: SiteInfo): VNode => {
+  // 4G信号有新旧两种算法
+  // 旧算法直接使用 CSQ 范围 0~31 (dBm ≈ -113 + 2 × CSQ)。 CSQ(0 - 31) = RSRP(-113 ~ -51dBm)
+  // 新算法使用 RSRP(dBm) 映射 0-100 signal值。 signal(0 - 100) = RSRP(-115 ~ -75dBm)
+
+  const signalNodeRender = (
+    tagType: 'default' | 'error' | 'warning' | 'primary' | 'success' | 'info',
+    signalString: string,
+    signalIcon: string,
+  ) => {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: tagType }, // props
+      {
+        default: () => signalString, // 默认插槽内容
+        icon: () => h('span', { class: signalIcon }), // icon 插槽
+      },
+    )
+  }
+
+  if (data.signal === undefined || Number(data.signal) < 0) {
+    return signalNodeRender(
+      'error',
+      `Disable(${data.signal})`,
+      'i-boxicons:signal-slash text-red-500',
+    )
+  }
+  const signal = Number(data.signal)
+
+  let isNew = false
+  if (signal > 31) {
+    // 一定是新算法
+    isNew = true
+  }
+
+  // TODO 其他判断新旧算法的条件
+  // 北美软件 APP V10R04B14D00 版本开始使用新算法
+  // 澳洲软件使用旧算法 exp: APP V12R00B08D00
+  // data.appVersion
+  if (data.appVersion !== undefined) {
+    const match = data.appVersion.match(VERSION_REGEX)
+    if (match) {
+      const [_, v, r, b, d] = match
+      if (v === '10') {
+        if (Number.parseInt(r, 10) > 5) {
+          isNew = true
+        } else if (Number.parseInt(r, 10) === 4 && Number.parseInt(b, 10) >= 14) {
+          isNew = true
+        }
+      }
+    }
+  }
+
+  // RSRP 值 (dBm)  | 信号质量 | 体验
+  // ---------------|----------|----------------
+  // ≥ -80          | 极好     | 网速快、稳定
+  // -80 ~ -90      | 良好     | 基本稳定
+  // -90 ~ -100     | 一般     | 偶尔波动
+  // -100 ~ -110    | 较差     | 容易卡顿
+  // ≤ -110         | 很差     | 掉线/无服务风险
+
+  // 未使能
+  let signalIcon = 'i-boxicons:signal-slash text-red-500'
+  let tagType: 'error' | 'warning' | 'success' | 'default' | 'primary' | 'info' = 'error'
+  let signalString = `Disable(${data.signal}`
+
+  if (signal >= 0) {
+    if (isNew) {
+      if (signal >= 87.5) {
+        // ≥ -80dBm
+        // rsrp = (87.5 / 2.5) + (-115) = -80dBm
+        signalIcon = 'i-boxicons:signal-5 text-green-500'
+        tagType = 'success'
+        signalString = `极好(${data.signal}) ${signal / 2.5 - 115}dBm`
+      } else if (signal >= 62.5) {
+        // ≥ -90dBm
+        // rsrp = (62.5 / 2.5) + (-115) = -90dBm
+        signalIcon = 'i-boxicons:signal-4 text-green-500'
+        tagType = 'success'
+        signalString = `良好(${data.signal}) ${signal / 2.5 - 115}dBm`
+      } else if (signal >= 37.5) {
+        // ≥ -100dBm
+        // rsrp = (37.5 / 2.5) + (-115) = -100dBm
+        signalIcon = 'i-boxicons:signal-3 text-blue-500'
+        tagType = 'info'
+        signalString = `一般(${data.signal}) ${signal / 2.5 - 115}dBm`
+      } else if (signal >= 12.5) {
+        // ≥ -110dBm
+        // rsrp = (12.5 / 2.5) + (-115) = -110dBm
+        signalIcon = 'i-boxicons:signal-2 text-yellow-500'
+        tagType = 'warning'
+        signalString = `较差(${data.signal}) ${signal / 2.5 - 115}dBm`
+      } else {
+        // < -110dBm
+        signalIcon = 'i-boxicons:signal-1 text-red-500'
+        // tagType = 'error'
+        signalString = `很差(${data.signal}) ${signal / 2.5 - 115}dBm`
+      }
+    } else {
+      if (signal >= 16.5) {
+        // ≥ -80dBm
+        // rsrp = (CSQ(16.5) * 2) + (-113) = -80dBm
+        signalIcon = 'i-boxicons:signal-5 text-green-500'
+        tagType = 'success'
+        signalString = `极好(${data.signal}) ${signal * 2 - 113}dBm`
+      } else if (signal >= 11.5) {
+        // ≥ -90dBm
+        // rsrp = (CSQ(11.5) * 2) + (-113) = -90dBm
+        signalIcon = 'i-boxicons:signal-4 text-green-500'
+        tagType = 'success'
+        signalString = `良好(${data.signal}) ${signal * 2 - 113}dBm`
+      } else if (signal >= 6.5) {
+        // ≥ -100dBm
+        // rsrp = (CSQ(6.5) * 2) + (-113) = -100dBm
+        signalIcon = 'i-boxicons:signal-3 text-blue-500'
+        tagType = 'info'
+        signalString = `一般(${data.signal}) ${signal * 2 - 113}dBm`
+      } else if (signal >= 1.5) {
+        // ≥ -110dBm
+        // rsrp = (CSQ(1.5) * 2) + (-113) = -110dBm
+        signalIcon = 'i-boxicons:signal-2 text-yellow-500'
+        tagType = 'warning'
+        signalString = `较差(${data.signal}) ${signal * 2 - 113}dBm`
+      } else {
+        // < -110dBm
+        signalIcon = 'i-boxicons:signal-1 text-red-500'
+        // tagType = 'error'
+        signalString = `很差(${data.signal}) ${signal * 2 - 113}dBm`
+      }
+    }
+  }
+
+  return h(
+    NTag,
+    { round: true, bordered: false, type: tagType }, // props
+    {
+      default: () => signalString, // 默认插槽内容
+      icon: () => h('span', { class: signalIcon }), // icon 插槽
+    },
+  )
+}
+
+export const renderWifiSignal = (data: SiteInfo): VNode => {
+  // 如果 signalDBm >= -50,那么 wifiSignal = 100
+  // 否则 wifiSignal = (signalDBm + 100) * 2
+  let signalIcon = 'i-boxicons:wifi text-green-500' // > -60 dBm 强/良好
+  let tagType: 'error' | 'warning' | 'success' | 'default' | 'primary' | 'info' = 'success'
+  let signalString = `强/良好(${data.wifiSignal}) > -50dBm`
+  const signal = Number(data.wifiSignal)
+  if (signal < 0) {
+    // 未使能
+    signalIcon = 'i-boxicons:wifi-slash text-red-500'
+    tagType = 'error'
+    signalString = `Disable(${data.wifiSignal})`
+  } else if (signal < 40) {
+    // wifiSignal = (-80 + 100) * 2 = 40
+    // < -80 dBm 很弱/不可用
+    signalIcon = 'i-boxicons:wifi-0 text-red-500'
+    tagType = 'error'
+    signalString = `很弱/不可用(${data.wifiSignal}) ${signal / 2 - 100}dBm`
+  } else if (signal < 60) {
+    // wifiSignal = (-70 + 100) * 2 = 60
+    // -80 ~ -70 dBm 弱
+    signalIcon = 'i-boxicons:wifi-1 text-yellow-500'
+    tagType = 'warning'
+    signalString = `弱(${data.wifiSignal}) ${signal / 2 - 100}dBm`
+  } else if (signal < 80) {
+    // wifiSignal = (-60 + 100) * 2 = 80
+    // -70 ~ -60 dBm 中等/一般
+    signalIcon = 'i-boxicons:wifi-2 text-blue-500'
+    tagType = 'info'
+    signalString = `中等/一般(${data.wifiSignal}) ${signal / 2 - 100}dBm`
+  }
+
+  return h(
+    NTag,
+    { round: true, bordered: false, type: tagType }, // props
+    {
+      default: () => signalString, // 默认插槽内容
+      icon: () => h('span', { class: signalIcon }), // icon 插槽
+    },
+  )
+}
+
+export const renderNetStatus = (data: SiteInfo): string | VNode => {
+  if (data.status === 0) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'warning' }, // props
+      {
+        default: () => '离线(0)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-ph:globe-x-bold  text-gray-400' }), // icon 插槽
+      },
+    )
+  } else if (data.status === 1) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'success' }, // props
+      {
+        default: () => '在线(1)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-ph:check-circle-fill  text-green-500' }), // icon 插槽
+      },
+    )
+  } else {
+    return `unknown(${data.status})`
+  }
+}
+
+export const renderConnType = (data: SiteInfo): string | VNode => {
+  if (data.connType === 0) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'warning' }, // props
+      {
+        default: () => '未连接(0)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-ph:globe-x-bold  text-gray-400' }), // icon 插槽
+      },
+    )
+  } else if (data.connType === 1) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'info' }, // props
+      {
+        default: () => 'eth0(1)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-mdi:ethernet  text-blue-500' }), // icon 插槽
+      },
+    )
+  } else if (data.connType === 2) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'info' }, // props
+      {
+        default: () => 'eth1(2)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-mdi:ethernet  text-blue-500' }), // icon 插槽
+      },
+    )
+  } else if (data.connType === 3) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'info' }, // props
+      {
+        default: () => 'WIFI(3)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-mdi:wifi  text-blue-500' }), // icon 插槽
+      },
+    )
+  } else if (data.connType === 4) {
+    return h(
+      NTag,
+      { round: true, bordered: false, type: 'info' }, // props
+      {
+        default: () => '4G(4)', // 默认插槽内容
+        icon: () => h('span', { class: 'i-mdi:signal  text-blue-500' }), // icon 插槽
+      },
+    )
+  } else {
+    return `unknown(${data.connType})`
+  }
+}

+ 1 - 1
src/views/sites-monitor/interface.ts

@@ -15,7 +15,7 @@ export interface SiteInfo {
   country: string
   province: string
   city: string
-  onOffStatus: number
+  Status: number // 网络状态 0-离线 1-在线
   realSysHdVersion: string
   runStatus: number
   version: string

+ 122 - 18
src/views/xlog/component/xlogCommand.vue

@@ -5,19 +5,23 @@
         <NButtonGroup vertical class="w-full">
           <NButton class="w-full justify-start px-4" v-for="(cmd, index) in cmdList" :key="index"
             @click="handleListClick(cmd)">
-            {{ index }} - {{ cmd.send }}
+            {{ index }} - {{ cmd.send }} ({{ cmdTypeMap[cmd.cmdType] ?? "" }})
           </NButton>
         </NButtonGroup>
       </div>
       <NLog :log="logStr" class="flex-1 overflow-auto h-full" :style="{ height: null }"></NLog>
     </div>
     <div class="flex items-center space-x-2 mt-auto">
-      <NInput v-model:value="inputCmd" @update:value="handleInputChange" @keyup.enter="handleSendCmd"
-        @keyup.up="handleInputUp" @keyup.down="handleInputDown">
+      <NSelect class="w-30" v-model:value="cmdType" :options="cmdTypeOptions" />
+      <NAutoComplete v-model:value="inputCmd" :input-props="{ autocomplete: 'disabled' }" :options="inputCmdOptions"
+        @update:value="handleInputChange" :render-label="inputCmdOptionsRenderLabel">
         <template #prefix>
           <i class="i-ph:terminal-bold" />
         </template>
-      </NInput>
+        <template #default="{ handleInput, handleBlur, handleFocus, value: slotValue }">
+          <NInput :value="slotValue" @input="handleInput" @focus="handleFocus" @blur="handleBlur" clearable />
+        </template>
+      </NAutoComplete>
       <NButton type="primary" :disabled="disabledSendCmd" :loading="loadingSendCmd" @click="handleSendCmd">
         Send
       </NButton>
@@ -26,8 +30,14 @@
 </template>
 
 <script setup lang="ts">
-import { NButton, NInput, NLog, NButtonGroup } from 'naive-ui'
+import type { VNodeChild } from 'vue'
+import { NButton, NInput, NLog, NButtonGroup, NSelect, NAutoComplete, NTag } from 'naive-ui'
+import type { AutoCompleteGroupOption, AutoCompleteOption, SelectOption } from 'naive-ui'
 import { sendXlogCmd } from '@/service/xlog';
+import { useXlogViewsStateStore } from '@/stores/modules/views/xlogViewsState';
+import type { XlogCmdListData } from '@/stores/modules/views/xlogViewsState';
+
+const xlogViewsStateStore = useXlogViewsStateStore()
 
 const { uuid, sn } = defineProps<{
   uuid: string,
@@ -38,21 +48,118 @@ let inputCmdIndex = -1
 let inputCmdTemp = ""
 const inputCmd = ref<string>("")
 const logStr = ref<string>("")
-const cmdList = ref<{
-  send: string,
-  recv: string
-}[]>([])
+const cmdList = xlogViewsStateStore.getCmdListRef(uuid)
+
+const cmdTypeMap: Record<string, string> = {
+  cmd: 'CMD',
+  bash_c: 'bash -c',
+}
+
+const cmdType = ref('cmd')
+const cmdTypeOptions = Object.entries(cmdTypeMap).map(([key, value]) => {
+  return {
+    label: value,
+    value: key
+  }
+})
+
+function toBashC(cmd: string): string {
+  return `bash -c "${cmd
+    .replace(/\\/g, '\\\\')   // 先转义反斜杠
+    .replace(/"/g, '\\"')     // 转义双引号
+    .replace(/\$/g, '\\$')    // 防止变量提前展开
+    .replace(/`/g, '\\`')     // 防止命令替换
+    .replace(/\n/g, '\\n')    // 处理换行
+    }"`
+}
+
+// 常用命令
+const commonCmd: { cmd: string, version: string }[] = [
+  { cmd: "head -10 /opt/config/EmuPara/EmuParaDevInfo.json", version: "ALL" },
+  { cmd: "rm -v /opt/app1/configuser/xlog_remote.xml", version: "ALL" },
+  { cmd: "rm -v /home/root/ExportFile/IbgLog/*_UsrRtData*.txt", version: "1.0" },
+  { cmd: "rm -v /home/root/ExportFile/IbgLog/*_AgApRtData*.txt", version: "1.0" },
+  { cmd: "rm -v /home/root/ExportFile/IbgLog/*_devdata.txt", version: "1.0" },
+  { cmd: "rm -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+  { cmd: "rm1 -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+  { cmd: "rm2 -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+  { cmd: "rm3 -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+  { cmd: "rm4 -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+  { cmd: "rm5 -v /home/root/ExportFile/IbgLog/*_Power.txt", version: "1.0" },
+]
+
+// 历史命令
+const historyCmd = ref<string[]>([])
+
+// const inputCmdOptions = ref<string[]>(["345", "24324"])
+const inputCmdOptions = computed<Array<AutoCompleteGroupOption | AutoCompleteOption | string>>(() => {
+  return [
+    {
+      type: 'group',
+      label: 'input',
+      key: 'input',
+      children: [{
+        label: inputCmd.value,
+        value: `input__${inputCmd.value}`,
+      } satisfies SelectOption]
+    },
+    {
+      type: 'group',
+      label: 'history',
+      key: 'history',
+      children: Array.from(new Set(cmdList.value.map(item => {
+        return item.send
+      }))).reverse().slice(0, 10).map(item => {
+        return {
+          label: item,
+          value: `history__${item}`,
+        } satisfies SelectOption
+      })
+    },
+    {
+      type: 'group',
+      label: 'common',
+      key: 'common',
+      children: commonCmd.map(item => {
+        return {
+          label: item.cmd,
+          value: `common__${item.cmd}`,
+          version: item.version // 用于显示版本标签
+        } satisfies SelectOption
+      })
+    }
+  ] satisfies AutoCompleteGroupOption[]
+})
+
+const inputCmdOptionsRenderLabel = (option: SelectOption): VNodeChild => {
+  if (option.version !== undefined) {
+    return [
+      option.label as string,
+      ' ',
+      h(NTag, { size: 'small', type: 'info' }, { default: () => option.version })
+    ]
+  } else {
+    return option.label as string
+  }
+}
 
 const disabledSendCmd = ref<boolean>(true)
 const loadingSendCmd = ref<boolean>(false)
 const handleSendCmd = async () => {
   loadingSendCmd.value = true
-  const res = await sendXlogCmd(uuid, inputCmd.value)
+  const cmdString = inputCmd.value
+  let sendCmdString = cmdString
+  if (cmdType.value === 'bash_c') {
+    sendCmdString = toBashC(cmdString)
+  }
+  const res = await sendXlogCmd(uuid, sendCmdString)
   cmdList.value.push({
-    send: inputCmd.value,
-    recv: res
+    send: cmdString,
+    recv: res,
+    cmdType: cmdType.value
   })
   loadingSendCmd.value = false
+  logStr.value = res
 }
 
 const handleInputChange = (value: string) => {
@@ -67,6 +174,7 @@ const handleInputChange = (value: string) => {
   }
 }
 
+// 模拟终端查看历史
 const handleInputUp = (e: KeyboardEvent) => {
   if (inputCmdIndex === -1) {
     inputCmdTemp = inputCmd.value
@@ -78,6 +186,7 @@ const handleInputUp = (e: KeyboardEvent) => {
   }
 }
 
+// 模拟终端查看历史
 const handleInputDown = (e: KeyboardEvent) => {
   if (inputCmdIndex === -1) {
     return
@@ -90,16 +199,11 @@ const handleInputDown = (e: KeyboardEvent) => {
   }
 }
 
-const handleListClick = (cmd: {
-  send: string,
-  recv: string
-}) => {
+const handleListClick = (cmd: XlogCmdListData) => {
   logStr.value = cmd.recv
 }
 
 onMounted(() => {
-
-
   // 更新log显示
   const lastCmd = cmdList.value.at(-1)
   if (lastCmd) {

部分文件因文件數量過多而無法顯示