Bläddra i källkod

faet:提交最新部署代码

Jerry Liang 1 år sedan
förälder
incheckning
7f42ecacf1
78 ändrade filer med 3019 tillägg och 864 borttagningar
  1. 14 0
      service/.vscode/settings.json
  2. 5 10
      service/README.md
  3. 0 326
      service/Untitled-1
  4. 25 0
      service/cache/cache.go
  5. 55 0
      service/cache/redisx/streamx.go
  6. 21 0
      service/controllers/backgroundTaskControllers.go
  7. 40 0
      service/controllers/shutdownManageControllers.go
  8. 72 0
      service/controllers/shutdownStatisticsControllers.go
  9. 11 5
      service/controllers/sitesAnalysisControllers.go
  10. 60 0
      service/database/logger.go
  11. 4 4
      service/database/mongo.go
  12. 4 4
      service/database/mysql.go
  13. 4 3
      service/database/pgsql.go
  14. 1 1
      service/domain/fwhProDomain.go
  15. 1 1
      service/domain/fwhProMqttDomain.go
  16. 21 2
      service/domain/shutdownStatisticsDomain.go
  17. 2 0
      service/domain/sitesAnalysisDomain.go
  18. 7 0
      service/domain/taskQueueDomain.go
  19. 8 0
      service/dto/backgroundTaskDTO.go
  20. 17 0
      service/dto/shutdownStatisticsDTO.go
  21. 11 1
      service/go.mod
  22. 23 0
      service/go.sum
  23. 23 6
      service/jobs/analysisSiteData.go
  24. 17 1
      service/jobs/statisticsShutdown.go
  25. 35 3
      service/logger/logger.go
  26. 4 0
      service/main.go
  27. 6 1
      service/repositories/api/fwh_pro/fwhHttpClient.go
  28. 30 10
      service/repositories/api/fwh_pro/fwh_eventAlarmLog.go
  29. 21 12
      service/repositories/api/fwh_pro/fwh_login.go
  30. 2 0
      service/repositories/api/fwh_pro/fwh_mqttLog.go
  31. 1 1
      service/repositories/api/fwh_pro/fwh_shutdownList.go
  32. 12 2
      service/repositories/mongo/sitesAnalysisRepo.go
  33. 55 10
      service/repositories/mongo/statisticsShutdownRepo.go
  34. 1 1
      service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotData.go
  35. 1 1
      service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotFileRecord.go
  36. 67 0
      service/repositories/pgsql/original/siteAnalysisData.go
  37. 1 1
      service/repositories/redis/authRepo.go
  38. 14 4
      service/repositories/repositories.go
  39. 16 0
      service/routes/backgroundTaskRoutes.go
  40. 3 0
      service/routes/routes.go
  41. 20 0
      service/routes/shutdownManageRoutes.go
  42. 20 0
      service/routes/shutdownStatisticsRoutes.go
  43. 35 14
      service/scheduler/scheduler.go
  44. 11 0
      service/services/backgroundTaskServices.go
  45. 241 0
      service/services/shutdownStatisticsServices.go
  46. 5 5
      service/services/sitesAnalysisServices.go
  47. 184 69
      service/taskQueue/taskQueue.go
  48. 2 2
      service/taskQueue/taskShutdownStatistics.go
  49. 46 13
      service/taskQueue/taskSiteAnalysis.go
  50. 59 0
      service/test/shutdownStatistics/shutdownStatisticsTest.go
  51. 57 0
      service/test/temp/runtimeSort.go
  52. 61 12
      service/utils/httpClient/httpClient.go
  53. 33 0
      src/router/record.ts
  54. 15 0
      src/service/backgroundTask/index.ts
  55. 24 0
      src/service/backgroundTask/interface.ts
  56. 26 0
      src/service/batchDownload/index.ts
  57. 57 0
      src/service/batchDownload/interface.ts
  58. 25 0
      src/service/dashboard/index.ts
  59. 0 1
      src/service/dashboard/interface.ts
  60. 3 1
      src/service/serverReq.ts
  61. 31 0
      src/service/shutdownManage/index.ts
  62. 13 0
      src/service/shutdownManage/interface.ts
  63. 1 0
      src/service/sitesAnalysis/index.ts
  64. 2 0
      src/service/sitesAnalysis/interface.ts
  65. 56 0
      src/stores/modules/views/siteRuntimeChartConfig.ts
  66. 3 1
      src/types/components.d.ts
  67. 359 0
      src/views/background-task/index.vue
  68. 296 0
      src/views/batch-download/index.vue
  69. 39 36
      src/views/dashboard/component/infoCard.vue
  70. 88 235
      src/views/dashboard/index.vue
  71. 69 0
      src/views/shutdown-manage/index.vue
  72. 306 29
      src/views/sites-analysis/component/siteRuntimeChart.vue
  73. 2 1
      src/views/sites-analysis/component/siteTimeline.vue
  74. 15 30
      src/views/sites-analysis/index.vue
  75. 12 1
      src/views/sites-analysis/manage.vue
  76. 33 2
      src/views/sites-monitor/component/powerFlowChart.vue
  77. 2 2
      src/views/sites-monitor/index.vue
  78. 53 0
      vite.config.ts

+ 14 - 0
service/.vscode/settings.json

@@ -0,0 +1,14 @@
+{
+    "cSpell.words": [
+        "infof",
+        "infoln",
+        "warnf",
+        "warnln",
+        "errorf",
+        "errorln",
+        "gorm",
+        "redisx",
+        "robfig",
+        "mqtt",
+    ]
+}

+ 5 - 10
service/README.md

@@ -16,23 +16,18 @@ Go 后端服务,整合了:
 ├── cmd/
 │    └── main.go
 ├── config/             # 配置
-│    └── config.go
 ├── cache/              # 缓存封装(Redis)
-├── database/
-│    └── database.go    # GORM 初始化
-├── logger/
-│    └── logger.go
+├── database/           # GORM 数据库相关
+├── logger/             # zap Logger
 ├── scheduler/          # 任务调度
-│    └── scheduler.go
-├── routes/
-│    └── routes.go      # Fiber 路由 & swagger
+├── routes/             # Fiber 路由 & swagger   
 ├── controllers/        # 控制器
 ├── models/             # GORM 模型,数据结构定义(实体、DTO)
 ├── services/           # 业务逻辑(核心)
 ├── middleware/         # 中间件 (鉴权 / 日志 / 限流 / 恢复 panic)
 ├── repositories/       # 数据访问层(DB/Cache/外部 API)
-├── dto/                # 请求/响应 DTO (Data Transfer Object)
-├── domain/             # 领域对象 Domain Model
+├── dto/                # 请求/响应 DTO (Data Transfer Object) 对外接口类型
+├── domain/             # 领域对象 Domain Model 对内/模型类型
 ├── utils/              # 工具函数
 ├── pkg/                # 自定义通用包
 ├── docs/               # Swagger 文档

+ 0 - 326
service/Untitled-1

@@ -1,326 +0,0 @@
-// main.go
-package main
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"log"
-	"net/http"
-	"os"
-	"strconv"
-	"time"
-
-	"github.com/gofiber/fiber/v2"
-	"github.com/google/uuid"
-	"github.com/redis/go-redis/v9"
-)
-
-var (
-	ctx = context.Background()
-	rdb *redis.Client
-
-	// 从 env 加载
-	clientID     = os.Getenv("CASDOOR_CLIENT_ID")
-	clientSecret = os.Getenv("CASDOOR_CLIENT_SECRET")
-	authURL      = os.Getenv("CASDOOR_AUTHORIZE_URL") // e.g. https://door.example.com/login/oauth/authorize
-	tokenURL     = os.Getenv("CASDOOR_TOKEN_URL")     // e.g. https://door.example.com/api/login/oauth/access_token
-	redirectURI  = os.Getenv("APP_REDIRECT_URI")      // e.g. https://api.example.com/api/auth/callback
-	frontendURL  = os.Getenv("FRONTEND_URL")          // e.g. https://app.example.com
-	// session cookie name
-	sessionCookieName = "sid"
-	// session TTL (sync with refresh token validity; e.g. 7 days)
-	sessionTTL = 7 * 24 * time.Hour
-)
-
-type OAuthTokenResp struct {
-	AccessToken  string `json:"access_token"`
-	RefreshToken string `json:"refresh_token"`
-	ExpiresIn    int    `json:"expires_in"` // 秒
-	// 可能还有其他字段,按实际返回解析
-}
-
-/* ------------------- Redis helpers ------------------- */
-func initRedis() {
-	rdb = redis.NewClient(&redis.Options{
-		Addr:     "127.0.0.1:6379", // env 改造
-		Password: "",
-		DB:       0,
-	})
-	if _, err := rdb.Ping(ctx).Result(); err != nil {
-		log.Fatalf("redis connect error: %v", err)
-	}
-}
-
-func storeSession(sid string, token OAuthTokenResp) error {
-	key := fmt.Sprintf("session:%s", sid)
-	exp := time.Now().Add(sessionTTL)
-	accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
-	fields := map[string]interface{}{
-		"refresh_token": token.RefreshToken,
-		"access_token":  token.AccessToken,
-		"access_exp":    accessExp,
-	}
-	if err := rdb.HSet(ctx, key, fields).Err(); err != nil {
-		return err
-	}
-	return rdb.ExpireAt(ctx, key, exp).Err()
-}
-
-func getSession(sid string) (map[string]string, error) {
-	key := fmt.Sprintf("session:%s", sid)
-	res, err := rdb.HGetAll(ctx, key).Result()
-	if err != nil {
-		return nil, err
-	}
-	if len(res) == 0 {
-		return nil, errors.New("no session")
-	}
-	return res, nil
-}
-
-func deleteSession(sid string) error {
-	key := fmt.Sprintf("session:%s", sid)
-	return rdb.Del(ctx, key).Err()
-}
-
-/* ------------------- Casdoor interaction helpers ------------------- */
-
-// 标准 OAuth2 token exchange
-func exchangeCodeForToken(code string) (OAuthTokenResp, error) {
-	form := map[string]string{
-		"grant_type":    "authorization_code",
-		"code":          code,
-		"redirect_uri":  redirectURI,
-		"client_id":     clientID,
-		"client_secret": clientSecret,
-	}
-	return postTokenForm(form)
-}
-
-func refreshWithRefreshToken(refreshToken string) (OAuthTokenResp, error) {
-	form := map[string]string{
-		"grant_type":    "refresh_token",
-		"refresh_token": refreshToken,
-		"client_id":     clientID,
-		"client_secret": clientSecret,
-	}
-	return postTokenForm(form)
-}
-
-func postTokenForm(form map[string]string) (OAuthTokenResp, error) {
-	bodyBytes, _ := json.Marshal(form)
-	req, _ := http.NewRequest("POST", tokenURL, bytes.NewBuffer(bodyBytes))
-	req.Header.Set("Content-Type", "application/json") // 某些 Casdoor 期望 application/x-www-form-urlencoded:根据 Casdoor 实际调整
-	client := &http.Client{Timeout: 10 * time.Second}
-	resp, err := client.Do(req)
-	if err != nil {
-		return OAuthTokenResp{}, err
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode >= 400 {
-		return OAuthTokenResp{}, fmt.Errorf("token endpoint returned %d", resp.StatusCode)
-	}
-	var tr OAuthTokenResp
-	if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
-		return OAuthTokenResp{}, err
-	}
-	return tr, nil
-}
-
-/* ------------------- Handlers ------------------- */
-
-// GET /api/auth/login -> 重定向到 Casdoor authorize URL
-func loginRedirect(c *fiber.Ctx) error {
-	// state 可用于防 CSRF
-	state := uuid.NewString()
-	// 保存 state -> 可以放到 Redis 验证,不过此处省略
-	u := fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&state=%s", authURL, clientID, redirectURI, state)
-	return c.Redirect(u, fiber.StatusFound)
-}
-
-// GET /api/auth/callback?code=...&state=...
-func callbackHandler(c *fiber.Ctx) error {
-	code := c.Query("code")
-	if code == "" {
-		return c.Status(400).SendString("missing code")
-	}
-
-	// 用 code 换 token
-	tokenResp, err := exchangeCodeForToken(code)
-	if err != nil {
-		log.Println("exchange code err:", err)
-		return c.Status(500).JSON(fiber.Map{"error": "token exchange failed"})
-	}
-
-	// 生成 sid 并存 Redis(只存 refresh_token,access_token 可以缓存)
-	sid := uuid.NewString()
-	if err := storeSession(sid, tokenResp); err != nil {
-		log.Println("store session err:", err)
-	}
-
-	// 设置 HttpOnly cookie 保存 sid
-	c.Cookie(&fiber.Cookie{
-		Name:     sessionCookieName,
-		Value:    sid,
-		Expires:  time.Now().Add(sessionTTL),
-		HTTPOnly: true,
-		Secure:   false, // production: true
-		SameSite: "Lax",
-		Path:     "/",
-	})
-
-	// 最后重定向回前端单页 app
-	return c.Redirect(frontendURL, fiber.StatusFound)
-}
-
-// GET /api/auth/session  -> 前端载入时调用,返回 access_token(若存在)
-func sessionHandler(c *fiber.Ctx) error {
-	sid := c.Cookies(sessionCookieName)
-	if sid == "" {
-		return c.Status(200).JSON(fiber.Map{"authenticated": false})
-	}
-	sess, err := getSession(sid)
-	if err != nil {
-		// session 不存在或已过期
-		// 清除 cookie
-		c.Cookie(&fiber.Cookie{
-			Name:     sessionCookieName,
-			Value:    "",
-			Expires:  time.Now().Add(-time.Hour),
-			HTTPOnly: true,
-			Secure:   false,
-			Path:     "/",
-		})
-		return c.Status(200).JSON(fiber.Map{"authenticated": false})
-	}
-	// 若 access_token 仍有效则直接返回
-	access := sess["access_token"]
-	accessExpStr := sess["access_exp"]
-	if access != "" && accessExpStr != "" {
-		accessExp, _ := strconv.ParseInt(accessExpStr, 10, 64)
-		if time.Now().Unix() < accessExp {
-			return c.JSON(fiber.Map{"authenticated": true, "access_token": access})
-		}
-	}
-
-	// access 已过期 -> 试着用 refresh token 刷新(后端直接刷新并返回)
-	refreshToken := sess["refresh_token"]
-	if refreshToken == "" {
-		return c.Status(200).JSON(fiber.Map{"authenticated": false})
-	}
-	newTok, err := refreshWithRefreshToken(refreshToken)
-	if err != nil {
-		// refresh 失败 -> session 失效
-		_ = deleteSession(sid)
-		c.Cookie(&fiber.Cookie{
-			Name:     sessionCookieName,
-			Value:    "",
-			Expires:  time.Now().Add(-time.Hour),
-			HTTPOnly: true,
-			Secure:   false,
-			Path:     "/",
-		})
-		return c.Status(200).JSON(fiber.Map{"authenticated": false})
-	}
-
-	// 更新 session(rotate)
-	if err := storeSession(sid, newTok); err != nil {
-		log.Println("update session err:", err)
-	}
-	return c.JSON(fiber.Map{"authenticated": true, "access_token": newTok.AccessToken})
-}
-
-// POST /api/auth/refresh -> 前端拦截器触发(带 sid cookie),后端用存在 Redis 的 refresh_token 调 Casdoor 刷新
-func refreshHandler(c *fiber.Ctx) error {
-	sid := c.Cookies(sessionCookieName)
-	if sid == "" {
-		return c.Status(401).JSON(fiber.Map{"error": "no session"})
-	}
-	sess, err := getSession(sid)
-	if err != nil {
-		return c.Status(401).JSON(fiber.Map{"error": "session not found"})
-	}
-	rt := sess["refresh_token"]
-	if rt == "" {
-		return c.Status(401).JSON(fiber.Map{"error": "no refresh token"})
-	}
-
-	newTok, err := refreshWithRefreshToken(rt)
-	if err != nil {
-		// refresh token 也过期或无效
-		_ = deleteSession(sid)
-		c.Cookie(&fiber.Cookie{
-			Name:     sessionCookieName,
-			Value:    "",
-			Expires:  time.Now().Add(-time.Hour),
-			HTTPOnly: true,
-			Secure:   false,
-			Path:     "/",
-		})
-		return c.Status(401).JSON(fiber.Map{"error": "refresh failed"})
-	}
-
-	// rotate: 更新 Redis 存储(用同 sid 存新 refresh token + new access)
-	if err := storeSession(sid, newTok); err != nil {
-		log.Println("store session after refresh err:", err)
-	}
-
-	// 返回新的 access token 给前端(前端把它放内存)
-	return c.JSON(fiber.Map{
-		"access_token": newTok.AccessToken,
-		"expires_in":   newTok.ExpiresIn,
-	})
-}
-
-// POST /api/auth/logout
-func logoutHandler(c *fiber.Ctx) error {
-	sid := c.Cookies(sessionCookieName)
-	if sid != "" {
-		_ = deleteSession(sid)
-	}
-	// 清除 cookie
-	c.Cookie(&fiber.Cookie{
-		Name:     sessionCookieName,
-		Value:    "",
-		Expires:  time.Now().Add(-time.Hour),
-		HTTPOnly: true,
-		Secure:   false,
-		Path:     "/",
-	})
-	return c.JSON(fiber.Map{"ok": true})
-}
-
-/* ------------------- main ------------------- */
-func main() {
-	// 检查必需 env
-	if clientID == "" || clientSecret == "" || authURL == "" || tokenURL == "" || redirectURI == "" || frontendURL == "" {
-		log.Println("Please set CASDOOR_CLIENT_ID, CASDOOR_CLIENT_SECRET, CASDOOR_AUTHORIZE_URL, CASDOOR_TOKEN_URL, APP_REDIRECT_URI, FRONTEND_URL")
-		// 继续运行以便开发测试,但最好中断
-	}
-
-	initRedis()
-
-	app := fiber.New()
-
-	app.Get("/api/auth/login", loginRedirect)
-	app.Get("/api/auth/callback", callbackHandler)
-	app.Get("/api/auth/session", sessionHandler)
-	app.Post("/api/auth/refresh", refreshHandler)
-	app.Post("/api/auth/logout", logoutHandler)
-
-	// example protected endpoint: 前端带 access_token in Authorization header
-	app.Get("/api/me", func(c *fiber.Ctx) error {
-		auth := c.Get("Authorization")
-		if auth == "" {
-			return c.Status(401).JSON(fiber.Map{"error": "missing token"})
-		}
-		// 这里可以验证 Casdoor 的 access token 或调用 Casdoor 的 userinfo endpoint
-		// 为示例我们只是回显(生产需校验)
-		return c.JSON(fiber.Map{"token": auth})
-	})
-
-	log.Fatal(app.Listen(":3000"))
-}

+ 25 - 0
service/cache/cache.go

@@ -152,11 +152,36 @@ func XReadGroup(ctx context.Context, group, consumer string, streams map[string]
 	return redisx.XReadGroup(ctx, group, consumer, streams, count)
 }
 
+// 从消费者组读取滞留消息
+func XAutoClaim(ctx context.Context, stream string, group string, consumer string, idle time.Duration, count int64) (messages []redis.XMessage, start string, err error) {
+	return redisx.XAutoClaim(ctx, stream, group, consumer, idle, count)
+}
+
+// 从消费者组读取指定ID的Pending状态信息
+func XPendingExtOne(ctx context.Context, stream string, group string, consumer string, id string) (*redis.XPendingExt, error) {
+	return redisx.XPendingExtOne(ctx, stream, group, consumer, id)
+}
+
 // 确认消息
 func XAck(ctx context.Context, stream, group string, ids ...string) error {
 	return redisx.XAck(ctx, stream, group, ids...)
 }
 
+// 确认同时删除消息
+func XAckDel(ctx context.Context, stream, group string, ids ...string) error {
+	return redisx.XAckDel(ctx, stream, group, ids...)
+}
+
+// 查询范围内指定的消息信息
+func XRangeN(ctx context.Context, stream, start, stop string, count int64) ([]redis.XMessage, error) {
+	return redisx.XRangeN(ctx, stream, start, stop, count)
+}
+
+// 获取完整的Stream信息
+func XInfoStreamFull(ctx context.Context, key string, count int) (*redis.XInfoStreamFull, error) {
+	return redisx.XInfoStreamFull(ctx, key, count)
+}
+
 /********* 分布式锁操作 *********/
 
 type RedisLock = redisx.RedisLock

+ 55 - 0
service/cache/redisx/streamx.go

@@ -44,11 +44,66 @@ func XReadGroup(ctx context.Context, group, consumer string, streams map[string]
 	return getClient().XReadGroup(ctx, args).Result()
 }
 
+// 从消费者组读取滞留消息
+func XAutoClaim(ctx context.Context, stream string, group string, consumer string, idle time.Duration, count int64) (messages []redis.XMessage, start string, err error) {
+	args := &redis.XAutoClaimArgs{
+		Stream:   stream,
+		Group:    group,
+		Consumer: consumer,
+		Start:    "-", // 从最小ID开始
+		Count:    count,
+		MinIdle:  idle,
+	}
+	return getClient().XAutoClaim(ctx, args).Result()
+}
+
+// 从消费者组读取指定ID的Pending状态信息
+func XPendingExtOne(ctx context.Context, stream string, group string, consumer string, id string) (*redis.XPendingExt, error) {
+	args := &redis.XPendingExtArgs{
+		Stream:   stream,
+		Group:    group,
+		Consumer: consumer,
+		Count:    1,
+		Idle:     0,
+		Start:    id,
+		End:      id,
+	}
+	res, err := getClient().XPendingExt(ctx, args).Result()
+	if err != nil {
+		return nil, err
+	}
+	if len(res) == 0 {
+		return nil, nil
+	}
+	return &res[0], nil
+}
+
 // 确认消息
 func XAck(ctx context.Context, stream, group string, ids ...string) error {
 	return getClient().XAck(ctx, stream, group, ids...).Err()
 }
 
+// 确认同时删除消息
+func XAckDel(ctx context.Context, stream, group string, ids ...string) error {
+	// 版本不支持合并写法
+	// return getClient().XAckDel(ctx, stream, group, "DEL", ids...).Err()
+	err := getClient().XAck(ctx, stream, group, ids...).Err()
+	if err != nil {
+		return err
+	}
+	return getClient().XDel(ctx, stream, ids...).Err()
+}
+
+// 查询范围内指定的消息信息
+func XRangeN(ctx context.Context, stream, start, stop string, count int64) ([]redis.XMessage, error) {
+	return getClient().XRangeN(ctx, stream, start, stop, count).Result()
+}
+
+// 获取完整的Stream信息
+func XInfoStreamFull(ctx context.Context, key string, count int) (*redis.XInfoStreamFull, error) {
+	return getClient().XInfoStreamFull(ctx, key, count).Result()
+}
+
 func flattenStreams(m map[string]string) []string {
 	arr := make([]string, 0, len(m)*2)
 	for k, v := range m {

+ 21 - 0
service/controllers/backgroundTaskControllers.go

@@ -0,0 +1,21 @@
+/*
+ * Background Task API List
+ * /backgroundTask/info - GET 后台任务信息
+ */
+package controllers
+
+import (
+	"fwh-toolbox-server/services"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// 获取后台任务信息
+// /backgroundTask/info
+func GetBackgroundTaskList(c *fiber.Ctx) error {
+	listPage, err := services.GetBackgroundTaskInfoList()
+	if err != nil {
+		return Error(c, "Database read failed. (MongoDb)")
+	}
+	return Success(c, listPage)
+}

+ 40 - 0
service/controllers/shutdownManageControllers.go

@@ -0,0 +1,40 @@
+/*
+ * Shutdown Manage API List
+ * /shutdownManage/date - GET shutdown信息最新日期
+ * /shutdownManage/infoMap - GET shutdown信息数据
+ */
+package controllers
+
+import (
+	"fwh-toolbox-server/services"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// 获取shutdown统计最新日期
+// /shutdownManage/date
+func GetShutdownManageDate(c *fiber.Ctx) error {
+	date, err := services.GetShutdownStatisticsDate()
+	if err != nil {
+		return Error(c, "Shutdown statistics date read failed.")
+	}
+	return Success(c, date)
+}
+
+// 获取shutdown信息数据
+// /shutdownManage/infoMap
+func GetShutdownInfoMap(c *fiber.Ctx) error {
+	date := c.Query("date", "")
+	var pDate *string = nil
+	if date != "" {
+		pDate = &date
+	}
+
+	data, err := services.GetShutdownInfoMap(pDate)
+	if err != nil {
+		return Error(c, "Shutdown statistics read failed.")
+	}
+
+	// 转换数据输出结构
+	return Success(c, data.SiteMap)
+}

+ 72 - 0
service/controllers/shutdownStatisticsControllers.go

@@ -0,0 +1,72 @@
+/*
+ * Shutdown Statistics API List
+ * /dashboard/shutdownStatistics/date - GET shutdown统计最新日期
+ * /dashboard/shutdownStatistics - GET shutdown统计
+ */
+package controllers
+
+import (
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/services"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// 获取shutdown统计最新日期
+// /dashboard/shutdownStatistics/date
+func GetShutdownStatisticsDate(c *fiber.Ctx) error {
+	date, err := services.GetShutdownStatisticsDate()
+	if err != nil {
+		return Error(c, "Shutdown statistics date read failed.")
+	}
+	return Success(c, date)
+}
+
+// 获取shutdown统计
+// /dashboard/shutdownStatistics
+func GetShutdownStatistics(c *fiber.Ctx) error {
+	date := c.Query("date", "")
+	var pDate *string = nil
+	if date != "" {
+		pDate = &date
+	}
+
+	// 提取月份
+	t, err := time.Parse(time.DateOnly, date)
+	if err != nil {
+		return Fail(c, "Invalid request para date.")
+	}
+	month := t.Month()
+
+	data, err := services.GetShutdownStatistics(pDate)
+	if err != nil {
+		return Error(c, "Shutdown statistics read failed.")
+	}
+
+	// 转换数据输出结构
+	return Success(c, dto.ShutdownStatisticsInfo{
+		Date: data.Date,
+		DayTotal: dto.ShutdownStatisticsDayItem{
+			Count:      data.Weekly.ALL[6],
+			Growth:     int(int64(data.Weekly.ALL[6]) - int64(data.Weekly.ALL[5])),
+			Weekly:     data.Weekly.ALL,
+			MonthCount: data.Yearly.ALL[month-1],
+			Year:       data.Yearly.ALL,
+		},
+		UsTotal: dto.ShutdownStatisticsDayItem{
+			Count:      data.Weekly.US[6],
+			Growth:     int(int64(data.Weekly.US[6]) - int64(data.Weekly.US[5])),
+			Weekly:     data.Weekly.US,
+			MonthCount: data.Yearly.US[month-1],
+			Year:       data.Yearly.US,
+		},
+		AuTotal: dto.ShutdownStatisticsDayItem{
+			Count:      data.Weekly.AU[6],
+			Growth:     int(int64(data.Weekly.AU[6]) - int64(data.Weekly.AU[5])),
+			Weekly:     data.Weekly.AU,
+			MonthCount: data.Yearly.AU[month-1],
+			Year:       data.Yearly.AU,
+		},
+	})
+}

+ 11 - 5
service/controllers/sitesAnalysisControllers.go

@@ -28,18 +28,24 @@ func GetSitesAnalysisList(c *fiber.Ctx) error {
 
 	sn := c.Query("sn", "")
 	progress := c.Query("progress", "")
+	trigger := c.Query("trigger", "")
 
-	var snP *string = nil
+	var p_sn *string = nil
 	if sn != "" {
-		snP = &sn
+		p_sn = &sn
 	}
 
-	var progressP *string = nil
+	var p_progress *string = nil
 	if progress != "" {
-		progressP = &progress
+		p_progress = &progress
 	}
 
-	listPage, err := services.GetSiteAnalysisManageList(uint(pageInt), uint(sizeInt), snP, progressP)
+	var p_trigger *string = nil
+	if trigger != "" {
+		p_trigger = &trigger
+	}
+
+	listPage, err := services.GetSiteAnalysisManageList(uint(pageInt), uint(sizeInt), p_sn, p_progress, p_trigger)
 	if err != nil {
 		return Error(c, "Database read failed. (MongoDb)")
 	}

+ 60 - 0
service/database/logger.go

@@ -0,0 +1,60 @@
+package database
+
+import (
+	"context"
+	"time"
+
+	"go.uber.org/zap"
+	"gorm.io/gorm/logger"
+)
+
+type GormZapLogger struct {
+	zap           *zap.SugaredLogger
+	level         logger.LogLevel
+	slowThreshold time.Duration
+}
+
+func NewGormZapLogger(zapLogger *zap.SugaredLogger, level logger.LogLevel) logger.Interface {
+	return &GormZapLogger{
+		zap:           zapLogger,
+		level:         level,
+		slowThreshold: 200 * time.Millisecond,
+	}
+}
+
+func (l *GormZapLogger) LogMode(level logger.LogLevel) logger.Interface {
+	l.level = level
+	return l
+}
+
+func (l *GormZapLogger) Info(ctx context.Context, msg string, data ...interface{}) {
+	if l.level >= logger.Info {
+		l.zap.Infof(msg, data...)
+	}
+}
+
+func (l *GormZapLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
+	if l.level >= logger.Warn {
+		l.zap.Warnf(msg, data...)
+	}
+}
+
+func (l *GormZapLogger) Error(ctx context.Context, msg string, data ...interface{}) {
+	if l.level >= logger.Error {
+		l.zap.Errorf(msg, data...)
+	}
+}
+
+func (l *GormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
+	elapsed := time.Since(begin)
+	sql, rows := fc()
+
+	switch {
+	case err != nil && l.level >= logger.Error:
+		l.zap.Errorf("[GORM] err=%v duration=%v rows=%v sql=%s", err, elapsed, rows, sql)
+	case elapsed > l.slowThreshold && l.level >= logger.Warn:
+		l.zap.Warnf("[GORM] slow sql duration=%v rows=%v sql=%s", elapsed, rows, sql)
+	case l.level >= logger.Info:
+		l.zap.Infof("[GORM] duration=%v rows=%v sql=%s", elapsed, rows, sql)
+	}
+}

+ 4 - 4
service/database/mongo.go

@@ -2,7 +2,7 @@ package database
 
 import (
 	"context"
-	"log"
+	zapLogger "fwh-toolbox-server/logger"
 	"strings"
 	"time"
 
@@ -19,17 +19,17 @@ func InitMongo(uri string) (*mongo.Client, error) {
 
 	client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
 	if err != nil {
-		log.Fatal("Mongo connect error:", err)
+		zapLogger.Sugar.Errorln("Mongo connect error: ", err)
 		return nil, err
 	}
 
 	if err := client.Ping(ctx, nil); err != nil {
-		log.Fatal("Mongo ping error:", err)
+		zapLogger.Sugar.Errorln("Mongo ping error: ", err)
 		return nil, err
 	}
 
 	MongoClient = client
-	log.Println("MongoDB connected:", uri)
+	zapLogger.Sugar.Infoln("MongoDB connected: ", uri)
 
 	parts := strings.Split(uri, "/")
 	dbName := parts[len(parts)-1] // 最后一段就是数据库名

+ 4 - 4
service/database/mysql.go

@@ -1,12 +1,11 @@
 package database
 
 import (
-	"log"
-
 	"gorm.io/driver/mysql"
 	"gorm.io/gorm"
 	"gorm.io/gorm/logger"
 
+	zapLogger "fwh-toolbox-server/logger"
 	"fwh-toolbox-server/models/fwh_pro/query_fwh_pro"
 )
 
@@ -14,10 +13,11 @@ var MySQL *gorm.DB
 
 func InitMySQL(dsn string) (*gorm.DB, error) {
 	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
-		Logger: logger.Default.LogMode(logger.Info),
+		// Logger: logger.Default.LogMode(logger.Info),
+		Logger: NewGormZapLogger(zapLogger.Sugar, logger.Info),
 	})
 	if err != nil {
-		log.Printf("failed to connect to MySQL: %v", err)
+		zapLogger.Sugar.Errorf("failed to connect to MySQL: %v", err)
 		return nil, err
 	}
 	MySQL = db

+ 4 - 3
service/database/pgsql.go

@@ -1,8 +1,8 @@
 package database
 
 import (
+	zapLogger "fwh-toolbox-server/logger"
 	"fwh-toolbox-server/models/model_pgsql/query_alarm_snapshot"
-	"log"
 
 	"gorm.io/driver/postgres"
 	"gorm.io/gorm"
@@ -13,10 +13,11 @@ var PgSQL *gorm.DB
 
 func InitPostgreSQL(dsn string) (*gorm.DB, error) {
 	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
-		Logger: logger.Default.LogMode(logger.Info),
+		// Logger: logger.Default.LogMode(logger.Info),
+		Logger: NewGormZapLogger(zapLogger.Sugar, logger.Info),
 	})
 	if err != nil {
-		log.Printf("failed to connect to PostgreSQL: %v", err)
+		zapLogger.Sugar.Errorf("failed to connect to PostgreSQL: %v", err)
 		return nil, err
 	}
 	PgSQL = db

+ 1 - 1
service/domain/fwhProDomain.go

@@ -43,7 +43,7 @@ type EventAlarmItem struct {
 
 type ShutdownRecord struct {
 	GatewayId    string `json:"gatewayId"`    // SN
-	ShutdownType int    `json:"shutdownType"` // 1-系统停机
+	ShutdownType int    `json:"shutdownType"` // 1-系统停机 2-aPower停机
 	Country      string `json:"country"`      // 国家
 	Province     string `json:"province"`     // 州/省
 	ActiveTime   string `json:"activeTime"`   // 激活时间

+ 1 - 1
service/domain/fwhProMqttDomain.go

@@ -225,7 +225,7 @@ type MqttLogItem struct {
 /********* LogData struct 定义 *********/
 
 type MqttLogContent[T any] struct {
-	CmdType   string `json:"cmdType"` // 日志类型 MqttCmdType
+	CmdType   int    `json:"cmdType"` // 日志类型 MqttCmdType
 	EquipNo   string `json:"equipNo"` // SN
 	Type      int    `json:"type"`    // 设备类型 0-设备网关IBG
 	TimeStamp int64  `json:"timeStamp"`

+ 21 - 2
service/domain/shutdownStatisticsDomain.go

@@ -21,10 +21,29 @@ type ShutdownStatisticsItem struct {
 	Id      *primitive.ObjectID               `bson:"_id,omitempty" json:"id,omitempty"`
 	Date    string                            `bson:"date" json:"date"`
 	Status  ShutdownStatisticsStatus          `bson:"status" json:"status"`
+	Count   ShutdownStatisticsArea[uint]      `bson:"count" json:"count"`
+	Country string                            `json:"country"`                // 国家
 	SiteMap map[string]ShutdownStatisticsInfo `bson:"siteMap" json:"siteMap"` // SN: Info
 }
 
 type ShutdownStatisticsInfo struct {
-	SiteAnalysisId   *primitive.ObjectID `bson:"siteAnalysisId" json:"siteAnalysisId"`
-	ShutdownTimeList []string            `bson:"shutdownTimeList" json:"shutdownTimeList"`
+	SiteAnalysisId   *primitive.ObjectID     `bson:"siteAnalysisId" json:"siteAnalysisId"`
+	ShutdownTimeList []ShutdownTimePointInfo `bson:"shutdownTimeList" json:"shutdownTimeList"`
+}
+
+type ShutdownTimePointInfo struct {
+	Time string `bson:"time" json:"time"`
+	Type int    `bson:"type" json:"type"` // 1-系统停机 2-aPower停机
+}
+
+type ShutdownStatisticsDashboard struct {
+	Date   string                           `bson:"date" json:"date"`
+	Weekly ShutdownStatisticsArea[[7]uint]  `bson:"weekly" json:"weekly"`
+	Yearly ShutdownStatisticsArea[[12]uint] `bson:"yearly" json:"yearly"`
+}
+
+type ShutdownStatisticsArea[T any] struct {
+	ALL T `bson:"all" json:"all"`
+	US  T `bson:"us" json:"us"`
+	AU  T `bson:"au" json:"au"`
 }

+ 2 - 0
service/domain/sitesAnalysisDomain.go

@@ -10,6 +10,7 @@ type SiteAnalysisInfo struct {
 	Description string             `bson:"description" json:"description"`
 	Item        []string           `bson:"item" json:"item"`
 	Progress    Progress           `bson:"progress" json:"progress"`
+	Trigger     *Trigger           `bson:"trigger,omitempty" json:"trigger,omitempty"`
 }
 
 type SiteAnalysisOptions struct {
@@ -33,6 +34,7 @@ type SiteAnalysisConfig struct {
 	Options     SiteAnalysisOptions `bson:"options"`
 	Item        []string            `bson:"item" json:"item"`
 	Progress    Progress            `bson:"progress" json:"progress"`
+	Trigger     Trigger             `bson:"trigger" json:"trigger"`
 	Data        SiteAnalysisData    `bson:"data" json:"data"`
 }
 

+ 7 - 0
service/domain/taskQueueDomain.go

@@ -1,6 +1,7 @@
 package domain
 
 type Progress string // 'pending' | 'running' | 'error' | 'complete'
+type Trigger string  // 'auto' | 'manual'
 
 // Progress枚举
 const (
@@ -9,3 +10,9 @@ const (
 	ProgressError    Progress = "error"
 	ProgressComplete Progress = "complete"
 )
+
+// Trigger枚举
+const (
+	TriggerAuto   Trigger = "auto"
+	TriggerManual Trigger = "manual"
+)

+ 8 - 0
service/dto/backgroundTaskDTO.go

@@ -0,0 +1,8 @@
+package dto
+
+type BackgroundTaskItem struct {
+	Id      string `json:"id"`
+	Status  string `json:"status"`
+	Retry   int64  `json:"retry"`   // 任务重试次数
+	Payload string `json:"payload"` // 任务传入参数信息
+}

+ 17 - 0
service/dto/shutdownStatisticsDTO.go

@@ -0,0 +1,17 @@
+package dto
+
+type ShutdownStatisticsInfo struct {
+	Date     string                    `json:"date"`     // 当前数据日期
+	DayTotal ShutdownStatisticsDayItem `json:"dayTotal"` // 日总数
+	UsTotal  ShutdownStatisticsDayItem `json:"usTotal"`  // 北美日总数
+	AuTotal  ShutdownStatisticsDayItem `json:"auTotal"`  // 澳洲日总数
+}
+
+type ShutdownStatisticsDayItem struct {
+	Count  uint    `json:"count"`
+	Growth int     `json:"growth"` // 增长量 增长(+)/下降(-)
+	Weekly [7]uint `json:"weekly"` // 周数据
+
+	MonthCount uint     `json:"monthCount"`
+	Year       [12]uint `json:"year"` // 年数据
+}

+ 11 - 1
service/go.mod

@@ -35,14 +35,17 @@ require (
 	github.com/andybalholm/brotli v1.1.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/fasthttp/websocket v1.5.3 // indirect
 	github.com/fsnotify/fsnotify v1.8.0 // indirect
+	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
 	github.com/go-openapi/jsonreference v0.19.6 // indirect
 	github.com/go-openapi/spec v0.20.4 // indirect
 	github.com/go-openapi/swag v0.19.15 // indirect
 	github.com/go-sql-driver/mysql v1.9.3 // indirect
 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/gofiber/websocket/v2 v2.2.1 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
@@ -54,14 +57,20 @@ require (
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/kavu/go_reuseport v1.5.0 // indirect
-	github.com/klauspost/compress v1.17.9 // indirect
+	github.com/klauspost/compress v1.18.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.11 // indirect
 	github.com/mailru/easyjson v0.7.6 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/minio/crc64nvme v1.0.2 // indirect
+	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/minio-go/v7 v7.0.95 // indirect
 	github.com/montanaflynn/stats v0.7.1 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+	github.com/philhofer/fwd v1.2.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/rs/xid v1.6.0 // indirect
 	github.com/sagikazarmark/locafero v0.7.0 // indirect
 	github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
 	github.com/sourcegraph/conc v0.3.0 // indirect
@@ -71,6 +80,7 @@ require (
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/swaggo/files/v2 v2.0.2 // indirect
 	github.com/swaggo/swag v1.16.4 // indirect
+	github.com/tinylib/msgp v1.3.0 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.51.0 // indirect
 	github.com/valyala/tcplisten v1.0.0 // indirect

+ 23 - 0
service/go.sum

@@ -24,12 +24,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 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/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=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
+github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
 github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
 github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -44,6 +48,8 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
 github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
 github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
 github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
 github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA=
@@ -82,6 +88,11 @@ github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+
 github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU=
 github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
+github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -105,11 +116,19 @@ github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwp
 github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
 github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
+github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
+github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
+github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
 github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
 github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
 github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
@@ -120,6 +139,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
 github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
 github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
@@ -150,6 +171,8 @@ github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
 github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
 github.com/tidwall/evio v1.0.8 h1:+M7lh83rL4KwEObDGtXP3J1wE5utH80LeaAhrKCGVfE=
 github.com/tidwall/evio v1.0.8/go.mod h1:MJhRp4iVVqx/n/5mJk77oKmSABVhC7yYykcJiKaFYYw=
+github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
+github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=

+ 23 - 6
service/jobs/analysisSiteData.go

@@ -63,14 +63,15 @@ func AnalysisSiteTimeline(id primitive.ObjectID, data SiteDataSet) error {
 			return err
 		}
 
-		var log_357 domain.MqttLogType_357
-		err = json.Unmarshal([]byte(log.LogData), &log_357)
+		var logData domain.MqttLogContent[domain.MqttLogType_357]
+		err = json.Unmarshal([]byte(log.LogData), &logData)
 		if err != nil {
-			zap.L().Error("357日志JSON数据格式错误", zap.String("data", log.LogData))
+			zap.L().Error("357日志JSON数据格式错误", zap.String("err", err.Error()), zap.String("data", log.LogData))
 			return err
 		}
 
 		// 判断操作类型 1-增加/编辑 2-删除 3-修改当前工作序列
+		log_357 := logData.DataArea
 		opt := log_357.Opt
 		var modeInfo *string
 		var modeData *string
@@ -102,7 +103,7 @@ func AnalysisSiteTimeline(id primitive.ObjectID, data SiteDataSet) error {
 				}
 			}
 		default:
-			zap.L().Error("357日志不支持的操作类型", zap.String("opt", strconv.Itoa(log_357.Opt)))
+			zap.L().Error("357日志不支持的操作类型", zap.String("opt", strconv.Itoa(log_357.Opt)), zap.Any("log_357", log_357))
 			return err
 		}
 
@@ -158,7 +159,7 @@ func AnalysisSiteTimeline(id primitive.ObjectID, data SiteDataSet) error {
 	}
 
 	// 数据时间排序
-	timelineSlice(&timelineItemList)
+	timelineSort(&timelineItemList)
 
 	// 最后将时间线数据存入数据库
 	if err := repositories.InsertSiteAnalysisTimeline(id, timelineItemList); err != nil {
@@ -169,7 +170,7 @@ func AnalysisSiteTimeline(id primitive.ObjectID, data SiteDataSet) error {
 	return nil
 }
 
-func timelineSlice(list *[]domain.TimelineItem) {
+func timelineSort(list *[]domain.TimelineItem) {
 	if list == nil {
 		return
 	}
@@ -200,6 +201,9 @@ func AnalysisSiteRuntime(id primitive.ObjectID, data SiteDataSet) error {
 		runtimeItemList = append(runtimeItemList, runtimeItem)
 	}
 
+	// 数据时间排序
+	runtimeSort(&runtimeItemList)
+
 	// 将运行时数据存入数据库
 	if err := repositories.InsertSiteAnalysisRuntime(id, runtimeItemList); err != nil {
 		zap.L().Error("插入站点分析runtime数据失败", zap.String("id", id.String()))
@@ -208,3 +212,16 @@ func AnalysisSiteRuntime(id primitive.ObjectID, data SiteDataSet) error {
 
 	return nil
 }
+
+func runtimeSort(list *[]domain.RuntimeItem) {
+	if list == nil {
+		return
+	}
+
+	// 排序
+	sort.Slice(*list, func(i, j int) bool {
+		ti, _ := time.Parse(time.DateTime, (*list)[i].Time)
+		tj, _ := time.Parse(time.DateTime, (*list)[j].Time)
+		return ti.Before(tj)
+	})
+}

+ 17 - 1
service/jobs/statisticsShutdown.go

@@ -33,10 +33,14 @@ func StatisticsShutdown(date string, data []domain.ShutdownRecord, analysisFunc
 func statisticsShutdown(date string, data []domain.ShutdownRecord, analysisFunc func(sn string, date string) (*primitive.ObjectID, error)) (*domain.ShutdownStatisticsItem, error) {
 	siteMap := make(map[string]domain.ShutdownStatisticsInfo)
 
+	var siteCount domain.ShutdownStatisticsArea[uint]
+	var country string
 	for _, d := range data {
 		sn := d.GatewayId
 		activeTime := d.ActiveTime
 		deviceTime := d.DeviceTime
+		shutdownType := d.ShutdownType
+		country = d.Country
 
 		// 解析为 time.Time
 		timeActive, err := time.Parse(time.DateTime, activeTime)
@@ -57,6 +61,13 @@ func statisticsShutdown(date string, data []domain.ShutdownRecord, analysisFunc
 
 		info := siteMap[sn]
 		if info.SiteAnalysisId == nil {
+			siteCount.ALL++
+			switch country {
+			case "美国":
+				siteCount.US++
+			case "澳大利亚":
+				siteCount.AU++
+			}
 			// 触发一次站点分析
 			var siteAnalysisId *primitive.ObjectID = nil
 			analysisId, err := analysisFunc(sn, date)
@@ -68,13 +79,18 @@ func statisticsShutdown(date string, data []domain.ShutdownRecord, analysisFunc
 
 			info.SiteAnalysisId = siteAnalysisId
 		}
-		info.ShutdownTimeList = append(info.ShutdownTimeList, deviceTime)
+		info.ShutdownTimeList = append(info.ShutdownTimeList, domain.ShutdownTimePointInfo{
+			Time: deviceTime,
+			Type: shutdownType,
+		})
 		siteMap[sn] = info
 	}
 
 	item := domain.ShutdownStatisticsItem{
 		Date:    date,
 		Status:  domain.ShutdownStatisticsComplete,
+		Count:   siteCount,
+		Country: country,
 		SiteMap: siteMap,
 	}
 

+ 35 - 3
service/logger/logger.go

@@ -14,10 +14,14 @@ func Init(env string) error {
 	opt := zap.AddStacktrace(zap.ErrorLevel) // error 日志打印堆栈信息
 
 	var err error
-	if env == "prod" {
-		Log, err = zap.NewProduction(opt)
+	if env == "pro" {
+		// Log, err = zap.NewProduction(opt)
+		Log, err = newProduction(opt)
+		log.Println("zap init newProduction")
 	} else {
-		Log, err = zap.NewDevelopment(opt)
+		// Log, err = zap.NewDevelopment(opt)
+		Log, err = newDevelopment(opt)
+		log.Println("zap init newDevelopment")
 	}
 
 	if err == nil {
@@ -31,3 +35,31 @@ func Init(env string) error {
 
 	return err
 }
+
+func newProduction(options ...zap.Option) (*zap.Logger, error) {
+	config := zap.Config{
+		Level:       zap.NewAtomicLevelAt(zap.WarnLevel),
+		Development: false,
+		Sampling: &zap.SamplingConfig{
+			Initial:    100,
+			Thereafter: 100,
+		},
+		Encoding:         "json",
+		EncoderConfig:    zap.NewProductionEncoderConfig(),
+		OutputPaths:      []string{"stderr"},
+		ErrorOutputPaths: []string{"stderr"},
+	}
+	return config.Build(options...)
+}
+
+func newDevelopment(options ...zap.Option) (*zap.Logger, error) {
+	config := zap.Config{
+		Level:            zap.NewAtomicLevelAt(zap.DebugLevel),
+		Development:      true,
+		Encoding:         "console",
+		EncoderConfig:    zap.NewDevelopmentEncoderConfig(),
+		OutputPaths:      []string{"stderr"},
+		ErrorOutputPaths: []string{"stderr"},
+	}
+	return config.Build(options...)
+}

+ 4 - 0
service/main.go

@@ -4,6 +4,7 @@ import (
 	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/config"
 	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/repositories"
 	"fwh-toolbox-server/taskQueue"
 	"fwh-toolbox-server/utils/casdoor"
 
@@ -43,6 +44,9 @@ func main() {
 	// 初始化缓存
 	cache.Init(cfg.RedisAddr)
 
+	// 初始化FwhApi
+	repositories.FwhHttpInit()
+
 	// 初始化计划任务
 	scheduler.Init()
 

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

@@ -8,9 +8,14 @@ import (
 )
 
 const (
-	timeout time.Duration = 30 // fwh请求超时30s
+	timeout time.Duration = 60 // fwh请求超时60s
 )
 
+// FwhHttpInit
+func FwhHttpInit() {
+	httpClient.RegisterLogin(reLogin)
+}
+
 // 仅用户登录或者不需要token的请求
 func FwhHttpGetWithoutToken(url string, params url.Values) ([]byte, error) {
 	t := timeout

+ 30 - 10
service/repositories/api/fwh_pro/fwh_eventAlarmLog.go

@@ -8,6 +8,8 @@ import (
 	"net/url"
 	"strconv"
 	"time"
+
+	"go.uber.org/zap"
 )
 
 type EventAlarmType int
@@ -88,6 +90,33 @@ func queryRealtimeAlarm(sn string) ([]byte, error) {
 }
 
 func queryEventOrAlarmListOnePage(sn string, startTime, endTime time.Time, t EventAlarmType) ([]domain.EventAlarmItem, error) {
+	// 三次请求尝试
+	for i := 0; i < 3; i++ {
+		eventLogRes, err := queryEventOrAlarmListOnePageExec(sn, startTime, endTime, t)
+		if err != nil {
+			zap.L().Warn("queryEventOrAlarmListOnePageExec failed and retry", zap.String("err", err.Error()))
+			continue
+		}
+
+		if eventLogRes.Code == 401 {
+			// TODO 尝试重新登录
+			return nil, errors.New("certification expired")
+		}
+
+		if eventLogRes.Code != 200 {
+			// return nil, fmt.Errorf("查询失败 code: %d, message: %s", eventLogRes.Code, eventLogRes.Message)
+			zap.L().Warn("queryEventOrAlarmListOnePage 查询失败 retry", zap.Int("code", eventLogRes.Code), zap.String("msg", eventLogRes.Message))
+			time.Sleep(time.Second * 5)
+			continue
+		}
+
+		return eventLogRes.Result, nil
+	}
+
+	return nil, errors.New("retry failed")
+}
+
+func queryEventOrAlarmListOnePageExec(sn string, startTime, endTime time.Time, t EventAlarmType) (*EventAlarmListResponse, error) {
 	startTimeStr := startTime.Format(time.DateTime)
 	endTimeStr := endTime.Format(time.DateTime)
 	// TEST
@@ -111,16 +140,7 @@ func queryEventOrAlarmListOnePage(sn string, startTime, endTime time.Time, t Eve
 		return nil, jsonErr
 	}
 
-	if eventLogRes.Code == 401 {
-		// TODO 尝试重新登录
-		return nil, errors.New("certification expired")
-	}
-
-	if eventLogRes.Code != 200 {
-		return nil, fmt.Errorf("查询失败 code: %d, message: %s", eventLogRes.Code, eventLogRes.Message)
-	}
-
-	return eventLogRes.Result, nil
+	return &eventLogRes, nil
 }
 
 // 从FWH获取事件或告警log数据

+ 21 - 12
service/repositories/api/fwh_pro/fwh_login.go

@@ -90,6 +90,7 @@ func isTokenExpired(token string) (bool, error) {
 
 	var payload map[string]interface{}
 	if err := json.Unmarshal(payloadBytes, &payload); err != nil {
+		zap.L().Error("token payload解析失败", zap.String("payload", string(payloadBytes)))
 		return false, err
 	}
 
@@ -106,18 +107,19 @@ func getLoginToken() string {
 	// 判断是否已经有token
 	if loginInfo.Token != "" {
 		// 判断token是否过期
-		expired, err := isTokenExpired(loginInfo.Token)
-		if err != nil {
-			zap.L().Error("loginInfo.Token 解析出错.", zap.String("err", err.Error()))
-			return ""
-		}
-
-		if expired {
-			zap.L().Info("fwh Token 已过期")
-		} else {
-			// Token 有效
-			return loginInfo.Token
-		}
+		// token.payload加密,无法判断过期,改为判断401
+		// expired, err := isTokenExpired(loginInfo.Token)
+		// if err != nil {
+		// 	zap.L().Error("loginInfo.Token 解析出错.", zap.String("token", loginInfo.Token), zap.String("err", err.Error()))
+		// 	return ""
+		// }
+
+		// if expired {
+		// 	zap.L().Info("fwh Token 已过期")
+		// } else {
+		// Token 有效
+		return loginInfo.Token
+		// }
 	}
 
 	// 重新登录
@@ -152,3 +154,10 @@ func ClearToken() {
 	loginInfo.UserId = 0
 	loginInfo.Token = ""
 }
+
+// 过期重新登录 - 提供给httpClient
+func reLogin() string {
+	// 重新登录
+	Login()
+	return loginInfo.Token
+}

+ 2 - 0
service/repositories/api/fwh_pro/fwh_mqttLog.go

@@ -126,6 +126,8 @@ func QueryMqttLogListOnTimeRange(sn string, cmdType domain.MqttCmdType, startTim
 				return nil, err
 			}
 
+			time.Sleep(time.Second) // 延时1s
+
 			mqttLogList = append(mqttLogList, mqttLogRes...)
 
 			// 判断是否未查完

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

@@ -24,7 +24,7 @@ type ShutdownListData struct {
 // 查询Shutdown站点列表 one page
 func querySiteShutdownListOnePage(date string, country int, page int) ([]byte, error) {
 	// 构造 query 参数
-	var params url.Values
+	params := url.Values{}
 	if country <= 0 {
 		params.Add("countryId", "") // 0-ALL
 	} else {

+ 12 - 2
service/repositories/mongo/sitesAnalysisRepo.go

@@ -13,7 +13,7 @@ import (
 )
 
 // 从数据库获取站点分析列表
-func GetSiteAnalysisManageList(page, size uint, sn, progress *string) (*dto.ResPage[domain.SiteAnalysisInfo], error) {
+func GetSiteAnalysisManageList(page, size uint, sn, progress, trigger *string) (*dto.ResPage[domain.SiteAnalysisInfo], error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
@@ -27,6 +27,9 @@ func GetSiteAnalysisManageList(page, size uint, sn, progress *string) (*dto.ResP
 	if progress != nil {
 		filter["progress"] = *progress
 	}
+	if trigger != nil {
+		filter["trigger"] = *trigger
+	}
 
 	findOptions := options.Find().
 		SetProjection(bson.M{
@@ -35,6 +38,7 @@ func GetSiteAnalysisManageList(page, size uint, sn, progress *string) (*dto.ResP
 			"description": 1,
 			"item":        1,
 			"progress":    1,
+			"trigger":     1,
 		}).
 		SetSkip(int64((page - 1) * size)).
 		SetLimit(int64(size))
@@ -86,10 +90,15 @@ func GetSiteAnalysisConfig(id primitive.ObjectID) (*domain.SiteAnalysisConfig, e
 }
 
 // 插入一条站点分析配置到数据库
-func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite) (primitive.ObjectID, error) {
+// auto - 是否是自动触发数据
+func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite, auto bool) (primitive.ObjectID, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
 	defer cancel()
 
+	trigger := domain.TriggerManual
+	if auto {
+		trigger = domain.TriggerAuto
+	}
 	config := domain.SiteAnalysisConfig{
 		Id:          writeConfig.Id,
 		Sn:          writeConfig.Sn,
@@ -98,6 +107,7 @@ func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite) (primi
 		Options:     writeConfig.Options,
 		Item:        []string{},
 		Progress:    domain.ProgressPending,
+		Trigger:     trigger,
 	}
 
 	// 生成Item标签

+ 55 - 10
service/repositories/mongo/statisticsShutdownRepo.go

@@ -24,16 +24,8 @@ func GetStatisticsShutdown(date string) (*domain.ShutdownStatisticsItem, error)
 		filter["date"] = date
 	}
 
-	findOptions := options.FindOne().
-		SetProjection(bson.M{
-			"_id":      1,
-			"date":     1,
-			"siteMap":  1,
-			"alarmMap": 1,
-		})
-
 	var item domain.ShutdownStatisticsItem
-	err := collection.FindOne(ctx, filter, findOptions).Decode(&item)
+	err := collection.FindOne(ctx, filter).Decode(&item)
 	if err != nil {
 		return nil, err
 	}
@@ -41,6 +33,55 @@ func GetStatisticsShutdown(date string) (*domain.ShutdownStatisticsItem, error)
 	return &item, nil
 }
 
+// 从数据库获取最新Shutdown统计数据
+func GetLatestStatisticsShutdown() (*domain.ShutdownStatisticsItem, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("StatisticsShutdown")
+
+	// 按 date 字段倒序排序(最新日期在最前)
+	opts := options.FindOne().SetSort(bson.D{{Key: "date", Value: -1}})
+
+	var result domain.ShutdownStatisticsItem
+	err := collection.FindOne(ctx, bson.D{}, opts).Decode(&result)
+	if err != nil {
+		return nil, err
+	}
+	return &result, nil
+}
+
+// 从数据库获取指定时间范围内的Shutdown统计数据
+func GetStatisticsShutdownByDateRange(startDate, endDate string) ([]domain.ShutdownStatisticsItem, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	collection := database.GetCollection("StatisticsShutdown")
+
+	var results []domain.ShutdownStatisticsItem
+
+	findOptions := options.Find().SetSort(bson.D{{Key: "date", Value: 1}})
+
+	filter := bson.M{
+		"date": bson.M{
+			"$gte": startDate, // 起始日期 >= startDate
+			"$lte": endDate,   // 结束日期 <= endDate
+		},
+	}
+
+	cursor, err := collection.Find(ctx, filter, findOptions) // 按日期升序
+	if err != nil {
+		return nil, err
+	}
+	defer cursor.Close(ctx)
+
+	if err = cursor.All(ctx, &results); err != nil {
+		return nil, err
+	}
+
+	return results, nil
+}
+
 // 插入或更新一条Shutdown统计数据到数据库
 func InsertStatisticsShutdown(data domain.ShutdownStatisticsItem) error {
 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -55,7 +96,11 @@ func InsertStatisticsShutdown(data domain.ShutdownStatisticsItem) error {
 	})
 
 	filter := bson.M{"date": data.Date}
-	update := bson.M{"$set": bson.M{"siteMap": data.SiteMap}}
+	update := bson.M{"$set": bson.M{
+		"status":  data.Status,
+		"count":   data.Count,
+		"siteMap": data.SiteMap,
+	}}
 	opts := options.Update().SetUpsert(true)
 
 	_, err := collection.UpdateOne(ctx, filter, update, opts)

+ 1 - 1
service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotData.go

@@ -1,4 +1,4 @@
-package alarmSnapshot
+package pgsql_alarmSnapshot
 
 import (
 	"context"

+ 1 - 1
service/repositories/pgsql/alarmSnapshot/siteAlarmSnapshotFileRecord.go

@@ -1,4 +1,4 @@
-package alarmSnapshot
+package pgsql_alarmSnapshot
 
 import (
 	"context"

+ 67 - 0
service/repositories/pgsql/original/siteAnalysisData.go

@@ -0,0 +1,67 @@
+package pgsql_original
+
+import (
+	"errors"
+	"fmt"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"time"
+
+	"go.uber.org/zap"
+	"gorm.io/datatypes"
+	"gorm.io/gorm"
+)
+
+type LogOriginalRecord struct {
+	ID      uint `gorm:"primaryKey"`
+	SN      string
+	CmdType string
+	Time    time.Time      `gorm:"type:timestamp without time zone"`
+	Data    datatypes.JSON `gorm:"type:jsonb"` // ✅ 指定 PostgreSQL JSONB 类型
+}
+
+type QuerySitesAnalysisRecord struct {
+	Name string
+	DB   *gorm.DB
+}
+
+func CreateSitesAnalysisRecordTable(name string) (*QuerySitesAnalysisRecord, error) {
+	tabName := "original." + name
+	// 动态建表
+	if !database.PgSQL.Migrator().HasTable(tabName) {
+		if err := database.PgSQL.Table(tabName).AutoMigrate(&LogOriginalRecord{}); err != nil {
+			zap.L().Error("AutoMigrate SitesAnalysisRecordTable failed", zap.String("err", err.Error()))
+			return nil, err
+		}
+	}
+
+	return &QuerySitesAnalysisRecord{
+		Name: tabName,
+		DB:   database.PgSQL.Table(tabName),
+	}, nil
+}
+
+func (query *QuerySitesAnalysisRecord) InsertSitesAnalysisRecord(data domain.MqttLogItem) error {
+	if query.DB == nil {
+		zap.L().Error("数据库未初始化,请先调用CreateSitesAnalysisRecordTable初始化数据库。")
+		return errors.New("please call CreateSitesAnalysisRecordTable to initialize the database first")
+	}
+
+	logTime, err := time.Parse(time.DateTime, data.LogTime)
+	if err != nil {
+		zap.L().Error("logTime格式错误", zap.String("time", data.LogTime), zap.String("err", err.Error()))
+		return fmt.Errorf("logTime格式错误 logTime=%s %w", data.LogTime, err)
+	}
+	record := LogOriginalRecord{
+		SN:      data.GatewayId,
+		CmdType: data.CmdType,
+		Time:    logTime,
+		Data:    datatypes.JSON(data.LogData),
+	}
+	if err := query.DB.Create(&record).Error; err != nil {
+		zap.L().Error("插入LogOriginalRecord数据失败", zap.String("err", err.Error()))
+		return fmt.Errorf("插入LogOriginalRecord数据失败 %w", err)
+	}
+
+	return nil
+}

+ 1 - 1
service/repositories/redis/authRepo.go

@@ -23,7 +23,7 @@ type SessionInfo struct {
 }
 
 func StoreSession(sid string, token *oauth2.Token) error {
-	key := fmt.Sprintf("session:%s", sid)
+	key := fmt.Sprintf("fwhtoolbox:session:%s", sid)
 	exp := time.Now().Add(domain.SessionTTL)
 	accessExp := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
 	fields := map[string]interface{}{

+ 14 - 4
service/repositories/repositories.go

@@ -3,7 +3,8 @@ package repositories
 import (
 	"fwh-toolbox-server/repositories/api/fwh_pro"
 	"fwh-toolbox-server/repositories/mongo"
-	"fwh-toolbox-server/repositories/pgsql/alarmSnapshot"
+	pgsql_alarmSnapshot "fwh-toolbox-server/repositories/pgsql/alarmSnapshot"
+	pgsql_original "fwh-toolbox-server/repositories/pgsql/original"
 	"fwh-toolbox-server/repositories/redis"
 )
 
@@ -19,6 +20,8 @@ const (
 	Alarm EventAlarmType = fwh_pro.Alarm // 1
 )
 
+var FwhHttpInit = fwh_pro.FwhHttpInit
+
 var QuerySiteBaseInfoList = fwh_pro.QuerySiteBaseInfoList
 var QuerySiteInfoList = fwh_pro.QuerySiteInfoList
 var QuerySiteModeInfo = fwh_pro.QuerySiteModeInfo
@@ -60,14 +63,21 @@ var InsertSiteAnalysisRuntime = mongo.InsertSiteAnalysisRuntime
 
 // StatisticsShutdown
 var GetStatisticsShutdown = mongo.GetStatisticsShutdown
+var GetLatestStatisticsShutdown = mongo.GetLatestStatisticsShutdown
+var GetStatisticsShutdownByDateRange = mongo.GetStatisticsShutdownByDateRange
 var InsertStatisticsShutdown = mongo.InsertStatisticsShutdown
 
 /*************** PGSQL ***************/
 
 // Alarm Snapshot
-var QuerySnapshotFileRecord = alarmSnapshot.QuerySnapshotFileRecord
-var InsertSnapshotFileRecord = alarmSnapshot.InsertSnapshotFileRecord
-var InsertSnapshotData = alarmSnapshot.InsertSnapshotData
+var QuerySnapshotFileRecord = pgsql_alarmSnapshot.QuerySnapshotFileRecord
+var InsertSnapshotFileRecord = pgsql_alarmSnapshot.InsertSnapshotFileRecord
+var InsertSnapshotData = pgsql_alarmSnapshot.InsertSnapshotData
+
+// Original
+type QuerySitesAnalysisRecord = pgsql_original.QuerySitesAnalysisRecord
+
+var CreateSitesAnalysisRecordTable = pgsql_original.CreateSitesAnalysisRecordTable
 
 /*************** MYSQL ***************/
 

+ 16 - 0
service/routes/backgroundTaskRoutes.go

@@ -0,0 +1,16 @@
+/*
+ * Background Task API List
+ * /backgroundTask/info - GET 后台任务信息
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func BackgroundTaskSetup(api fiber.Router) {
+	// 获取站点分析管理名称表
+	api.Get("/backgroundTask/info", controllers.GetBackgroundTaskList)
+}

+ 3 - 0
service/routes/routes.go

@@ -20,8 +20,11 @@ func Setup(app *fiber.App) {
 	// api.Get("/keyAccounts/sunrun/state", controllers.GetUsers)
 	// api.Get("/keyAccounts/sunrun/abnormalList", controllers.GetUsers)
 	// …
+	ShutdownStatisticsSetup(api)
+	ShutdownManageSetup(api)
 	SitesMonitorSetup(api)
 	SitesAnalysisSetup(api)
+	BackgroundTaskSetup(api)
 	XlogSetup(api)
 
 	app.Get("/swagger/*", swagger.HandlerDefault)

+ 20 - 0
service/routes/shutdownManageRoutes.go

@@ -0,0 +1,20 @@
+/*
+ * Shutdown Manage API List
+ * /shutdownManage/date - GET shutdown信息最新日期
+ * /shutdownManage/infoMap - GET shutdown信息数据
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func ShutdownManageSetup(api fiber.Router) {
+	// 获取shutdown统计最新日期
+	api.Get("/shutdownManage/date", controllers.GetShutdownManageDate)
+
+	// 获取shutdown信息数据
+	api.Get("/shutdownManage/infoMap", controllers.GetShutdownInfoMap)
+}

+ 20 - 0
service/routes/shutdownStatisticsRoutes.go

@@ -0,0 +1,20 @@
+/*
+ * Shutdown Statistics API List
+ * /dashboard/shutdownStatistics/date - GET shutdown统计最新日期
+ * /dashboard/shutdownStatistics - GET shutdown统计
+ */
+package routes
+
+import (
+	"fwh-toolbox-server/controllers"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+func ShutdownStatisticsSetup(api fiber.Router) {
+	// 获取shutdown统计最新日期
+	api.Get("/dashboard/shutdownStatistics/date", controllers.GetShutdownStatisticsDate)
+
+	// 获取shutdown统计
+	api.Get("/dashboard/shutdownStatistics", controllers.GetShutdownStatistics)
+}

+ 35 - 14
service/scheduler/scheduler.go

@@ -1,39 +1,60 @@
 package scheduler
 
 import (
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/taskQueue"
 	"log"
+	"time"
 
 	"github.com/robfig/cron/v3"
+	"go.uber.org/zap"
 )
 
 func Init() *cron.Cron {
 	c := cron.New()
 
-	c.AddFunc("@every 1h", func() {
-		log.Println("每小时任务执行")
+	// c.AddFunc("@every 1h", func() {
+	// 	log.Println("每小时任务执行")
+	// 	// do job
+	// })
+
+	c.AddFunc("30 10 * * *", func() {
+		log.Println("每天10:30任务执行")
+		// do job
+	})
+
+	c.AddFunc("10 12 * * *", func() {
+		log.Println("每天12:10任务执行")
 		// do job
 	})
 
 	// 每天 14:00 执行一次
 	// robfig/cron 默认支持 6 字段:秒 分 时 日 月 星期
 	// "0 0 14 * * *" 秒=0, 分=0, 时=14,每天执行
-	// c.AddFunc("0 0 14 * * *", func() {
-	// 	log.Println("每天 14:00 执行Shutdown统计")
+	c.AddFunc("35 15 * * *", func() {
+		log.Println("每天 15:35 执行Shutdown统计")
 
-	// 	// 获取当前时间
-	// 	now := time.Now()
+		// 获取当前时间
+		now := time.Now()
 
-	// 	// 计算前天(往前两天)
-	// 	beforeYesterday := now.AddDate(0, 0, -2)
+		// 计算前天(往前两天)
+		beforeYesterday := now.AddDate(0, 0, -2)
 
-	// 	// 格式化为字符串(按 yyyy-MM-dd)
-	// 	beforeYesterdayStr := beforeYesterday.Format(time.DateOnly)
+		// 格式化为字符串(按 yyyy-MM-dd)
+		beforeYesterdayStr := beforeYesterday.Format(time.DateOnly)
 
-	// 	taskQueue.EnqueueShutdownStatisticsTask(domain.TaskShutdownStatistics{
-	// 		Date: beforeYesterdayStr,
-	// 	})
-	// })
+		taskQueue.EnqueueShutdownStatisticsTask(domain.TaskShutdownStatistics{
+			Date: beforeYesterdayStr,
+		})
+	})
 
 	c.Start()
+	// 打印任务计划表
+	entries := c.Entries()
+	var entriesStr []string
+	for _, i := range entries {
+		entriesStr = append(entriesStr, i.Next.Format(time.DateTime))
+	}
+	zap.L().Debug("cron entries", zap.Any("entries", entriesStr))
 	return c
 }

+ 11 - 0
service/services/backgroundTaskServices.go

@@ -0,0 +1,11 @@
+package services
+
+import (
+	"fwh-toolbox-server/dto"
+	"fwh-toolbox-server/taskQueue"
+)
+
+// 从任务流获取后台任务信息
+func GetBackgroundTaskInfoList() ([]dto.BackgroundTaskItem, error) {
+	return taskQueue.GetTaskInfoList()
+}

+ 241 - 0
service/services/shutdownStatisticsServices.go

@@ -0,0 +1,241 @@
+package services
+
+import (
+	"context"
+	"fmt"
+	"fwh-toolbox-server/cache"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/repositories"
+	"time"
+
+	"go.uber.org/zap"
+)
+
+// 从数据库获取shutdown统计最新日期
+func GetShutdownStatisticsDate() (*string, error) {
+	return getDashboardDate()
+}
+
+// 从数据库获取shutdown统计
+func GetShutdownStatistics(date *string) (*domain.ShutdownStatisticsDashboard, error) {
+	currDate, err := getDashboardDate()
+	if err != nil {
+		return nil, err
+	}
+
+	var weekly *domain.ShutdownStatisticsArea[[7]uint]
+	var yearly *domain.ShutdownStatisticsArea[[12]uint]
+	if date == nil {
+		date = currDate
+	}
+
+	weekly, err = getDashboardWeekly(*date)
+	if err != nil {
+		return nil, err
+	}
+	yearly, err = getDashboardYearly(*date)
+	if err != nil {
+		return nil, err
+	}
+
+	return &domain.ShutdownStatisticsDashboard{
+		Date:   *date,
+		Weekly: *weekly,
+		Yearly: *yearly,
+	}, nil
+}
+
+// 从数据库获取shutdown统计信息详细数据
+func GetShutdownInfoMap(date *string) (*domain.ShutdownStatisticsItem, error) {
+	currDate, err := getDashboardDate()
+	if err != nil {
+		return nil, err
+	}
+
+	if date == nil {
+		date = currDate
+	}
+
+	return repositories.GetStatisticsShutdown(*date)
+}
+
+// 对Dashboard常规数据做缓存
+const (
+	RedisKeyDashboardDate   = "fwhtoolbox:dashboard:date"
+	RedisKeyDashboardWeekly = "fwhtoolbox:dashboard:weekly"
+	RedisKeyDashboardYearly = "fwhtoolbox:dashboard:yearly"
+)
+
+func getDashboardDate() (*string, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	var date string
+	err := cache.GetJSON(ctx, RedisKeyDashboardDate, &date)
+	if err == nil {
+		return &date, nil // 命中缓存
+	}
+
+	// 未命中缓存 → 查询数据库
+	shutdownData, err := repositories.GetLatestStatisticsShutdown()
+	if err != nil {
+		return nil, err
+	}
+	date = shutdownData.Date
+
+	// 缓存结果 24h
+	cache.SetJSON(ctx, RedisKeyDashboardDate, date, time.Hour*24)
+	return &date, nil
+}
+
+func getDashboardWeekly(date string) (*domain.ShutdownStatisticsArea[[7]uint], error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	// 未命中缓存 → 查询数据库
+	currDate, err := getDashboardDate()
+	if err != nil {
+		return nil, err
+	}
+
+	// 判断给定日期是否最新
+	if date == *currDate {
+		var data domain.ShutdownStatisticsArea[[7]uint]
+		err := cache.GetJSON(ctx, RedisKeyDashboardWeekly, &data)
+		if err == nil {
+			return &data, nil // 命中缓存
+		}
+	}
+
+	shutdownDataList, err := handleWeeklyData(*currDate)
+	if err != nil {
+		return nil, err
+	}
+
+	// 缓存结果,只缓存最新日期
+	if date == *currDate {
+		cache.SetJSON(ctx, RedisKeyDashboardWeekly, shutdownDataList, time.Hour*24*7)
+	}
+	return shutdownDataList, nil
+}
+
+func getDashboardYearly(date string) (*domain.ShutdownStatisticsArea[[12]uint], error) {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	// 未命中缓存 → 查询数据库
+	currDate, err := getDashboardDate()
+	if err != nil {
+		return nil, err
+	}
+
+	t_currDate, err := time.Parse(time.DateOnly, *currDate)
+	if err != nil {
+		return nil, err
+	}
+	t_date, err := time.Parse(time.DateOnly, date)
+	if err != nil {
+		return nil, err
+	}
+
+	// 判断是否同年
+	if t_currDate.Year() == t_date.Year() {
+		var data domain.ShutdownStatisticsArea[[12]uint]
+		err := cache.GetJSON(ctx, RedisKeyDashboardYearly, &data)
+		if err == nil {
+			return &data, nil // 命中缓存
+		}
+	}
+
+	currDateTime, err := time.Parse(time.DateOnly, *currDate)
+	if err != nil {
+		zap.L().Error("getDashboardYearly currDate日期格式错误", zap.String("currDate", *currDate))
+		return nil, err
+	}
+
+	shutdownDataList, err := handleYearlyData(currDateTime.Year())
+	if err != nil {
+		return nil, err
+	}
+
+	// 缓存结果
+	if t_currDate.Year() == t_date.Year() {
+		cache.SetJSON(ctx, RedisKeyDashboardYearly, shutdownDataList, time.Hour*24*365)
+	}
+	return shutdownDataList, nil
+}
+
+func handleWeeklyData(date string) (*domain.ShutdownStatisticsArea[[7]uint], error) {
+	timeDate, err := time.Parse(time.DateOnly, date)
+	if err != nil {
+		zap.L().Error("handleWeeklyData日期格式错误", zap.String("date", date))
+		return nil, err
+	}
+
+	// 计算6天前的日期
+	startDate := timeDate.AddDate(0, 0, -6).Format(time.DateOnly)
+	dataList, err := repositories.GetStatisticsShutdownByDateRange(startDate, date)
+	if err != nil {
+		zap.L().Error("GetStatisticsShutdownByDateRange error.", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	var res domain.ShutdownStatisticsArea[[7]uint]
+	for _, d := range dataList {
+		t, err := time.Parse(time.DateOnly, d.Date)
+		if err != nil {
+			zap.L().Error("handleWeeklyData日期格式错误", zap.String("date", d.Date))
+			return nil, err
+		}
+		diff := timeDate.Sub(t)
+		// 转为天数(取整)
+		days := int(diff.Hours() / 24)
+		idx := 6 - days
+		if idx < 0 || idx >= 7 {
+			continue
+		}
+
+		res.ALL[idx] = d.Count.ALL
+		res.US[idx] = d.Count.US
+		res.AU[idx] = d.Count.AU
+	}
+
+	return &res, nil
+}
+
+func handleYearlyData(year int) (*domain.ShutdownStatisticsArea[[12]uint], error) {
+	// 判断年份是否有效
+	if year < 2000 || year > (time.Now().Year()+1) {
+		return nil, fmt.Errorf("handleYearlyData年份不正确 year = %d", year)
+	}
+
+	var yearList domain.ShutdownStatisticsArea[[12]uint]
+	for month := 1; month <= 12; month++ {
+		// 每个月的第一天
+		start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.Local)
+
+		// 下个月的第一天再减1天 = 本月最后一天
+		end := start.AddDate(0, 1, -1)
+
+		// 获取月数据
+		dataList, err := repositories.GetStatisticsShutdownByDateRange(start.Format(time.DateOnly), end.Format(time.DateOnly))
+		if err != nil {
+			zap.L().Error("GetStatisticsShutdownByDateRange error.", zap.String("err", err.Error()))
+			return nil, err
+		}
+
+		// 计算月总数
+		var monthCount domain.ShutdownStatisticsArea[uint]
+		for _, d := range dataList {
+			monthCount.ALL = monthCount.ALL + d.Count.ALL
+			monthCount.US = monthCount.US + d.Count.US
+			monthCount.AU = monthCount.AU + d.Count.AU
+		}
+
+		yearList.ALL[month-1] = monthCount.ALL
+		yearList.US[month-1] = monthCount.US
+		yearList.AU[month-1] = monthCount.AU
+	}
+
+	return &yearList, nil
+}

+ 5 - 5
service/services/sitesAnalysisServices.go

@@ -10,8 +10,8 @@ import (
 )
 
 // 从数据库获取站点分析列表
-func GetSiteAnalysisManageList(page, size uint, sn, progress *string) (*dto.ResPage[domain.SiteAnalysisInfo], error) {
-	return repositories.GetSiteAnalysisManageList(page, size, sn, progress)
+func GetSiteAnalysisManageList(page, size uint, sn, progress, trigger *string) (*dto.ResPage[domain.SiteAnalysisInfo], error) {
+	return repositories.GetSiteAnalysisManageList(page, size, sn, progress, trigger)
 }
 
 // 从数据库获取一条站点分析配置
@@ -21,7 +21,7 @@ func GetSiteAnalysisConfig(id primitive.ObjectID) (*domain.SiteAnalysisConfig, e
 
 // 插入一条站点分析配置到数据库
 func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite) error {
-	id, err := repositories.InsertSiteAnalysisConfig(writeConfig)
+	id, err := repositories.InsertSiteAnalysisConfig(writeConfig, false)
 	if err != nil {
 		return err
 	}
@@ -34,7 +34,7 @@ func InsertSiteAnalysisConfig(writeConfig domain.SiteAnalysisConfigWrite) error
 			EnableTimeline: writeConfig.Options.EnableTimeline,
 			EnableRuntime:  writeConfig.Options.EnableRuntime,
 		},
-	})
+	}, false)
 
 	return nil
 }
@@ -54,7 +54,7 @@ func UpdateSiteAnalysisConfig(id primitive.ObjectID, writeConfig domain.SiteAnal
 			EnableTimeline: writeConfig.Options.EnableTimeline,
 			EnableRuntime:  writeConfig.Options.EnableRuntime,
 		},
-	})
+	}, false)
 
 	return nil
 }

+ 184 - 69
service/taskQueue/taskQueue.go

@@ -6,17 +6,30 @@ import (
 	"fmt"
 	"fwh-toolbox-server/cache"
 	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
 	"fwh-toolbox-server/logger"
+	"slices"
 	"time"
 
 	"github.com/redis/go-redis/v9"
+	"go.uber.org/zap"
 )
 
 var (
 	ctx        = context.Background()
-	streamName = "fwhtoolbox_stream"
+	streamName = "fwhtoolbox:taskQueueStream"
 	groupName  = "task_queue"
 	consumerID = "worker-taskQueue"
+
+	// retryConsumerID = "retry-taskQueue"
+	retryConsumerID = consumerID
+	maxRetry        = 3
+	idleTime        = 10 * time.Minute
+
+	// 状态管理相关变量
+	runningID      = ""                 // 当前执行的任务ID
+	failedTaskList = []string{}         // 失败且未处理任务ID列表
+	errorTaskList  = []redis.XMessage{} // 超过重试次数仍失败的任务列表
 )
 
 type TaskStreamInfo struct {
@@ -49,103 +62,156 @@ func startTaskQueueWorker() {
 	streamsMap[streamName] = ">"
 
 	for {
+		// 处理新任务
 		res, err := cache.XReadGroup(ctx, groupName, consumerID, streamsMap, 1) // 等待任务
-
 		if err != nil {
 			if err == redis.Nil {
-				continue
-			}
-			logger.Std.Println("XReadGroup error:", err)
-			time.Sleep(time.Second)
-			continue
-		}
-
-		for _, stream := range res {
-			for _, message := range stream.Messages {
-				id := message.ID
-				rawPayload := message.Values["payload"].(string)
-
-				// 先反序列化成 map 确定 type
-				var base TaskPayloadBase
-				if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
-					ackTask(TaskStreamInfo{
-						Ctx:        ctx,
-						StreamName: streamName,
-						GroupName:  groupName,
-						ID:         id,
-					}, false, fmt.Errorf("unmarshal base error - %s", err.Error()))
+				// 未读取到新消息
+				// 处理滞留的消息
+				retryRes, _, err := cache.XAutoClaim(ctx, streamName, groupName, retryConsumerID, idleTime, 1)
+				if err != nil {
+					if err == redis.Nil {
+						continue
+					}
+					logger.Std.Println("XAutoClaim error:", err)
+					time.Sleep(time.Second)
 					continue
 				}
 
-				switch base.Type {
-				case TaskTypeSiteAnalysis: // 站点分析任务
-					var payload TaskPayload[domain.TaskSiteAnalysis]
-					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()))
-						continue
-					}
-					logger.Std.Printf("Processing task %s: %+v\n", id, payload)
-					if err := handleTaskSiteAnalysis(ctx, payload.Payload); err != nil {
-						ackTask(TaskStreamInfo{
-							Ctx:        ctx,
-							StreamName: streamName,
-							GroupName:  groupName,
-							ID:         id,
-						}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
-						continue
-					}
-				case TaskTypeShutdownStatistics: // Shutdown统计任务
-					var payload TaskPayload[domain.TaskShutdownStatistics]
-					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()))
-						continue
+				for _, msg := range retryRes {
+					pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
+					if err != nil {
+						logger.Std.Println("XPendingExtOne error:", err)
+					} else if pendingRes == nil {
+						panic("无法读取XPendingExtOne")
 					}
-					logger.Std.Printf("Processing task %s: %+v\n", id, payload)
-					if err := handleTaskAlarmStatistics(ctx, payload.Payload); err != nil {
+
+					// 判断重试次数
+					if pendingRes.RetryCount > int64(maxRetry) {
+						if slices.Contains(failedTaskList, msg.ID) {
+							failedTaskList = slices.DeleteFunc(failedTaskList, func(s string) bool { return s == msg.ID })
+						}
+						errorTaskList = append(errorTaskList, msg)
 						ackTask(TaskStreamInfo{
 							Ctx:        ctx,
 							StreamName: streamName,
 							GroupName:  groupName,
-							ID:         id,
-						}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+							ID:         msg.ID,
+						}, true, fmt.Errorf("任务超过最大重试次数。"))
 						continue
 					}
-				default:
-					logger.Std.Println("Unknown task type:", base.Type)
+
+					zap.L().Warn("taskQueue任务重试。", zap.String("ID", msg.ID), zap.Int64("count", pendingRes.RetryCount))
+
+					runningID = msg.ID
+					handleTaskQueueMsg(msg)
 				}
+				continue
+			}
+			logger.Std.Println("XReadGroup error:", err)
+			time.Sleep(time.Second)
+			continue
+		}
 
-				// 任务完成
-				ackTask(TaskStreamInfo{
-					Ctx:        ctx,
-					StreamName: streamName,
-					GroupName:  groupName,
-					ID:         id,
-				}, true, nil)
+		for _, stream := range res {
+			for _, message := range stream.Messages {
+				runningID = message.ID
+				handleTaskQueueMsg(message)
 			}
 		}
 	}
 }
 
+// 处理单条消息
+func handleTaskQueueMsg(message redis.XMessage) {
+	id := message.ID
+	rawPayload := message.Values["payload"].(string)
+
+	// 先反序列化成 map 确定 type
+	var base TaskPayloadBase
+	if err := json.Unmarshal([]byte(rawPayload), &base); err != nil {
+		ackTask(TaskStreamInfo{
+			Ctx:        ctx,
+			StreamName: streamName,
+			GroupName:  groupName,
+			ID:         id,
+		}, false, fmt.Errorf("unmarshal base error - %s", err.Error()))
+		return
+	}
+
+	switch base.Type {
+	case TaskTypeSiteAnalysis: // 站点分析任务
+		var payload TaskPayload[domain.TaskSiteAnalysis]
+		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 := handleTaskSiteAnalysis(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+			return
+		}
+	case TaskTypeShutdownStatistics: // Shutdown统计任务
+		var payload TaskPayload[domain.TaskShutdownStatistics]
+		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 := handleTaskAlarmStatistics(ctx, payload.Payload); err != nil {
+			ackTask(TaskStreamInfo{
+				Ctx:        ctx,
+				StreamName: streamName,
+				GroupName:  groupName,
+				ID:         id,
+			}, false, fmt.Errorf("handle task SiteAnalysis error - %s", err.Error()))
+			return
+		}
+	default:
+		logger.Std.Println("Unknown task type:", base.Type)
+	}
+
+	// 任务完成
+	ackTask(TaskStreamInfo{
+		Ctx:        ctx,
+		StreamName: streamName,
+		GroupName:  groupName,
+		ID:         id,
+	}, true, nil)
+}
+
 func ackTask(streamInfo TaskStreamInfo, success bool, err error) {
 	if success {
 		// 确认任务完成
-		if err := cache.XAck(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
+		if err := cache.XAckDel(streamInfo.Ctx, streamInfo.StreamName, streamInfo.GroupName, streamInfo.ID); err != nil {
 			logger.Std.Println("XAck error:", err)
 		}
 	} else {
+		if !slices.Contains(failedTaskList, streamInfo.ID) {
+			failedTaskList = append(failedTaskList, streamInfo.ID)
+		}
 		// 任务失败
 		logger.Std.Println("Task queue execution failed, error:", err)
 	}
+
+	if err != nil {
+		zap.L().Error("taskQueue ackTask error.", zap.String("err", err.Error()))
+	}
 }
 
 func EnqueueTask[T any](payload TaskPayload[T]) error {
@@ -160,3 +226,52 @@ func EnqueueTask[T any](payload TaskPayload[T]) error {
 
 	return nil
 }
+
+func GetTaskInfoList() ([]dto.BackgroundTaskItem, error) {
+	c, cancel := context.WithTimeout(ctx, 15*time.Second)
+	defer cancel()
+
+	allMsg, err := cache.XRangeN(c, streamName, "-", "+", 1000) // 查询所有信息,限制1000条
+	if err != nil {
+		zap.L().Error("XRangeN查询失败", zap.String("err", err.Error()))
+		return nil, err
+	}
+
+	res := []dto.BackgroundTaskItem{}
+	for _, msg := range append(errorTaskList, allMsg...) {
+		// 判断任务状态
+		taskStatus := "pending" // 默认为pending
+		if msg.ID == runningID {
+			taskStatus = "running"
+		} else if slices.Contains(failedTaskList, msg.ID) {
+			taskStatus = "failed"
+		}
+
+		// 获取任务重试次数
+		var retryCount int64
+		pendingRes, err := cache.XPendingExtOne(ctx, streamName, groupName, retryConsumerID, msg.ID)
+		if err != nil {
+			zap.L().Error("XPendingExtOne查询失败", zap.String("err", err.Error()))
+			return nil, err
+		} else if pendingRes == nil {
+			retryCount = 0
+		} else {
+			retryCount = pendingRes.RetryCount
+		}
+
+		// 获取任务参数
+		payload, ok := msg.Values["payload"].(string)
+		if !ok {
+			payload = fmt.Sprint(msg.Values["taskId"])
+		}
+		item := dto.BackgroundTaskItem{
+			Id:      msg.ID,
+			Status:  taskStatus,
+			Retry:   retryCount,
+			Payload: payload,
+		}
+
+		res = append(res, item)
+	}
+	return res, nil
+}

+ 2 - 2
service/taskQueue/taskShutdownStatistics.go

@@ -40,7 +40,7 @@ func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
 		},
 	}
 
-	id, err := repositories.InsertSiteAnalysisConfig(writeConfig)
+	id, err := repositories.InsertSiteAnalysisConfig(writeConfig, true)
 	if err != nil {
 		return nil, err
 	}
@@ -53,7 +53,7 @@ func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
 			EnableTimeline: writeConfig.Options.EnableTimeline,
 			EnableRuntime:  writeConfig.Options.EnableRuntime,
 		},
-	})
+	}, true)
 
 	return &id, nil
 }

+ 46 - 13
service/taskQueue/taskSiteAnalysis.go

@@ -38,7 +38,7 @@ func handleTaskSiteAnalysis(ctx context.Context, payload domain.TaskSiteAnalysis
 	} else {
 		fmt.Println("任务执行成功")
 		if repoErr := repositories.UpdateSiteAnalysisResult(payload.Id, domain.ProgressComplete); repoErr != nil {
-			zap.L().Error("更新站点分析结果状态失败(Complete)", zap.String("id", payload.Id.String()))
+			zap.L().Error("更新站点分析结果状态失败(Complete)", zap.String("id", payload.Id.String()), zap.String("err", repoErr.Error()))
 			return repoErr
 		}
 	}
@@ -50,12 +50,12 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 	// 转换时间范围,只取用日期部分
 	startTime, err := time.Parse(time.DateOnly, payload.TimePeriod[0])
 	if err != nil {
-		zap.L().Error("StartTime 格式错误", zap.String("time", payload.TimePeriod[0]))
+		zap.L().Error("StartTime 格式错误", zap.String("time", payload.TimePeriod[0]), zap.String("err", err.Error()))
 		return err
 	}
 	endTime, err := time.Parse(time.DateOnly, payload.TimePeriod[1])
 	if err != nil {
-		zap.L().Error("EndTime 格式错误", zap.String("time", payload.TimePeriod[1]))
+		zap.L().Error("EndTime 格式错误", zap.String("time", payload.TimePeriod[1]), zap.String("err", err.Error()))
 		return err
 	}
 
@@ -64,6 +64,12 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 
 	fmt.Println(payload.Options)
 
+	// 创建记录数据库用于存储原始MQTT日志请求数据
+	queryRecord, err := repositories.CreateSitesAnalysisRecordTable(string(payload.Id.Hex()))
+	if err != nil {
+		return err
+	}
+
 	if payload.Options.EnableTimeline {
 		// 时间线展示功能
 		// Site 时间线生成逻辑:
@@ -78,39 +84,58 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 		fmt.Println("查询MQTT日志0")
 		logList_0, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_0, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志0失败")
+			zap.L().Error("查询MQTT日志0失败", zap.String("err", err.Error()))
 			return err
 		}
+		for _, l := range logList_0 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
+
 		fmt.Println("查询MQTT日志101")
 		logList_101, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_101, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志101失败")
+			zap.L().Error("查询MQTT日志101失败", zap.String("err", err.Error()))
 			return err
 		}
+		for _, l := range logList_101 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
 
 		// 查询站点的事件触发记录
 		fmt.Println("查询站点的事件触发记录")
 		eventLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Event)
 		if err != nil {
-			zap.L().Error("查询事件日志失败")
+			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
 			return err
 		}
 
+		time.Sleep(time.Second) // 延时1s
+
 		// 查询站点的告警开始-结束触发记录
 		fmt.Println("查询站点的告警开始-结束触发记录")
 		alarmLog, err := repositories.QueryEventOrAlarmListOnTimeRange(payload.Sn, startTime, endTime, repositories.Alarm)
 		if err != nil {
-			zap.L().Error("查询事件日志失败")
+			zap.L().Error("查询事件日志失败", zap.String("err", err.Error()))
 			return err
 		}
 
+		time.Sleep(time.Second) // 延时1s
+
 		// 查询MQTT日志357
-		fmt.Println("查询MQTT日志357")
 		logList_357, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_357, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志357失败")
+			zap.L().Error("查询MQTT日志357失败", zap.String("err", err.Error()))
 			return err
 		}
+		for _, l := range logList_357 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
 
 		// 分析数据,并将结果写入数据库
 		fmt.Println("分析数据,并将结果写入数据库")
@@ -122,7 +147,7 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			AlarmLog: alarmLog,
 		})
 		if err != nil {
-			zap.L().Error("站点时间线数据分析失败", zap.String("sn", payload.Sn))
+			zap.L().Error("站点时间线数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
 			return err
 		}
 	}
@@ -134,9 +159,14 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 		fmt.Println("查询MQTT日志201")
 		logList_201, err := repositories.QueryMqttLogListOnTimeRange(payload.Sn, domain.MQTT_LOG_201, startTime, endTime)
 		if err != nil {
-			zap.L().Error("查询MQTT日志201失败")
+			zap.L().Error("查询MQTT日志201失败", zap.String("err", err.Error()))
 			return err
 		}
+		for _, l := range logList_201 {
+			if err := queryRecord.InsertSitesAnalysisRecord(l); err != nil {
+				return err
+			}
+		}
 
 		// 分析数据,并将结果写入数据库
 		fmt.Println("分析数据,并将结果写入数据库")
@@ -144,7 +174,7 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 			Log_201: logList_201,
 		})
 		if err != nil {
-			zap.L().Error("站点运行时数据分析失败", zap.String("sn", payload.Sn))
+			zap.L().Error("站点运行时数据分析失败", zap.String("sn", payload.Sn), zap.String("err", err.Error()))
 			return err
 		}
 	}
@@ -152,7 +182,10 @@ var executeTaskSiteAnalysis = func(ctx context.Context, payload domain.TaskSiteA
 	return nil
 }
 
-func EnqueueSiteAnalysisTask(payload domain.TaskSiteAnalysis) error {
+// 插入站点分析队列
+// payload - 任务参数
+// auto - 是否是自动插入
+func EnqueueSiteAnalysisTask(payload domain.TaskSiteAnalysis, auto bool) error {
 	p := TaskPayload[domain.TaskSiteAnalysis]{
 		TaskPayloadBase: TaskPayloadBase{
 			Type: TaskTypeSiteAnalysis,

+ 59 - 0
service/test/shutdownStatistics/shutdownStatisticsTest.go

@@ -0,0 +1,59 @@
+package main
+
+import (
+	"fmt"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/jobs"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+
+	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.uber.org/zap"
+)
+
+func main() {
+	err := logger.Init("dev")
+	if err != nil {
+		panic(err)
+	}
+	defer logger.Log.Sync() // 退出前同步zap缓冲区
+
+	if _, err := database.InitMongo("mongodb://127.0.0.1:27017/FwhToolboxDbDev"); err != nil {
+		panic(err)
+	}
+
+	records, err := repositories.QuerySiteShutdownList("2025-10-16", 0) // 请求所有设备
+	if err != nil {
+		panic(err)
+	}
+
+	// 分析告警统计数据,并将结果写入数据库
+	fmt.Println("分析告警统计数据,并将结果写入数据库")
+	err = jobs.StatisticsShutdown("2025-10-16", records, analysisShutdownSite)
+	if err != nil {
+		zap.L().Error("告警统计数据分析失败", zap.String("date", "2025-10-16"))
+		panic(err)
+	}
+
+	fmt.Println("分析成功")
+}
+
+func analysisShutdownSite(sn string, date string) (*primitive.ObjectID, error) {
+	writeConfig := domain.SiteAnalysisConfigWrite{
+		Sn:          sn,
+		Description: fmt.Sprintf("Shutdown站点分析 %s", date),
+		TimePeriod:  [2]string{date, date},
+		Options: domain.SiteAnalysisOptions{
+			EnableTimeline: true,
+			EnableRuntime:  true,
+		},
+	}
+
+	id, err := repositories.InsertSiteAnalysisConfig(writeConfig, true)
+	if err != nil {
+		return nil, err
+	}
+
+	return &id, nil
+}

+ 57 - 0
service/test/temp/runtimeSort.go

@@ -0,0 +1,57 @@
+package main
+
+import (
+	"fmt"
+	"fwh-toolbox-server/database"
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/logger"
+	"fwh-toolbox-server/repositories"
+	"sort"
+	"time"
+)
+
+func main() {
+	err := logger.Init("dev")
+	if err != nil {
+		panic(err)
+	}
+	defer logger.Log.Sync() // 退出前同步zap缓冲区
+
+	if _, err := database.InitMongo("mongodb://127.0.0.1:27017/FwhToolboxDb"); err != nil {
+		panic(err)
+	}
+
+	dataPage, err := repositories.GetSiteAnalysisManageList(1, 1000, nil, nil, nil)
+	if err != nil {
+		panic(err)
+	}
+
+	dataList := dataPage.Data
+	for _, d := range dataList {
+		data, err := repositories.GetSiteAnalysisConfig(d.Id)
+		if err != nil {
+			panic(err)
+		}
+
+		runtimeData := data.Data.Runtime
+		runtimeSort(&runtimeData)
+		if err := repositories.InsertSiteAnalysisRuntime(d.Id, runtimeData); err != nil {
+			panic(err)
+		}
+	}
+
+	fmt.Println("转换成功。")
+}
+
+func runtimeSort(list *[]domain.RuntimeItem) {
+	if list == nil {
+		return
+	}
+
+	// 排序
+	sort.Slice(*list, func(i, j int) bool {
+		ti, _ := time.Parse(time.DateTime, (*list)[i].Time)
+		tj, _ := time.Parse(time.DateTime, (*list)[j].Time)
+		return ti.Before(tj)
+	})
+}

+ 61 - 12
service/utils/httpClient/httpClient.go

@@ -28,6 +28,38 @@ type PostBody struct {
 	ContentType string
 }
 
+type ResponseData struct {
+	Code int `json:"code"`
+}
+
+var reLogin = func() string {
+	// empty function
+	return ""
+}
+
+func responseInterceptor(body []byte, retry func(*string) ([]byte, error)) []byte {
+	// 判断是否回复code 401
+	var data ResponseData
+	if err := json.Unmarshal(body, &data); err != nil {
+		zap.L().Warn("ResponseData 解析失败", zap.String("body", string(body)), zap.String("err", err.Error()))
+		return body
+	}
+	if data.Code == 401 {
+		tokenStr := reLogin()
+		retryBody, err := retry(&tokenStr)
+		if err != nil {
+			zap.L().Warn("http retry 失败", zap.String("token", tokenStr), zap.String("err", err.Error()))
+			return body
+		}
+		return retryBody
+	}
+	return body
+}
+
+func RegisterLogin(login func() string) {
+	reLogin = login
+}
+
 func HttpGet(url string, params url.Values, opt *HttpClientOptions) ([]byte, error) {
 	// 拼接参数到 URL
 	finalURL := url + "?" + params.Encode()
@@ -57,16 +89,25 @@ func HttpGet(url string, params url.Values, opt *HttpClientOptions) ([]byte, err
 		timeout = *opt.Timeout
 	}
 
-	// 发送请求
-	client := &http.Client{Timeout: timeout * time.Second}
-	resp, err := client.Do(req)
+	// 发送请求 - 封装成函数用于重试
+	var reqFunc = func(token *string) ([]byte, error) {
+		client := &http.Client{Timeout: timeout * time.Second}
+		resp, err := client.Do(req)
+		if err != nil {
+			zap.L().Error("Do Send failed.", zap.String("err", err.Error()))
+			return nil, err
+		}
+		defer resp.Body.Close()
+
+		return io.ReadAll(resp.Body)
+	}
+
+	body, err := reqFunc(nil)
 	if err != nil {
-		zap.L().Error("Do Send failed.", zap.String("err", err.Error()))
 		return nil, err
 	}
-	defer resp.Body.Close()
 
-	return io.ReadAll(resp.Body)
+	return responseInterceptor(body, reqFunc), nil
 }
 
 // toPostBody 将任意 body 转换为 http POST 可用的 io.Reader
@@ -144,14 +185,22 @@ func HttpPost(url string, body any, opt *HttpClientOptions) ([]byte, error) {
 		timeout = *opt.Timeout
 	}
 
-	// 发送请求
-	client := &http.Client{Timeout: timeout * time.Second}
-	resp, err := client.Do(req)
+	// 发送请求 - 封装成函数用于重试
+	var reqFunc = func(token *string) ([]byte, error) {
+		client := &http.Client{Timeout: timeout * time.Second}
+		resp, err := client.Do(req)
+		if err != nil {
+			zap.L().Error("Do Send failed.", zap.String("err", err.Error()))
+			return nil, err
+		}
+		defer resp.Body.Close()
+
+		return io.ReadAll(resp.Body)
+	}
+	respBody, err := reqFunc(nil)
 	if err != nil {
-		zap.L().Error("Do Send failed.", zap.String("err", err.Error()))
 		return nil, err
 	}
-	defer resp.Body.Close()
 
-	return io.ReadAll(resp.Body)
+	return responseInterceptor(respBody, reqFunc), nil
 }

+ 33 - 0
src/router/record.ts

@@ -13,6 +13,17 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
     },
     component: 'dashboard/index',
   },
+  {
+    path: 'shutdown-manage',
+    name: 'shutdownManage',
+    icon: 'i-ph:bug-beetle-duotone',
+    label: 'Shutdown站点管理',
+    meta: {
+      componentName: 'ShutdownManage',
+      showTab: true,
+    },
+    component: 'shutdown-manage/index',
+  },
   {
     path: 'sites-monitor-manage',
     name: 'sitesMonitorManage',
@@ -266,6 +277,28 @@ export const routeRecordRaw: MergeMenuMixedOptions[] = [
   //   },
   //   component: 'error-page/404',
   // },
+  {
+    path: 'batch-download',
+    name: 'batchDownload',
+    icon: 'i-ph:download',
+    label: '批量数据下载',
+    meta: {
+      componentName: 'BatchDownload',
+      showTab: true,
+    },
+    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: 'xlog-manage',
     name: 'xlogManage',

+ 15 - 0
src/service/backgroundTask/index.ts

@@ -0,0 +1,15 @@
+// API List
+// /backgroundTask/info - GET
+
+import http from '@/service/serverReq'
+import type { BackgroundTaskResponse } from './interface'
+
+const API_List = {
+  backgroundTaskInfo: '/backgroundTask/info',
+}
+
+// GET 后台任务信息
+export const getBackgroundTaskList = async (): Promise<BackgroundTaskResponse[]> => {
+  const res = await http.get<BackgroundTaskResponse[]>(API_List.backgroundTaskInfo)
+  return res
+}

+ 24 - 0
src/service/backgroundTask/interface.ts

@@ -0,0 +1,24 @@
+export interface BackgroundTaskFilter {
+  type?: string
+  status?: string
+}
+
+export interface BackgroundTaskItem {
+  id: string
+  type: string
+  status: string
+  retry: number
+  info: string // 任务相关数据 JSON格式化
+}
+
+export interface BackgroundTaskResponse {
+  id: string
+  status: string
+  retry: number // 任务重试次数
+  payload: string // 任务传入参数信息 json string
+}
+
+export interface BackgroundTaskPayload {
+  type: string
+  payload: any // 任务相关数据
+}

+ 26 - 0
src/service/batchDownload/index.ts

@@ -0,0 +1,26 @@
+// API List
+// /batchDownload/list - GET
+
+import http from '@/service/serverReq'
+import type { BatchDownloadFilter, BatchDownloadItemPage } from './interface'
+
+const API_List = {
+  batchDownloadList: '/batchDownload/list',
+}
+
+// GET 后台任务信息
+export const getBatchDownloadList = async (
+  pageSize: number,
+  pageNum: number,
+  filter?: BatchDownloadFilter,
+): Promise<BatchDownloadItemPage> => {
+  const res = await http.get<BatchDownloadItemPage>(API_List.batchDownloadList, {
+    params: {
+      pageSize,
+      pageNum,
+      sn: filter ? (filter.sn ?? '') : '',
+      type: filter ? (filter.type ?? '') : '',
+    },
+  })
+  return res
+}

+ 57 - 0
src/service/batchDownload/interface.ts

@@ -0,0 +1,57 @@
+import type { ResPage } from '../interface'
+
+export interface BatchDownloadFilter {
+  sn?: string
+  type?: string
+}
+
+export interface BatchDownloadItemPage extends ResPage<BatchDownloadItem[]> {}
+
+export type BatchDownloadItem = BatchDownloadRealtimeData | BatchDownloadHistoryData
+
+export interface BatchDownloadData {
+  id: string
+  snList: string[]
+  result: string // 任务处理结果
+}
+
+export interface BatchDownloadRealtimeData extends BatchDownloadData {
+  type: 'realtime'
+  options: BatchDownloadRealtimeOptions
+}
+
+export interface BatchDownloadRealtimeOptions {
+  statusInfo: boolean // 站点当前状态
+  powerFlow: boolean // 当前状态功率流截图
+  eventAlarm: boolean // 当前状态事件和告警
+  configInfo: {
+    // 站点设置数据
+    gridProfile: boolean // 并网合规配置
+    accessories: boolean // 附件配置
+    systemParam: boolean // 系统参数
+    devPara: {
+      // 设备参数VID/PID/CID
+      emsVid: number[]
+      emsPid: number[]
+      peCid: number[]
+      bmsVid: number[]
+      bmsPid: number[]
+    }
+  }
+}
+
+export interface BatchDownloadHistoryData extends BatchDownloadData {
+  type: 'history'
+  options: BatchDownloadHistoryOptions
+}
+
+export interface BatchDownloadHistoryOptions {
+  powerFlow: boolean // 功率流截图
+  energyStatistics: boolean // 能量数据统计
+  mpttLog: string[] // 运行通讯日志
+  eventAlarm: boolean // 事件和告警数据
+  emsLog: string[] // EMS日志
+  emsModuleLog: string[] // EMS模块日志
+  faultRecord: boolean // 故障录波
+  updateRecord: boolean // 升级记录
+}

+ 25 - 0
src/service/dashboard/index.ts

@@ -0,0 +1,25 @@
+// API List
+// /dashboard/shutdownStatistics/date - GET shutdown统计最新日期
+// /dashboard/shutdownStatistics - GET shutdown统计
+
+import http from '@/service/serverReq'
+import type { ShutdownStatisticsInfo } from './interface'
+
+const API_List = {
+  shutdownStatisticsDate: '/dashboard/shutdownStatistics/date',
+  shutdownStatistics: '/dashboard/shutdownStatistics',
+}
+
+// GET shutdown统计最新日期
+export const getShutdownStatisticsDate = async (): Promise<string> => {
+  const res = await http.get<string>(API_List.shutdownStatisticsDate)
+  return res
+}
+
+// GET shutdown统计
+export const getShutdownStatistics = async (date: string): Promise<ShutdownStatisticsInfo> => {
+  const res = await http.get<ShutdownStatisticsInfo>(API_List.shutdownStatistics, {
+    params: { date },
+  })
+  return res
+}

+ 0 - 1
src/service/dashboard/interface.ts

@@ -11,6 +11,5 @@ export interface ShutdownStatisticsDayItem {
   weekly: number[] // 周数据
 
   monthCount: number
-  monthGrowth: number | undefined // 增长量 增长(+)/下降(-)
   year: number[] // 年数据
 }

+ 3 - 1
src/service/serverReq.ts

@@ -14,7 +14,7 @@ export interface Response<T = any> {
   timestamp: number
 }
 
-const http = new FetchClient(baseUrl + 'api')
+const http = new FetchClient(baseUrl + 'api', 60 * 1000) // 超时时间设置为60s
 
 let isRefreshing = false
 let pendingQueue: (() => void)[] = []
@@ -68,6 +68,8 @@ function handleRefreshFailed() {
   pendingQueue = []
   isRefreshing = false
 
+  console.log('jumpToSignin')
+
   // 跳转登录页
   jumpToSignin()
 }

+ 31 - 0
src/service/shutdownManage/index.ts

@@ -0,0 +1,31 @@
+// API List
+// /shutdownManage/infoMap - GET
+
+import http from '@/service/serverReq'
+import type { ShutdownDataMap } from './interface'
+
+const API_List = {
+  shutdownInfoDate: '/shutdownManage/date',
+  shutdownManageInfoMap: '/shutdownManage/infoMap',
+}
+
+// GET Shutdown信息最新日期
+export const getShutdownInfoDate = async (): Promise<string> => {
+  const res = await http.get<string>(API_List.shutdownInfoDate)
+  return res
+}
+
+// GET Shutdown信息
+export const getShutdownManageInfoMap = async (date?: string): Promise<ShutdownDataMap> => {
+  const res = await http.get<ShutdownDataMap>(
+    API_List.shutdownManageInfoMap,
+    date
+      ? {
+          params: {
+            date,
+          },
+        }
+      : undefined,
+  )
+  return res
+}

+ 13 - 0
src/service/shutdownManage/interface.ts

@@ -0,0 +1,13 @@
+export interface ShutdownDataMap {
+  [sn: string]: ShutdownInfo
+}
+
+export interface ShutdownInfo {
+  siteAnalysisId: string
+  shutdownTimeList: ShutdownTimePointInfo[]
+}
+
+export interface ShutdownTimePointInfo {
+  time: string
+  type: number // 1-系统停机 2-aPower停机
+}

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

@@ -32,6 +32,7 @@ export const getManageList = async (
       pageNum,
       sn: filter ? (filter.sn ?? '') : '',
       progress: filter ? (filter.progress ?? '') : '',
+      trigger: filter ? (filter.trigger ?? '') : '',
     },
   })
   return res

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

@@ -3,6 +3,7 @@ import type { ResPage } from '../interface'
 export interface AnalysisListFilter {
   sn?: string
   progress?: 'pending' | 'running' | 'error' | 'complete'
+  trigger?: 'auto' | 'manual'
 }
 
 export interface AnalysisConfigOptions {
@@ -25,6 +26,7 @@ export interface AnalysisItemInfo {
   description: string
   item: string[]
   progress: 'pending' | 'running' | 'error' | 'complete'
+  trigger: 'auto' | 'manual'
 }
 
 export interface AnalysisItemInfoPage extends ResPage<AnalysisItemInfo[]> {}

+ 56 - 0
src/stores/modules/views/siteRuntimeChartConfig.ts

@@ -0,0 +1,56 @@
+import { defineSetupStore } from '@/stores/public/template'
+
+const STORES_KEY = 'SiteRuntimeChartConfigStore'
+const CURRENT_VERSION = '0.0.0'
+
+export const useSiteRuntimeChartConfigStore = defineSetupStore(
+  STORES_KEY,
+  CURRENT_VERSION,
+  () => {
+    // 默认值
+    let siteRuntimeChartConfig: string[] = [
+      'mode', // 工作模式(mode)
+      'run_status', // 运行状态(run_status)
+      'elecnet_state', // 市电状态(elecnet_state)
+      'soc', // 系统SOC(soc)
+      'name', // 调度策略(name)
+      'main_sw_0', // 市电开关状态(main_sw[0])
+      'main_sw_2', // aGate-PV开关状态(main_sw[2])
+      'p_uti', // 市电功率(p_uti)
+      'p_sun', // 光伏发电功率(p_sun)
+      'p_gen', // 发电机发电功率(p_gen)
+      'p_fhp', // aPower功率(p_fhp)
+      'p_load', // 家庭负载功率(p_load)
+    ]
+
+    const getConfig = (): string[] => {
+      return siteRuntimeChartConfig
+    }
+
+    const updateConfig = (config: string[]) => {
+      siteRuntimeChartConfig = config
+    }
+
+    return {
+      // normal state
+      siteRuntimeChartConfig,
+
+      // reactive state - ref()
+
+      // reactive getters - computed()
+
+      // normal getters - function()
+      getConfig,
+
+      // actions - function()
+      updateConfig, // 更新配置
+    }
+  },
+  {
+    persist: {
+      key: STORES_KEY, // 存储 key
+      storage: localStorage, // 默认是 localStorage,sessionStorage可选
+      debug: true,
+    },
+  },
+)

+ 3 - 1
src/types/components.d.ts

@@ -14,20 +14,22 @@ declare module 'vue' {
     HintHelp: typeof import('./../components/HintHelp.vue')['default']
     NAlert: typeof import('naive-ui')['NAlert']
     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']
-    NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
     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']

+ 359 - 0
src/views/background-task/index.vue

@@ -0,0 +1,359 @@
+<template>
+  <div class="main-wrap flex flex-col gap-y-2 p-4">
+    <NCard>
+      <div class="mb-2 flex gap-x-4">
+        <NForm ref="formRef" :model="form" inline label-placement="left">
+          <NFormItem label="任务类型" path="type">
+            <NSelect v-model:value="form.type" :options="taskTypeOptions" style="width: 250px" clearable
+              @update:value="onUpdateTaskType" />
+          </NFormItem>
+          <NFormItem label="任务状态" path="status">
+            <NSelect v-model:value="form.status" :options="taskStatusOptions" style="width: 120px" clearable
+              @update:value="onUpdateTaskStatus" />
+          </NFormItem>
+        </NForm>
+        <div class="flex gap-2">
+          <NButton type="success" disabled>
+            <template #icon>
+              <span class="i-ph:plus-circle" />
+            </template>
+            创建任务
+          </NButton>
+          <NButton type="info" @click="handleQueryClick" :loading="isRequestLoading" :disabled="isRequestLoading">
+            <template #icon>
+              <span class="i-ph:magnifying-glass" />
+            </template>
+            查询
+          </NButton>
+          <NButton type="warning" @click="resetForm">
+            <template #icon>
+              <span class="i-ph:arrow-clockwise" />
+            </template>
+            重置
+          </NButton>
+        </div>
+      </div>
+      <div>
+        <NDataTable ref="dataTableRef" v-model:checked-row-keys="checkedRowKeys" :remote="true" :min-height="166.6"
+          :max-height="maxHeight" size="small" :columns="tableColumns" :data="tableData" :row-key="tableRowKey"
+          :loading="isRequestLoading" striped :single-line="false" :row-props="rowProps" />
+        <div class="mt-3 flex items-end justify-between">
+          <div class="flex items-center gap-x-3">
+            <span>已选择&nbsp;{{ checkedRowKeys.length }}&nbsp; 条</span>
+          </div>
+          <NPagination v-bind="tablePagination" :prefix="paginationPrefix" />
+        </div>
+      </div>
+    </NCard>
+    <NDropdown placement="bottom-start" trigger="manual" v-bind="dropdownOptions" :show="showDropdown" />
+  </div>
+</template>
+
+<script setup lang="tsx">
+import { useRouter } from 'vue-router'
+import { NButton, NDataTable, NForm, NNumberAnimation, NInput } from 'naive-ui'
+import type { DataTableColumns, PaginationProps, DropdownProps } from 'naive-ui'
+import { useDataTable } from '@/composable/comp/useDataTable'
+import { useResettableReactive } from '@/composable/useResettable'
+import { getBackgroundTaskList } from '@/service/backgroundTask'
+import type { BackgroundTaskFilter, BackgroundTaskItem, BackgroundTaskResponse, BackgroundTaskPayload } from '@/service/backgroundTask/interface'
+
+defineOptions({
+  name: 'BackgroundTask',
+})
+
+const router = useRouter()
+
+const message = useMessage()
+const dialog = useDialog()
+
+const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
+const dataTableRef = useTemplateRef<InstanceType<typeof NDataTable>>('dataTableRef')
+const { maxHeight } = useDataTable(dataTableRef)
+
+const isRequestLoading = ref(false)
+const showDropdown = ref(false)
+const checkedRowKeys = ref<Array<number | string>>([])
+const contextmenu = ref<BackgroundTaskItem | undefined>(undefined)
+
+type TaskFilterForm = BackgroundTaskFilter
+type TaskItemInfo = BackgroundTaskItem
+
+const [form, , resetForm] = useResettableReactive<TaskFilterForm>({
+  type: undefined,
+  status: undefined,
+})
+
+
+const taskTypeOptions = [
+  { label: '站点分析任务', value: 'task_SiteAnalysis' },
+  { label: 'Shutdown统计任务', value: 'task_ShutdownStatistics' },
+]
+
+const taskStatusOptions = [
+  { label: 'pending', value: 'pending' },
+  { label: 'running', value: 'running' },
+  { label: 'failed', value: 'failed' },
+  { label: 'error', value: 'error' },
+  { label: 'complete', value: 'complete' },
+]
+
+const handleQueryClick = () => {
+  // 请求查询数据
+  handleListQuery()
+}
+
+const onUpdateTaskType = (value: string) => {
+  handleFilterChange()
+}
+
+const onUpdateTaskStatus = (value: string) => {
+  handleFilterChange()
+}
+
+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 dropdownOptions = reactive<DropdownProps>({
+  x: 0,
+  y: 0,
+  options: [
+    {
+      label: () => <span class='text-green-500'> 详细信息 </span>,
+      key: 'view',
+    },
+    {
+      label: () => <span class='text-red-500'> 取消任务 </span>,
+      key: 'cancel',
+      disabled: true,
+    },
+  ],
+  onClickoutside: () => {
+    showDropdown.value = false
+  },
+  onSelect: (key: string) => {
+    showDropdown.value = false
+    if (contextmenu.value === undefined) return
+
+    if (key === 'view') {
+      itemView(contextmenu.value)
+    } else if (key === 'cancel') {
+      itemCancel(contextmenu.value.id)
+    }
+  },
+})
+
+const tableRowKey = (rowData: TaskItemInfo) => {
+  return rowData.id
+}
+
+function createColumns(): DataTableColumns<TaskItemInfo> {
+  return [
+    {
+      title: 'ID',
+      key: 'id',
+      width: 200,
+      fixed: 'left'
+    },
+    {
+      title: '任务类型',
+      key: 'type',
+      width: 200,
+      fixed: 'left'
+    },
+    {
+      title: '任务状态',
+      key: 'status'
+    },
+    {
+      title: '重试次数',
+      key: 'retry'
+    },
+    {
+      title: '操作',
+      key: 'operate',
+      width: 200,
+      fixed: 'right',
+      render(row) {
+        return h('div', { class: 'flex justify-center gap-4 p-x-4' }, [
+          h(NButton, {
+            type: "success",
+            strong: true,
+            size: 'small',
+            onClick: () => itemView(row)
+          }, {
+            default: () => '详细信息'
+          }),
+          h(NButton, {
+            type: "error",
+            strong: true,
+            size: 'small',
+            disabled: true,
+            onClick: () => itemCancel(row.id)
+          }, {
+            default: () => '取消任务'
+          })
+        ])
+      }
+    }
+  ]
+}
+
+// 表格右键菜单
+function rowProps(row: TaskItemInfo) {
+  return {
+    onContextmenu: (e: MouseEvent) => {
+      e.preventDefault()
+      showDropdown.value = false
+      nextTick().then(() => {
+        contextmenu.value = row
+        showDropdown.value = true
+        dropdownOptions.x = e.clientX
+        dropdownOptions.y = e.clientY
+      })
+    },
+  }
+}
+
+let taskDataList: TaskItemInfo[] = []
+let taskFilterData: TaskItemInfo[] = []
+const tableData = ref<TaskItemInfo[]>([])
+const tableColumns = createColumns()
+const tablePagination = reactive<PaginationProps>({
+  page: 1,
+  pageSize: 10,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50, 100],
+  itemCount: 0,
+  showQuickJumper: true,
+  showQuickJumpDropdown: true,
+  onUpdatePage: (page: number) => {
+    tablePagination.page = page
+    handlePaginationChange()
+  },
+  onUpdatePageSize: (pageSize: number) => {
+    tablePagination.pageSize = pageSize
+    tablePagination.page = 1
+    handlePaginationChange()
+  },
+})
+
+const itemView = (data: TaskItemInfo) => {
+  dialog.info({
+    title: "任务参数信息",
+    content: () => h(NInput, {
+      value: data.info,
+      type: "textarea",
+      readonly: true,
+      rows: 20
+    })
+  })
+}
+
+const itemCancel = (id: string) => {
+  // TODO
+}
+
+const queryBackgroundTaskList = async (): Promise<BackgroundTaskItem[]> => {
+  const responseList = await getBackgroundTaskList()
+  return convertBackgroundTask(responseList)
+}
+
+function anyToString(value: any, indent: number = 2): string {
+  if (value === null || value === undefined) {
+    return String(value); // "null" 或 "undefined"
+  }
+
+  const type = typeof value;
+
+  if (type === "string" || type === "number" || type === "boolean") {
+    return String(value);
+  }
+
+  if (type === "object") {
+    try {
+      return JSON.stringify(value, null, indent);
+    } catch (e) {
+      console.error("无法序列化对象:", e);
+      return "[Cannot serialize object]";
+    }
+  }
+
+  // 函数、symbol 等特殊类型
+  return String(value);
+}
+
+// 转换BackgroundTask数据
+const convertBackgroundTask = (list: BackgroundTaskResponse[]): BackgroundTaskItem[] => {
+  return list.map<BackgroundTaskItem>(item => {
+    const payload: BackgroundTaskPayload = JSON.parse(item.payload)
+    return {
+      id: item.id,
+      type: payload.type,
+      status: item.status,
+      retry: item.retry,
+      info: anyToString(payload.payload),
+    }
+  })
+}
+
+const handleListQuery = async () => {
+  taskDataList = await queryBackgroundTaskList()
+  tablePagination.itemCount = taskDataList.length
+  handleFilterChange()
+}
+
+const handleFilterChange = () => {
+  // 处理过滤条件
+  const filter: TaskFilterForm = {
+    type: form.type,
+    status: form.status
+  }
+
+  // 过滤
+  taskFilterData = taskDataList
+  if (filter.type != null || filter.status != null) {
+    taskFilterData = taskDataList.map<BackgroundTaskItem | null>(item => {
+      if (filter.type != null && item.type !== filter.type) return null
+      if (filter.status != null && item.status !== filter.status) return null
+      return item
+    }).filter(x => x != null)
+  }
+
+  // 重新分页后,重置到第一页
+  tablePagination.page = 1
+  handlePaginationChange()
+}
+
+const handlePaginationChange = () => {
+  const pageSize = tablePagination.pageSize ?? 10
+  const page = tablePagination.page ?? 1
+
+  // 分页
+  tableData.value = taskFilterData.slice((page - 1) * pageSize, page * pageSize)
+}
+
+onMounted(async () => {
+  // 请求查询数据
+  await handleListQuery()
+})
+
+</script>

+ 296 - 0
src/views/batch-download/index.vue

@@ -0,0 +1,296 @@
+<template>
+  <div class="main-wrap flex flex-col gap-y-2 p-4">
+    <NCard>
+      <div class="mb-2 flex gap-x-4">
+        <NForm ref="formRef" :model="form" inline label-placement="left">
+          <NFormItem label="站点" path="sn">
+            <NInput v-model:value="form.sn" style="width: 250px" clearable />
+          </NFormItem>
+          <NFormItem label="类型" path="type">
+            <NSelect v-model:value="form.type" :options="dataTypeOptions" style="width: 120px" clearable />
+          </NFormItem>
+        </NForm>
+        <div class="flex gap-2">
+          <NButton type="success" disabled>
+            <template #icon>
+              <span class="i-ph:plus-circle" />
+            </template>
+            创建任务
+          </NButton>
+          <NButton type="info" @click="handleQueryClick" :loading="isRequestLoading" :disabled="isRequestLoading">
+            <template #icon>
+              <span class="i-ph:magnifying-glass" />
+            </template>
+            查询
+          </NButton>
+          <NButton type="warning" @click="resetForm">
+            <template #icon>
+              <span class="i-ph:arrow-clockwise" />
+            </template>
+            重置
+          </NButton>
+        </div>
+      </div>
+      <div>
+        <NDataTable ref="dataTableRef" v-model:checked-row-keys="checkedRowKeys" :remote="true" :min-height="166.6"
+          :max-height="maxHeight" size="small" :columns="tableColumns" :data="tableData" :row-key="tableRowKey"
+          :loading="isRequestLoading" striped :single-line="false" :row-props="rowProps" />
+        <div class="mt-3 flex items-end justify-between">
+          <div class="flex items-center gap-x-3">
+            <span>已选择&nbsp;{{ checkedRowKeys.length }}&nbsp; 条</span>
+            <NButtonGroup size="small" :ghost="true">
+              <NButton :disabled="isSelectMultiple" @click="handleDownloadClick" secondary type="info">
+                下载
+              </NButton>
+              <NButton :disabled="isSelectMultiple" @click="handleDeleteClick" secondary type="info">
+                删除
+              </NButton>
+            </NButtonGroup>
+          </div>
+          <NPagination v-bind="tablePagination" :prefix="paginationPrefix" />
+        </div>
+      </div>
+    </NCard>
+    <NDropdown placement="bottom-start" trigger="manual" v-bind="dropdownOptions" :show="showDropdown" />
+  </div>
+</template>
+
+<script setup lang="tsx">
+import { useRouter } from 'vue-router'
+import { NButton, NDataTable, NForm, NNumberAnimation, NInput } from 'naive-ui'
+import type { DataTableColumns, PaginationProps, DropdownProps } from 'naive-ui'
+import { useDataTable } from '@/composable/comp/useDataTable'
+import { useResettableReactive } from '@/composable/useResettable'
+import { getBatchDownloadList } from '@/service/batchDownload'
+import type { BatchDownloadFilter, BatchDownloadItem } from '@/service/batchDownload/interface'
+
+defineOptions({
+  name: 'BatchDownload',
+})
+
+const router = useRouter()
+
+const message = useMessage()
+const dialog = useDialog()
+
+const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
+const dataTableRef = useTemplateRef<InstanceType<typeof NDataTable>>('dataTableRef')
+const { maxHeight } = useDataTable(dataTableRef)
+
+const isRequestLoading = ref(false)
+const showDropdown = ref(false)
+const checkedRowKeys = ref<Array<number | string>>([])
+const contextmenu = ref<BatchDownloadItem | undefined>(undefined)
+const isSelectMultiple = ref(false)
+
+type FilterForm = BatchDownloadFilter
+
+const [form, , resetForm] = useResettableReactive<FilterForm>({
+  sn: undefined,
+})
+
+const dataTypeOptions = [
+  { label: '实时数据', value: 'realtime' },
+  { label: '历史数据', value: 'history' },
+]
+
+const handleQueryClick = () => {
+  // 请求查询数据
+  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 dropdownOptions = reactive<DropdownProps>({
+  x: 0,
+  y: 0,
+  options: [
+    {
+      label: () => <span class='text-green-500'> 下载 </span>,
+      key: 'download',
+      disabled: true,
+    },
+    {
+      label: () => <span class='text-red-500'> 删除 </span>,
+      key: 'delete',
+      disabled: true,
+    },
+  ],
+  onClickoutside: () => {
+    showDropdown.value = false
+  },
+  onSelect: (key: string) => {
+    showDropdown.value = false
+    if (contextmenu.value === undefined) return
+
+    if (key === 'download') {
+      itemDownload(contextmenu.value.id)
+    } else if (key === 'delete') {
+      itemDelete(contextmenu.value.id)
+    }
+  },
+})
+
+const tableRowKey = (rowData: BatchDownloadItem) => {
+  return rowData.id
+}
+
+function createColumns(): DataTableColumns<BatchDownloadItem> {
+  return [
+    {
+      type: 'selection',
+      fixed: 'left'
+    },
+    {
+      title: 'SN',
+      key: 'snList',
+      width: 200,
+      fixed: 'left'
+    },
+    {
+      title: '类型',
+      key: 'type',
+      width: 50,
+    },
+    {
+      title: '选项',
+      key: 'type',
+    },
+    {
+      title: '任务结果',
+      key: 'result'
+    },
+    {
+      title: '操作',
+      key: 'operate',
+      width: 200,
+      fixed: 'right',
+      render(row) {
+        return h('div', { class: 'flex justify-center gap-4 p-x-4' }, [
+          h(NButton, {
+            type: "success",
+            strong: true,
+            size: 'small',
+            disabled: true,
+            onClick: () => itemDownload(row.id)
+          }, {
+            default: () => '下载'
+          }),
+          h(NButton, {
+            type: "error",
+            strong: true,
+            size: 'small',
+            disabled: true,
+            onClick: () => itemDelete(row.id)
+          }, {
+            default: () => '删除'
+          })
+        ])
+      }
+    }
+  ]
+}
+
+// 表格右键菜单
+function rowProps(row: BatchDownloadItem) {
+  return {
+    onContextmenu: (e: MouseEvent) => {
+      e.preventDefault()
+      showDropdown.value = false
+      nextTick().then(() => {
+        contextmenu.value = row
+        showDropdown.value = true
+        dropdownOptions.x = e.clientX
+        dropdownOptions.y = e.clientY
+      })
+    },
+  }
+}
+
+const tableData = ref<BatchDownloadItem[]>([])
+const tableColumns = createColumns()
+const tablePagination = reactive<PaginationProps>({
+  page: 1,
+  pageSize: 10,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50, 100],
+  itemCount: 0,
+  showQuickJumper: true,
+  showQuickJumpDropdown: true,
+  onUpdatePage: (page: number) => {
+    tablePagination.page = page
+    handleListQuery()
+  },
+  onUpdatePageSize: (pageSize: number) => {
+    tablePagination.pageSize = pageSize
+    tablePagination.page = 1
+    handleListQuery()
+  },
+})
+
+const itemDownload = (id: string) => {
+  // TODO
+}
+
+const itemDelete = (id: string) => {
+  // TODO
+}
+
+// 批量下载
+const handleDownloadClick = () => {
+
+}
+
+// 批量删除
+const handleDeleteClick = () => {
+
+}
+
+const queryBatchDownloadList = async (pageSize: number, pageNum: number, filter?: BatchDownloadFilter) => {
+  const responseList = await getBatchDownloadList(pageSize, pageNum, filter)
+  if (responseList.data != null) {
+    tableData.value = responseList.data
+  } else {
+    tableData.value = []
+  }
+  tablePagination.itemCount = responseList.total
+}
+
+const handleListQuery = async () => {
+  const pageSize = tablePagination.pageSize ?? 10
+  const page = tablePagination.page ?? 1
+
+  // 处理过滤条件
+  const filter: FilterForm = {
+    sn: form.sn,
+    type: form.type,
+  }
+
+  await queryBatchDownloadList(pageSize, page, filter)
+}
+
+onMounted(async () => {
+  // 请求查询数据
+  await handleListQuery()
+})
+
+</script>

+ 39 - 36
src/views/dashboard/component/infoCard.vue

@@ -1,19 +1,22 @@
 <template>
-  <div class="flex items-center justify-between gap-x-4 overflow-hidden rounded bg-[var(--card-color)] p-6 shadow-xs duration-300 transition-ease-in-out">
+  <div
+    class="flex items-center justify-between gap-x-4 overflow-hidden rounded bg-[var(--card-color)] p-6 shadow-xs duration-300 transition-ease-in-out">
     <div class="flex-1">
       <span class="text-sm font-medium text-neutral-450">{{ props.data.title }}</span>
       <div class="mt-1 mb-1.5 flex gap-x-4 text-2xl text-neutral-700 dark:text-neutral-400">
         <NNumberAnimation :to="props.data.value" show-separator :precision="props.data.precision" />
       </div>
       <div class="flex items-center">
-        <div v-if="props.data.growth !== undefined" class="flex items-center gap-x-0.5 rounded-xs px-1.5 py-0.5 text-xs duration-300" :class="props.data.percentage > 0
-          ? 'bg-green-100 text-green-600 dark:bg-green-500/20 dark:text-green-400'
-          : 'bg-rose-100 text-rose-600 dark:bg-rose-500/20 dark:text-rose-400'
-          ">
+        <div v-if="props.data.growth !== undefined"
+          class="flex items-center gap-x-0.5 rounded-xs px-1.5 py-0.5 text-xs duration-300" :class="props.data.growth > 0
+            ? 'bg-green-100 text-green-600 dark:bg-green-500/20 dark:text-green-400'
+            : 'bg-rose-100 text-rose-600 dark:bg-rose-500/20 dark:text-rose-400'
+            ">
           <span :class="(props.data.growth > 0) ? 'i-ph:arrow-up' : 'i-ph:arrow-down'"></span>
           <span class="font-medium">{{ Math.abs(props.data.growth) }}</span>
         </div>
-        <span v-if="props.data.description !== undefined" class="ml-2 text-neutral-500 dark:text-neutral-400">{{ props.data.description }}</span>
+        <span v-if="props.data.description !== undefined" class="ml-2 text-neutral-500 dark:text-neutral-400">{{
+          props.data.description }}</span>
       </div>
     </div>
     <div>
@@ -46,22 +49,22 @@ const props = defineProps<{
 const iconClass = computed(() => {
   let iconColorClass = ''
   switch (props.data.backgroundColor) {
-  case 'indigo':
-    iconColorClass = 'text-indigo-50 dark:text-indigo-150'
-    break;
-  case 'blue':
-    iconColorClass = 'text-blue-50 dark:text-blue-150'
-    break;
-  case 'emerald':
-    iconColorClass = 'text-emerald-50 dark:text-emerald-150'
-    break;
-  case 'orange':
-    iconColorClass = 'text-orange-50 dark:text-orange-150'
-    break;
+    case 'indigo':
+      iconColorClass = 'text-indigo-50 dark:text-indigo-150'
+      break;
+    case 'blue':
+      iconColorClass = 'text-blue-50 dark:text-blue-150'
+      break;
+    case 'emerald':
+      iconColorClass = 'text-emerald-50 dark:text-emerald-150'
+      break;
+    case 'orange':
+      iconColorClass = 'text-orange-50 dark:text-orange-150'
+      break;
 
-  default: // 默认indigo
-    iconColorClass = 'text-indigo-50 dark:text-indigo-150'
-    break;
+    default: // 默认indigo
+      iconColorClass = 'text-indigo-50 dark:text-indigo-150'
+      break;
   }
   return props.data.iconClass + ' ' + iconColorClass
 })
@@ -69,22 +72,22 @@ const iconClass = computed(() => {
 const iconBgClass = computed(() => {
   let iconBgColorClass = ''
   switch (props.data.backgroundColor) {
-  case 'indigo':
-    iconBgColorClass = 'text-indigo-500/5 bg-indigo-400 ring-4 ring-indigo-200 duration-300 dark:bg-indigo-650 dark:ring-indigo-500/30'
-    break;
-  case 'blue':
-    iconBgColorClass = 'text-blue-500/5 bg-blue-400 ring-4 ring-blue-200 duration-300 dark:bg-blue-650 dark:ring-blue-500/30'
-    break;
-  case 'emerald':
-    iconBgColorClass = 'text-emerald-500/5 bg-emerald-400 ring-4 ring-emerald-200 duration-300 dark:bg-emerald-650 dark:ring-emerald-500/30'
-    break;
-  case 'orange':
-    iconBgColorClass = 'text-orange-500/5 bg-orange-400 ring-4 ring-orange-200 duration-300 dark:bg-orange-650 dark:ring-orange-500/30'
-    break;
+    case 'indigo':
+      iconBgColorClass = 'text-indigo-500/5 bg-indigo-400 ring-4 ring-indigo-200 duration-300 dark:bg-indigo-650 dark:ring-indigo-500/30'
+      break;
+    case 'blue':
+      iconBgColorClass = 'text-blue-500/5 bg-blue-400 ring-4 ring-blue-200 duration-300 dark:bg-blue-650 dark:ring-blue-500/30'
+      break;
+    case 'emerald':
+      iconBgColorClass = 'text-emerald-500/5 bg-emerald-400 ring-4 ring-emerald-200 duration-300 dark:bg-emerald-650 dark:ring-emerald-500/30'
+      break;
+    case 'orange':
+      iconBgColorClass = 'text-orange-500/5 bg-orange-400 ring-4 ring-orange-200 duration-300 dark:bg-orange-650 dark:ring-orange-500/30'
+      break;
 
-  default: // 默认indigo
-    iconBgColorClass = 'text-indigo-500/5 bg-indigo-400 ring-4 ring-indigo-200 duration-300 dark:bg-indigo-650 dark:ring-indigo-500/30'
-    break;
+    default: // 默认indigo
+      iconBgColorClass = 'text-indigo-500/5 bg-indigo-400 ring-4 ring-indigo-200 duration-300 dark:bg-indigo-650 dark:ring-indigo-500/30'
+      break;
   }
   return iconBgColorClass
 })

+ 88 - 235
src/views/dashboard/index.vue

@@ -1,14 +1,14 @@
 <script setup lang="ts">
+import { NSpin } from 'naive-ui';
 import { Chart, type G2Spec, type Mark, type Composition, type TooltipTitle } from '@antv/g2';
 import { onMounted, watch, ref, computed, onUnmounted } from 'vue'
 
 import InfoCard, { type InfoCardData } from './component/infoCard.vue'
 
 import { usePersonalization } from '@/composable/usePersonalization'
-import thc from '@/utils/themeColor'
 
+import { getShutdownStatisticsDate, getShutdownStatistics } from '@/service/dashboard'
 import type { ShutdownStatisticsInfo } from '@/service/dashboard/interface'
-import { it } from 'node:test';
 
 defineOptions({
   name: 'Dashboard',
@@ -16,8 +16,7 @@ defineOptions({
 
 const { isDark, color } = usePersonalization()
 
-// const cardList = ref(generateCardData())
-const cardList = computed<InfoCardData>(() => {
+const cardList = computed<InfoCardData[]>(() => {
   const date = new Date(shutdownStatisticsInfo.value.date);
   // const year = date.getFullYear();
   const month = date.getMonth() + 1; // getMonth() 返回 0-11,需要 +1
@@ -55,7 +54,6 @@ const cardList = computed<InfoCardData>(() => {
     {
       title: `${month}月总数`,
       value: dayTotal.monthCount,
-      growth: dayTotal.monthGrowth,
       iconClass: 'i-ph:calendar-dots-bold',
       backgroundColor: 'indigo',
       precision: 0
@@ -71,135 +69,6 @@ const shutdownYearChart = ref<HTMLDivElement | null>(null)
 let shutdownYearChartInstance: Chart | null = null
 let shutdownYearChartResizeHandler: (() => void) | null = null
 
-// const shutdownYearChart2 = ref<HTMLDivElement | null>(null)
-// let shutdownYearChart2Instance: Chart | null = null
-// let shutdownYearChart2ResizeHandler: (() => void) | null = null
-
-const CHART_CONFIG = {
-  MONTHS: Array.from({ length: 12 }, (_, i) => `${i + 1}月`),
-}
-
-const getBusinessLinesConfig = () => [
-  {
-    name: '主要收入',
-    color: color.value,
-    dataRange: { min: 30000, max: 85000 },
-  },
-  {
-    name: '核心业务',
-    color: thc.cyan[500],
-    dataRange: { min: 25000, max: 75000 },
-  },
-  {
-    name: '辅助收入',
-    color: thc.lime[500],
-    dataRange: { min: 15000, max: 50000 },
-  },
-  {
-    name: '订阅收入',
-    color: thc.orange[500],
-    dataRange: { min: 10000, max: 35000 },
-  },
-  {
-    name: '广告收入',
-    color: thc.pink[500],
-    dataRange: { min: 8000, max: 25000 },
-  },
-]
-
-const barChartSelectedLegend = ref(getBusinessLinesConfig()[0].name)
-
-const generateRandomData = (
-  baseMin: number,
-  baseMax: number,
-  trend: 'up' | 'down' | 'stable' = 'stable',
-) => {
-  const data = []
-  let currentBase = (baseMin + baseMax) / 2
-
-  for (let i = 0; i < 12; i++) {
-    if (trend === 'up') {
-      currentBase += (baseMax - baseMin) * 0.03
-    } else if (trend === 'down') {
-      currentBase -= (baseMax - baseMin) * 0.02
-    }
-
-    const baseVariation = (baseMax - baseMin) * 0.6 * (Math.random() - 0.5)
-    const seasonalFactor = Math.sin((i * Math.PI) / 6) * (baseMax - baseMin) * 0.15
-    const randomSpike = Math.random() > 0.8 ? (baseMax - baseMin) * 0.2 * (Math.random() - 0.5) : 0
-
-    const variation = baseVariation + seasonalFactor + randomSpike
-    const value = Math.max(baseMin * 0.3, Math.min(90000, currentBase + variation))
-    data.push(Math.round(value))
-  }
-  return data
-}
-
-const businessLinesWithData = computed(() =>
-  getBusinessLinesConfig().map((line) => ({
-    ...line,
-    data: generateRandomData(line.dataRange.min, line.dataRange.max, 'up'),
-  })),
-)
-
-const createTooltipConfig = (formatter?: any) => ({
-  trigger: 'axis',
-  backgroundColor: isDark.value ? thc.neutral[750] : '#fff',
-  borderWidth: 0,
-  padding: 8,
-  extraCssText: 'box-shadow: none;',
-  textStyle: {
-    color: isDark.value ? thc.neutral[400] : thc.neutral[600],
-    fontSize: 12,
-  },
-  axisPointer: {
-    type: 'none',
-  },
-  ...(formatter && { formatter }),
-})
-
-const chartDataManager = {
-  getMonths: () => CHART_CONFIG.MONTHS,
-
-  getAllLines: () => businessLinesWithData.value,
-
-  getLineByName: (name: string) => {
-    return businessLinesWithData.value.find((line) => line.name === name)
-  },
-
-  getAllNames: () => {
-    return businessLinesWithData.value.map((line) => line.name)
-  },
-
-  getAllColors: () => {
-    return businessLinesWithData.value.map((line) => line.color)
-  },
-
-  getHighestRevenueLine: () => {
-    return businessLinesWithData.value.reduce((prev, current) => {
-      const prevTotal = prev.data.reduce((sum, value) => sum + value, 0)
-      const currentTotal = current.data.reduce((sum, value) => sum + value, 0)
-      return prevTotal > currentTotal ? prev : current
-    })
-  },
-
-  getLowestRevenueLine: () => {
-    return businessLinesWithData.value.reduce((prev, current) => {
-      const prevTotal = prev.data.reduce((sum, value) => sum + value, 0)
-      const currentTotal = current.data.reduce((sum, value) => sum + value, 0)
-      return prevTotal < currentTotal ? prev : current
-    })
-  },
-
-  getCurrentMonthData: (month: number) => {
-    return businessLinesWithData.value.map((line) => ({
-      name: line.name,
-      value: line.data[month],
-      color: line.color,
-    }))
-  },
-}
-
 interface ShutdownWeeklyChartData {
   type: '总数' | '北美' | '澳洲'
   date: string
@@ -229,6 +98,7 @@ function initShutdownWeeklyChart() {
   chart.options({
     type: 'view',
     autoFit: true,
+    theme: { type: isDark.value ? 'dark' : 'light' },
     title: {
       title: "Shutdown周曲线",
       align: 'center'
@@ -300,8 +170,8 @@ function initShutdownWeeklyChart() {
   chart.render()
 
   shutdownWeeklyChartInstance = chart
-  shutdownWeeklyChartResizeHandler = () => chart.resize()
-  window.addEventListener('resize', shutdownWeeklyChartResizeHandler, { passive: true })
+  // shutdownWeeklyChartResizeHandler = () => chart.resize()
+  // window.addEventListener('resize', shutdownWeeklyChartResizeHandler, { passive: true })
 }
 
 interface ShutdownYearChartData {
@@ -326,6 +196,7 @@ function initShutdownYearChart() {
   chart.options({
     type: 'interval',
     autoFit: true,
+    theme: { type: isDark.value ? 'dark' : 'light' },
     data: {
       type: 'inline',
       value: data,
@@ -390,7 +261,6 @@ function initShutdownYearChart() {
     });
   }
 
-
   // 监听图例点击事件 - 当用户通过图例进行筛选时触发
   chart.on('legend:filter', (e) => {
     const { nativeEvent, data } = e;
@@ -406,147 +276,130 @@ function initShutdownYearChart() {
   });
 
   shutdownYearChartInstance = chart
-  shutdownYearChartResizeHandler = () => chart.resize()
-  window.addEventListener('resize', shutdownYearChartResizeHandler, { passive: true })
+  // shutdownYearChartResizeHandler = () => chart.resize()
+  // window.addEventListener('resize', shutdownYearChartResizeHandler, { passive: true })
 }
 
-// function initShutdownYearChart2() {
-//   if (!shutdownYearChart2.value) return
-
-//   // 初始化图表实例
-//   const chart = new Chart({
-//     container: shutdownYearChart2.value,
-//   });
-
-//   shutdownYearChart2Instance = chart
-//   shutdownYearChart2ResizeHandler = () => chart.resize()
-//   window.addEventListener('resize', shutdownYearChart2ResizeHandler, { passive: true })
-// }
+const isInit = ref(true)
 
 // 模拟数据
 const shutdownStatisticsInfo = ref<ShutdownStatisticsInfo>({
-  date: "2025-10-16",
+  date: "1970-01-01",
   dayTotal: {
-    count: 687,
-    growth: 14,
-    weekly: [0, 0, 456, 234, 103, 43, 100],
+    count: 0,
+    growth: 0,
+    weekly: [0, 0, 0, 0, 0, 0, 0],
 
-    monthCount: 6800,
-    monthGrowth: 340,
-    year: [0, 0, 0, 0, 0, 0, 0, 5867, 3245, 6780, 3400, 5800],
+    monthCount: 0,
+    year: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
   },
   usTotal: {
-    count: 580,
-    growth: 32,
-    weekly: [0, 0, 234, 123, 98, 23, 55],
+    count: 0,
+    growth: 0,
+    weekly: [0, 0, 0, 0, 0, 0, 0],
 
-    monthCount: 6800,
-    monthGrowth: 340,
-    year: [0, 0, 0, 0, 0, 0, 0, 5867, 3245, 6780, 3400, 5800],
+    monthCount: 0,
+    year: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
   },
   auTotal: {
-    count: 120,
-    growth: 9,
-    weekly: [0, 0, 44, 21, 5, 12, 36],
+    count: 0,
+    growth: 0,
+    weekly: [0, 0, 0, 0, 0, 0, 0],
 
-    monthCount: 6800,
-    monthGrowth: 340,
-    year: [0, 0, 0, 0, 0, 0, 0, 5867, 3245, 6780, 3400, 5800],
+    monthCount: 0,
+    year: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
   }
 })
 
-onMounted(() => {
-  // TODO 请求数据
+onMounted(async () => {
+  // 请求数据
+  const latestDate = await getShutdownStatisticsDate()
+  const info = await getShutdownStatistics(latestDate)
+  shutdownStatisticsInfo.value = info
 
   initShutdownWeeklyChart()
   initShutdownYearChart()
-  // initShutdownYearChart2()
+
+  isInit.value = false
 })
 
 onUnmounted(() => {
   if (shutdownWeeklyChartInstance) {
-    if (shutdownWeeklyChartResizeHandler) {
-      window.removeEventListener('resize', shutdownWeeklyChartResizeHandler)
-      shutdownWeeklyChartResizeHandler = null
-    }
+    // if (shutdownWeeklyChartResizeHandler) {
+    //   window.removeEventListener('resize', shutdownWeeklyChartResizeHandler)
+    //   shutdownWeeklyChartResizeHandler = null
+    // }
     shutdownWeeklyChartInstance.destroy()
     shutdownWeeklyChartInstance = null
   }
 
   if (shutdownYearChartInstance) {
-    if (shutdownYearChartResizeHandler) {
-      window.removeEventListener('resize', shutdownYearChartResizeHandler)
-      shutdownYearChartResizeHandler = null
-    }
+    // if (shutdownYearChartResizeHandler) {
+    //   window.removeEventListener('resize', shutdownYearChartResizeHandler)
+    //   shutdownYearChartResizeHandler = null
+    // }
     shutdownYearChartInstance.destroy()
     shutdownYearChartInstance = null
   }
-
-  // if (shutdownYearChart2Instance) {
-  //   if (shutdownYearChart2ResizeHandler) {
-  //     window.removeEventListener('resize', shutdownYearChart2ResizeHandler)
-  //     shutdownYearChart2ResizeHandler = null
-  //   }
-  //   shutdownYearChart2Instance.destroy()
-  //   shutdownYearChart2Instance = null
-  // }
 })
 
-watch([isDark, color], () => {
+watch(isDark, (newVal) => {
   if (shutdownWeeklyChartInstance) {
-    if (shutdownWeeklyChartResizeHandler) {
-      window.removeEventListener('resize', shutdownWeeklyChartResizeHandler)
-      shutdownWeeklyChartResizeHandler = null
-    }
-    shutdownWeeklyChartInstance.destroy()
-    shutdownWeeklyChartInstance = null
+    shutdownWeeklyChartInstance.theme({ type: newVal ? 'dark' : 'light' })
+    shutdownWeeklyChartInstance.render()
   }
-
   if (shutdownYearChartInstance) {
-    if (shutdownYearChartResizeHandler) {
-      window.removeEventListener('resize', shutdownYearChartResizeHandler)
-      shutdownYearChartResizeHandler = null
-    }
-    shutdownYearChartInstance.destroy()
-    shutdownYearChartInstance = null
+    shutdownYearChartInstance.theme({ type: newVal ? 'dark' : 'light' })
+    shutdownYearChartInstance.render()
   }
-
-  // if (shutdownYearChart2Instance) {
-  //   if (shutdownYearChart2ResizeHandler) {
-  //     window.removeEventListener('resize', shutdownYearChart2ResizeHandler)
-  //     shutdownYearChart2ResizeHandler = null
-  //   }
-  //   shutdownYearChart2Instance.destroy()
-  //   shutdownYearChart2Instance = null
-  // }
-
-  initShutdownWeeklyChart()
-  initShutdownYearChart()
-  // initShutdownYearChart2()
 })
+
+// watch([isDark, color], () => {
+//   if (shutdownWeeklyChartInstance) {
+//     if (shutdownWeeklyChartResizeHandler) {
+//       window.removeEventListener('resize', shutdownWeeklyChartResizeHandler)
+//       shutdownWeeklyChartResizeHandler = null
+//     }
+//     shutdownWeeklyChartInstance.destroy()
+//     shutdownWeeklyChartInstance = null
+//   }
+
+//   if (shutdownYearChartInstance) {
+//     if (shutdownYearChartResizeHandler) {
+//       window.removeEventListener('resize', shutdownYearChartResizeHandler)
+//       shutdownYearChartResizeHandler = null
+//     }
+//     shutdownYearChartInstance.destroy()
+//     shutdownYearChartInstance = null
+//   }
+
+//   initShutdownWeeklyChart()
+//   initShutdownYearChart()
+// })
 </script>
 <template>
-  <div class="space-y-4 p-4">
-    <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
-      <InfoCard v-for="item in cardList" :key="item.title" :data="item" />
-    </div>
+  <NSpin :show="isInit">
+    <div class="space-y-4 p-4">
+      <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
+        <InfoCard v-for="item in cardList" :key="item.title" :data="item" />
+      </div>
 
-    <div class="grid grid-cols-1 gap-4 overflow-hidden lg:grid-cols-12">
-      <div class="col-span-1 lg:col-span-8">
-        <div
-          class="rounded bg-[var(--card-color)] px-5 pt-5 pb-4.5 shadow-xs transition-background-color-300 transition-ease-in-out"
-          style="height: 400px">
-          <div ref="shutdownWeeklyChart" class="h-full" />
+      <div class="grid grid-cols-1 gap-4 overflow-hidden lg:grid-cols-12">
+        <div class="col-span-1 lg:col-span-8">
+          <div
+            class="rounded bg-[var(--card-color)] px-5 pt-5 pb-4.5 shadow-xs transition-background-color-300 transition-ease-in-out"
+            style="height: 400px">
+            <div ref="shutdownWeeklyChart" class="h-full" />
+          </div>
         </div>
-      </div>
-      <div class="col-span-1 lg:col-span-4">
-        <div
-          class="flex flex-col rounded bg-[var(--card-color)] px-5 pt-5 pb-4.5 shadow-xs transition-[background-color]-300 transition-ease-in-out"
-          style="height: 400px">
-          <div ref="shutdownYearChart" class="flex-1" />
-          <!-- <div ref="shutdownYearChart2" style="height: 150px" /> -->
+        <div class="col-span-1 lg:col-span-4">
+          <div
+            class="flex flex-col rounded bg-[var(--card-color)] px-5 pt-5 pb-4.5 shadow-xs transition-[background-color]-300 transition-ease-in-out"
+            style="height: 400px">
+            <div ref="shutdownYearChart" class="flex-1" />
+          </div>
         </div>
       </div>
     </div>
-  </div>
+  </NSpin>
 </template>

+ 69 - 0
src/views/shutdown-manage/index.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="main-wrap flex flex-col gap-y-2 p-4">
+    <div class="flex flex-col gap-y-2">
+      <NCard ref="headerCardRef">
+        <div class="flex gap-4">
+          <NFormItem class="inline-block" :show-feedback="false" label="选择日期">
+            <NDatePicker :formatted-value="infoDate" type="date" @update:value="updateDateSelectValue"
+              :is-date-disabled="disablePastDates" />
+          </NFormItem>
+        </div>
+      </NCard>
+      <NCard ref="contextCardRef" :style="cardHeight" class="overflow-auto">
+        <NCollapse>
+          <NCollapseItem v-for="[sn, info] in Object.entries(collapseList)" :title="sn" :key="sn">
+            <NTimeline class="p-x-6">
+              <NTimelineItem v-for="timePoint in info.shutdownTimeList" type="error" :content="timePoint.time" />
+            </NTimeline>
+          </NCollapseItem>
+        </NCollapse>
+      </NCard>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { NCard, NFormItem, NDatePicker, NTimeline, NTimelineItem } from 'naive-ui'
+import { useFullCard } from '@/composable/comp/useFullCard'
+import type { ShutdownDataMap } from '@/service/shutdownManage/interface'
+import { getShutdownInfoDate, getShutdownManageInfoMap } from '@/service/shutdownManage'
+
+
+defineOptions({
+  name: 'ShutdownManage',
+})
+
+const headerCardRef = useTemplateRef<InstanceType<typeof NCard>>('headerCardRef')
+const contextCardRef = useTemplateRef<InstanceType<typeof NCard>>('contextCardRef')
+const { height, updateMaxHeight } = useFullCard(contextCardRef)
+
+const cardHeight = computed(() => {
+  console.log({ height: height.value })
+  return { height: (height.value === undefined || height.value === 0 ? 480 : height.value) + 'px' }
+})
+
+let infoDateLast: string | undefined = undefined
+const infoDate = ref<string | null>(null)
+
+// 禁用最新数据之后的日期
+const disablePastDates = (ts: number) => {
+  const lastDate = new Date(infoDateLast ?? Date.now())
+  return ts > lastDate.getTime()
+}
+
+// 切换日期
+const updateDateSelectValue = async (value: number | null, formattedValue: string | null) => {
+  if (formattedValue != null) {
+    collapseList.value = await getShutdownManageInfoMap(formattedValue)
+  }
+}
+
+const collapseList = ref<ShutdownDataMap>({})
+
+onMounted(async () => {
+  infoDateLast = await getShutdownInfoDate()
+  infoDate.value = infoDateLast ?? null
+  collapseList.value = await getShutdownManageInfoMap()
+})
+
+</script>

+ 306 - 29
src/views/sites-analysis/component/siteRuntimeChart.vue

@@ -1,31 +1,297 @@
 <template>
-  <NSlider v-model:value="timeRange" :max="getMaxRange()" :marks="dateMarks" range :format-tooltip="sliderFormatTooltip"
-    @dragend="handleDragEnd" />
+  <div class="flex p-x-4 gap-2">
+    <NSlider v-model:value="timeRange" :max="getMaxRange()" :marks="dateMarks" range
+      :format-tooltip="sliderFormatTooltip" @dragend="handleDragEnd" />
+    <NButton quaternary circle @click="() => { showChartConfig = true }">
+      <span class="i-ph:caret-circle-double-left-bold h-6 w-6" />
+    </NButton>
+    <NDrawer v-model:show="showChartConfig" :width="680" placement="right" :close-on-esc="false" :mask-closable="false">
+      <NDrawerContent title="Chart配置" closable>
+        <NForm class="h-full flex flex-col" :model="formModel" label-placement="top">
+          <NFormItem label="使用数据类型">
+            <NRadioGroup v-model:value="formModel.dataType" name="dataType">
+              <NSpace>
+                <NPopover trigger="hover">
+                  <template #trigger>
+                    <NRadio value="cycle">
+                      周期
+                    </NRadio>
+                  </template>
+                  <span>只显示5min的标准周期数据</span>
+                </NPopover>
+                <NPopover trigger="hover">
+                  <template #trigger>
+                    <NRadio value="all">
+                      全部
+                    </NRadio>
+                  </template>
+                  <span>显示全部数据,包括召唤和事件变化</span>
+                </NPopover>
+              </NSpace>
+            </NRadioGroup>
+          </NFormItem>
+          <NFormItem class="flex-1" label="绘图选项">
+            <NTransfer class="h-full" v-model:value="formModel.selectChartConfig"
+              :options="optionsChartConfig.transferOption" :render-source-list="renderSourceList" source-filterable />
+          </NFormItem>
+        </NForm>
+        <template #footer>
+          <NButton>确定</NButton>
+        </template>
+      </NDrawerContent>
+    </NDrawer>
+  </div>
   <div ref="chartRuntime" class="w-full"></div>
 </template>
 
 <script setup lang="ts">
 import { Chart, type G2Spec, type Mark, type Composition } from '@antv/g2'
-import { NSlider } from 'naive-ui';
+import { NButton, NDrawer, NDrawerContent, NSlider, NTransfer, NTree, NForm, NFormItem, NRadio, NRadioGroup, NSpace, NPopover } from 'naive-ui';
+import type { TransferRenderSourceList, TreeOption, TransferOption } from 'naive-ui';
 import { ref, onMounted, onBeforeUnmount } from 'vue'
 import dayjs from 'dayjs'
 
+import { usePersonalization } from '@/composable/usePersonalization'
+import { useSiteRuntimeChartConfigStore } from '@/stores/modules/views/siteRuntimeChartConfig';
+
+import type { RuntimeItem } from '@/service/sitesAnalysis/interface';
+
 const props = defineProps<{
   height?: number
-  data: RuntimeDataType[] // 数据必须按正确的时间排序
+  data: RuntimeItem[] // 数据必须按正确的时间排序
 }>()
 
+const { isDark, color } = usePersonalization()
+const runtimeChartConfigStore = useSiteRuntimeChartConfigStore()
+
+interface FormModelType {
+  dataType: string
+  selectChartConfig: string[]
+}
+
+const formModel = ref<FormModelType>({
+  dataType: 'cycle',
+  selectChartConfig: runtimeChartConfigStore.getConfig()
+})
+
+interface ChartConfigOption extends TreeOption {
+  label: string
+  value: string
+  children?: ChartConfigOption[]
+  array?: false | { // 是否是数组
+    key: string,
+    index: number
+  }
+}
+
+// TODO 暂时没包含aPower相关配置
+const chartConfigOption: ChartConfigOption[] = [
+  {
+    label: "状态信息",
+    value: "stateInfo",
+    children: [
+      { label: "工作模式(mode)", value: "mode" },
+      { label: "运行状态(run_status)", value: "run_status" },
+      { label: "EMS-DSP运行状态(slaver_stat)", value: "slaver_stat" },
+      { label: "市电状态(elecnet_state)", value: "elecnet_state" },
+      { label: "系统SOC(soc)", value: "soc" },
+      { label: "环境温度(t_amb)", value: "t_amb" },
+      { label: "4G信号(signal)", value: "signal" },
+      { label: "wifi信号(wifiSignal)", value: "wifiSignal" },
+      { label: "连接方式(connType)", value: "connType" },
+      { label: "发电机状态(genStat)", value: "genStat" },
+      { label: "远端光伏接入数量(remoteSolarEn)", value: "remoteSolarEn" },
+      { label: "主动离网(offgridreason)", value: "offgridreason" },
+      { label: "调度策略(name)", value: "name" },
+      { label: "电费类型(electricity_type)", value: "electricity_type" },
+      { label: "市电相制(gridPhaseConSet)", value: "gridPhaseConSet" },
+      { label: "紧急电源模式(v2lModeEnable)", value: "v2lModeEnable" },
+      { label: "v2l电车运行状态(v2lRunState)", value: "v2lRunState" },
+    ]
+  },
+  {
+    label: "开关状态",
+    value: "switchState",
+    children: [
+      { label: "市电开关状态(main_sw[0])", value: "main_sw_0", array: { key: "main_sw", index: 0 } },
+      { label: "发电机开关状态(main_sw[1])", value: "main_sw_1", array: { key: "main_sw", index: 1 } },
+      { label: "aGate-PV开关状态(main_sw[2])", value: "main_sw_2", array: { key: "main_sw", index: 2 } },
+      { label: "智能负载1状态(pro_load[0])", value: "pro_load_0", array: { key: "pro_load", index: 0 } },
+      { label: "智能负载2状态(pro_load[1])", value: "pro_load_1", array: { key: "pro_load", index: 1 } },
+      { label: "智能负载3状态(pro_load[2])", value: "pro_load_2", array: { key: "pro_load", index: 2 } },
+      { label: "输出干接点1状态(do[0])", value: "do_0", array: { key: "do", index: 0 } },
+      { label: "输出干接点2状态(do[1])", value: "do_1", array: { key: "do", index: 1 } },
+      { label: "输出干接点3状态(do[2])", value: "do_2", array: { key: "do", index: 2 } },
+      { label: "输出干接点4状态(do[3])", value: "do_3", array: { key: "do", index: 3 } },
+      { label: "输入干接点1状态(di[0])", value: "di_0", array: { key: "di", index: 0 } },
+      { label: "输入干接点2状态(di[1])", value: "di_1", array: { key: "di", index: 1 } },
+      { label: "输入干接点3状态(di[2])", value: "di_2", array: { key: "di", index: 2 } },
+      { label: "输入干接点4状态(di[3])", value: "di_3", array: { key: "di", index: 3 } },
+      { label: "光伏电表前Apbox继电器状态(BFPVApboxRelay)", value: "BFPVApboxRelay" },
+      { label: "市电继电器2状态(gridRelay2)", value: "gridRelay2" },
+      { label: "光伏继电器2状态(pvRelay2)", value: "pvRelay2" },
+      { label: "黑启动继电器状态(blackStartRelay)", value: "blackStartRelay" },
+      { label: "MPPT工作状态(mpptSta)", value: "mpptSta" },
+    ]
+  },
+  {
+    label: "功率数据",
+    value: "powerData",
+    children: [
+      { label: "市电功率(p_uti)", value: "p_uti" },
+      { label: "光伏发电功率(p_sun)", value: "p_sun" },
+      { label: "发电机发电功率(p_gen)", value: "p_gen" },
+      { label: "aPower功率(p_fhp)", value: "p_fhp" },
+      { label: "家庭负载功率(p_load)", value: "p_load" },
+      { label: "近端光伏功率(solarPower)", value: "solarPower" },
+      { label: "远端光伏1功率(remoteSolar1Power)", value: "remoteSolar1Power" },
+      { label: "远端光伏2功率(remoteSolar2Power)", value: "remoteSolar2Power" },
+      { label: "DSP内置电表NEM光伏功率(DSPNEMPVPower)", value: "DSPNEMPVPower" },
+      { label: "三相光伏功率(threePhasePvPower)", value: "threePhasePvPower" },
+      { label: "MPPT光伏总功率(mpptAllPower)", value: "mpptAllPower" },
+      { label: "aPwoerS逆变侧功率(acPower)", value: "acPower" },
+      { label: "aPowerS交流侧光伏功率(aggAcPvPower)", value: "aggAcPvPower" },
+    ]
+  },
+  {
+    label: "电量数据",
+    value: "energyData",
+    children: [
+      { label: "市电输入电量(kwh_uti_in)", value: "kwh_uti_in" },
+      { label: "市电输出电量(kwh_uti_out)", value: "kwh_uti_out" },
+      { label: "光伏发电量(kwh_sun)", value: "kwh_sun" },
+      { label: "发电机发电量(kwh_gen)", value: "kwh_gen" },
+      { label: "aPower放电电量(kwh_fhp_di)", value: "kwh_fhp_di" },
+      { label: "aPower充电电量(kwh_fhp_chg)", value: "kwh_fhp_chg" },
+      { label: "家庭负载用电量(kwh_load)", value: "kwh_load" },
+      { label: "智能负载线路使用电量(kwhIqLoad1)", value: "kwhIqLoad1" },
+      { label: "近端光伏日电量(proximalSolarWh)", value: "proximalSolarWh" },
+      { label: "远端光伏1日电量(remoteSolar1Wh)", value: "remoteSolar1Wh" },
+      { label: "远端光伏2日电量(remoteSolar2Wh)", value: "remoteSolar2Wh" },
+      { label: "远端光伏3日电量(remoteSolar3Wh)", value: "remoteSolar3Wh" },
+      { label: "meterkit日电量(meterkitPvWh)", value: "meterkitPvWh" },
+      { label: "第二辅助电表日电量(secondaryPvWh)", value: "secondaryPvWh" },
+      { label: "MPPT日电量(mpptWh)", value: "mpptWh" },
+    ]
+  },
+  {
+    label: "其他数据",
+    value: "otherData",
+    children: [
+      { label: "油机电压(genVoltage)", value: "genVoltage" },
+
+    ]
+  }
+]
+
+interface ChartConfigKeyMapValue {
+  label: string
+  array?: false | { // 是否是数组
+    key: string,
+    index: number
+  }
+}
+
+interface ChartConfigKeyMap {
+  [key: string]: ChartConfigKeyMapValue
+}
+
+function flattenTree(list: ChartConfigOption[]): {
+  keyMap: ChartConfigKeyMap,
+  transferOption: TransferOption[]
+} {
+  const result: {
+    keyMap: ChartConfigKeyMap,
+    transferOption: TransferOption[]
+  } = {
+    keyMap: {},
+    transferOption: []
+  }
+  function flatten(_list: ChartConfigOption[] = []) {
+    _list.forEach((item) => {
+      if (item.children != null) {
+        flatten(item.children)
+      } else {
+        result.keyMap[item.value] = {
+          label: item.label,
+          array: item.array
+        }
+        result.transferOption.push(item)
+      }
+    })
+  }
+  flatten(list)
+  return result
+}
+
+const optionsChartConfig = flattenTree(chartConfigOption)
+
+// 转换并解析运行时json数据
+const transformRuntimeData = (data?: RuntimeItem[]): RuntimeDataType[] => {
+  if (data === undefined) return []
+  return data.map(d => {
+    const obj = JSON.parse(d.data_201);
+    const dataArea = obj['dataArea']
+    if (dataArea === null) {
+      return {
+        time: d.time
+      }
+    }
+
+    // 从keyMap中获取数据配置信息
+    const keyTab: Record<string, string | number> = {}
+    for (const [key, value] of Object.entries(optionsChartConfig.keyMap)) {
+      if (value.array != null && value.array !== false) {
+        keyTab[key] = dataArea[value.array.key] ? dataArea[value.array.key][value.array.index] ?? undefined : undefined
+      } else {
+        keyTab[key] = dataArea[key] ?? undefined
+      }
+    }
+
+    return {
+      time: d.time,
+      // ...(dataArea['mode'] != null && { RunMode: dataArea['mode'] }),
+      // ...(dataArea['run_status'] != null && { RunStatus: dataArea['run_status'] }),
+      // ...(dataArea['elecnet_state'] != null && { GridStatus: dataArea['elecnet_state'] })
+      ...keyTab
+    }
+  })
+}
+
+const showChartConfig = ref(false)
+const renderSourceList: TransferRenderSourceList = function ({
+  onCheck,
+  pattern
+}) {
+  return h(NTree, {
+    style: 'margin: 0 4px;',
+    keyField: 'value',
+    cascade: true,
+    checkable: true,
+    selectable: false,
+    blockLine: true,
+    checkOnClick: true,
+    data: chartConfigOption,
+    pattern,
+    checkedKeys: formModel.value.selectChartConfig,
+    onUpdateCheckedKeys: (checkedKeys: Array<string>) => {
+      runtimeChartConfigStore.updateConfig(checkedKeys)
+      onCheck(checkedKeys)
+    }
+  })
+}
+
 const chartRuntime = ref<HTMLDivElement>()
 let runtimeChart: Chart | null = null
 
-export interface RuntimeDataType {
+interface RuntimeDataType {
   time: string // format YYYY-MM-DD HH:mm:ss
   [key: string]: string | number // number是曲线 string是事件
 }
 
 interface ChartDataType {
   time: number // 距离其实时间的秒数
-  [key: string]: string | number // number是曲线 string是事件
+  value: string | number // number是曲线 string是事件
 }
 
 // 模拟数据:10 分钟,每秒一个点
@@ -48,7 +314,6 @@ const diffSeconds = endTimeDayjs.diff(startTimeDayjs, 'second');
 
 // 计算某个点的时间 YYYY-MM-DD HH:mm:ss
 const getPointTime = (value: number): string => {
-  console.log({ startTime, endTime })
   if (value >= diffSeconds) return endTime
   return startTimeDayjs.add(value, 'second').format('YYYY-MM-DD HH:mm:ss')
 }
@@ -105,54 +370,63 @@ const dateMarks = computed<{ [markValue: number]: string }>(() => {
 });
 
 const sliderFormatTooltip = (value: number) => {
-  console.log({ value, time: getPointTime(value) })
+  // console.log({ value, time: getPointTime(value) })
   return getPointTime(value)
 }
 
-interface RuntimeChartOpt {
+interface RuntimeChartChildrenData {
+  type: string
   data: ChartDataType[]
-  childrenName: Record<string, string>
+}
+
+interface RuntimeChartOpt {
+  childrenData: Record<string, RuntimeChartChildrenData>
 }
 
 // 生成Chart数据和子字段SET表
 const handleChartData = (objList: RuntimeDataType[]): RuntimeChartOpt => {
-  const chartData: ChartDataType[] = []
-  const childrenSet: Record<string, string> = {};
+  const childrenData: Record<string, RuntimeChartChildrenData> = {};
 
   objList.map(obj => {
-    chartData.push({
-      ...obj,
-      time: dayjs(obj.time).diff(startTimeDayjs, 'second'),
-    })
     Object.entries(obj).forEach(([key, value]) => {
-      if (key !== 'time') {
-        const keyType = typeof value
-        childrenSet[key] = keyType
+      const keyType = typeof value
+      if (key !== 'time' && (keyType === 'string' || keyType === 'number')) {
+        if (childrenData[key] == null) {
+          childrenData[key] = {
+            type: keyType,
+            data: []
+          }
+        }
+        childrenData[key].data.push({
+          time: dayjs(obj.time).diff(startTimeDayjs, 'second'),
+          value
+        })
       }
     });
   })
 
   return {
-    data: chartData,
-    childrenName: childrenSet
+    childrenData
   }
 }
-const runtimeChartOpt = handleChartData(props.data)
+const runtimeChartOpt = handleChartData(transformRuntimeData(props.data))
+console.log({ runtimeChartOpt })
 
 // 生成Chart子配置
 const getChildrenChartOpt = (xRange: [number, number]): (Mark | Composition)[] => {
   const childrenChart: (Mark | Composition)[] = []
 
-  Object.entries(runtimeChartOpt.childrenName).forEach(([key, value]) => {
-    if (value === 'number') {
+  Object.entries(runtimeChartOpt.childrenData).forEach(([key, value]) => {
+    if (value.type === 'number') {
       // 子Chart为曲线
       childrenChart.push(
         {
           type: 'line',
           height: 100,
+          data: value.data,
           encode: {
             x: 'time',
-            y: key
+            y: 'value'
           },
           axis: {
             x: false,
@@ -173,15 +447,16 @@ const getChildrenChartOpt = (xRange: [number, number]): (Mark | Composition)[] =
           }
         }
       )
-    } else if (value === 'string') {
+    } else if (value.type === 'string') {
       // 子Chart是事件点
       childrenChart.push(
         {
           type: 'point',
           height: 100,
+          data: value.data,
           encode: {
             x: 'time',
-            y: key
+            y: 'value'
           },
           axis: {
             x: false,
@@ -213,7 +488,8 @@ const getChartOpt = (xRange: [number, number]): G2Spec => {
   return {
     type: 'spaceFlex',
     direction: 'col',
-    data: runtimeChartOpt.data,
+    // data: runtimeChartOpt.data,
+    theme: { type: isDark ? 'dark' : 'light' },
     children: getChildrenChartOpt(xRange)
   }
 }
@@ -226,6 +502,7 @@ const handleDragEnd = () => {
   } else {
     runtimeChart.options(getChartOpt(timeRange.value))
   }
+  console.log("render")
   runtimeChart.render()
 }
 
@@ -233,7 +510,7 @@ onMounted(() => {
   runtimeChart = new Chart({
     container: chartRuntime.value!,
     autoFit: true,
-    height: 100 * Object.keys(runtimeChartOpt.childrenName).length,
+    height: 100 * Object.keys(runtimeChartOpt.childrenData).length,
   });
 
   runtimeChart.options(getChartOpt(timeRange.value))

+ 2 - 1
src/views/sites-analysis/component/siteTimeline.vue

@@ -1,7 +1,8 @@
 <template>
   <div class="flex flex-col gap-y-2 p-4">
     <NCard>
-      <NTimeline>
+      <NResult v-if="timelineComponents.length === 0" status="418" title="没有事件发生" />
+      <NTimeline v-else>
         <component v-for="comp in timelineComponents" :is="comp.component" />
       </NTimeline>
     </NCard>

+ 15 - 30
src/views/sites-analysis/index.vue

@@ -1,21 +1,23 @@
 <template>
   <div class="flex flex-col gap-y-2 p-4">
-    <NTabs type="line" animated>
-      <NTabPane name="timeline" tab="时间线">
-        <SiteTimeline v-if="showData" :id="analysisID" :data="timelineData ?? []" />
-      </NTabPane>
-      <NTabPane name="runtime" tab="运行时">
-        <SiteRuntimeChart v-if="showData" :data="transformRuntimeData(runtimeData)" />
-      </NTabPane>
-    </NTabs>
+    <NSpin :show="isLoading" :delay="1000">
+      <NTabs type="line" animated>
+        <NTabPane name="timeline" tab="时间线">
+          <SiteTimeline v-if="showData" :id="analysisID" :data="timelineData ?? []" />
+        </NTabPane>
+        <NTabPane name="runtime" tab="运行时">
+          <SiteRuntimeChart v-if="showData" :data="runtimeData ?? []" />
+        </NTabPane>
+      </NTabs>
+    </NSpin>
   </div>
 </template>
 
 <script setup lang="ts">
 import { useRoute } from 'vue-router'
-import { NTabs, NTabPane } from 'naive-ui'
+import { NTabs, NTabPane, NSpin } from 'naive-ui'
 import SiteTimeline from './component/siteTimeline.vue'
-import SiteRuntimeChart, { type RuntimeDataType } from './component/siteRuntimeChart.vue'
+import SiteRuntimeChart from './component/siteRuntimeChart.vue'
 import type { TimelineItem, RuntimeItem } from '@/service/sitesAnalysis/interface';
 import { getAnalysisData } from '@/service/sitesAnalysis';
 
@@ -30,32 +32,14 @@ const analysisID = route.params.name as string
 
 const message = useMessage()
 
+const isLoading = ref(false)
 const showData = ref(false)
 
 let timelineData: TimelineItem[] | undefined = undefined
 let runtimeData: RuntimeItem[] | undefined = undefined
 
-// 转换并解析运行时json数据
-const transformRuntimeData = (data?: RuntimeItem[]): RuntimeDataType[] => {
-  if (data === undefined) return []
-  return data.map(d => {
-    const obj = JSON.parse(d.data_201);
-    const dataArea = obj['dataArea']
-    if (dataArea === null) {
-      return {
-        time: d.time
-      }
-    }
-    return {
-      time: d.time,
-      ...(dataArea['mode'] != null && { RunMode: dataArea['mode'] }),
-      ...(dataArea['run_status'] != null && { RunStatus: dataArea['run_status'] }),
-      ...(dataArea['elecnet_state'] != null && { GridStatus: dataArea['elecnet_state'] })
-    }
-  })
-}
-
 onMounted(async () => {
+  isLoading.value = true
   // 获取Timeline数据
   const analysisData = await getAnalysisData(analysisID)
   console.log({ analysisData })
@@ -69,5 +53,6 @@ onMounted(async () => {
   } else {
     message.error("分析数据获取失败!")
   }
+  isLoading.value = false
 })
 </script>

+ 12 - 1
src/views/sites-analysis/manage.vue

@@ -9,6 +9,9 @@
           <NFormItem label="进度" path="progress">
             <NSelect v-model:value="form.progress" :options="progressOptions" style="width: 120px" clearable />
           </NFormItem>
+          <NFormItem label="触发类型" path="trigger">
+            <NSelect v-model:value="form.trigger" :options="triggerOptions" style="width: 120px" clearable />
+          </NFormItem>
         </NForm>
         <div class="flex gap-2">
           <NButton type="success" @click="createOrEditAnalysis()">
@@ -75,6 +78,7 @@ interface AnalysisFilterForm extends Partial<AnalysisItemInfo> { }
 const [form, , resetForm] = useResettableReactive<AnalysisFilterForm>({
   sn: '',
   progress: undefined,
+  trigger: undefined,
 })
 
 const message = useMessage()
@@ -87,6 +91,11 @@ const progressOptions = [
   { label: 'complete', value: 'complete' },
 ]
 
+const triggerOptions = [
+  { label: '自动', value: 'auto' },
+  { label: '手动', value: 'manual' }
+]
+
 const handleQueryClick = () => {
   // 请求查询数据
   handleListQuery()
@@ -333,6 +342,7 @@ const querySitesAnalysisList = async (pageSize: number, pageNum: number, filter?
   } else {
     tableData.value = res.data
   }
+  tablePagination.itemCount = res.total
 }
 
 const createSiteAnalysisConfig = async (data: AnalysisConfig) => {
@@ -357,7 +367,8 @@ const handleListQuery = async () => {
   // 处理过滤条件
   const filter = {
     sn: form.sn,
-    progress: form.progress
+    progress: form.progress,
+    trigger: form.trigger
   }
 
   querySitesAnalysisList(pageSize, page, filter)

+ 33 - 2
src/views/sites-monitor/component/powerFlowChart.vue

@@ -3,11 +3,14 @@
 </template>
 
 <script setup lang="ts">
-import { Chart, ChartEvent } from '@antv/g2'
+import { Chart, ChartEvent, register } from '@antv/g2'
 import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { usePersonalization } from '@/composable/usePersonalization'
 
 const message = useMessage()
 
+const { isDark } = usePersonalization()
+
 const props = defineProps<{
   height?: number
 }>()
@@ -63,6 +66,17 @@ const voidData: PowerChartData[] = (() => {
   }).flat()
 })()
 
+// 自定义色板
+// PV: #F1A502
+// GEN: #5398FF
+// Grid: #BB56FD
+// aPower: #41D187
+// Home: #0BF5FF
+// register('palette.customPowerFlow', customPalette);
+// function customPalette() {
+//   return ['#F1A502', '#5398FF', '#BB56FD', '#41D187', '#0BF5FF'];
+// }
+
 onMounted(() => {
   chart = new Chart({
     container: chartContainer.value!,
@@ -72,13 +86,23 @@ onMounted(() => {
 
   chart
     .area()
+    .theme({ type: isDark ? 'dark' : 'light' })
     .data(voidData)
     .encode('x', 'Time')
     .encode('y', 'Power')
     .encode('color', 'powerType')
     .transform({ type: 'stackY' })
     .interaction('elementHighlight', { background: true })
-    .scale('color', { palette: 'accent' })
+    .scale('color', {
+      palette: 'accent',
+      relations: [
+        ['aPower', '#41D187'],
+        ['Generator', '#5398FF'],
+        ['HomeLoad', '#0BF5FF'],
+        ['PV', '#F1A502'],
+        ['Grid', '#BB56FD'],
+      ],
+    })
 
   chart.render();
 
@@ -133,6 +157,13 @@ const changeLegend = (legend: string[]): boolean => {
   return true
 }
 
+watch(isDark, (newVal) => {
+  if (chart) {
+    chart.theme({ type: newVal ? 'dark' : 'light' })
+    chart.render()
+  }
+})
+
 // 暴露给父组件
 defineExpose({
   redraw, // 重绘数据

+ 2 - 2
src/views/sites-monitor/index.vue

@@ -298,9 +298,9 @@ const handleDownloadCsv = async () => {
               {{ monitorSiteList.length }}
             </NStatistic>
           </NCol>
-          <NCol :span="4">
+          <!-- <NCol :span="4">
             <NButton :loading="loadingDownloadCsv" @click="handleDownloadCsv">下载CSV</NButton>
-          </NCol>
+          </NCol> -->
         </NRow>
       </NCard>
     </NCollapseTransition>

+ 53 - 0
vite.config.ts

@@ -52,6 +52,59 @@ export default defineConfig(({ mode }) => {
     },
     build: {
       sourcemap: true, // ✅ 生成 source map
+      // rolldownOptions: {
+      //   output: {
+      //     advancedChunks: {
+      //       includeDependenciesRecursively: true,
+      //       minSize: 50 * 1024, // 全局最小 50KB
+      //       maxSize: 300 * 1024, // 全局最大 300KB
+      //       minShareCount: 1,
+      //       groups: [
+      //         {
+      //           name: 'vue',
+      //           test: /node_modules[\\/]vue/,
+      //           priority: 10,
+      //         },
+      //         {
+      //           name: 'g2',
+      //           test: /node_modules[\\/]@antv[\\/]g2/,
+      //           priority: 9,
+      //         },
+      //         {
+      //           name: 'g-webgl',
+      //           test: /node_modules[\\/]@antv[\\/]g-webgl/,
+      //           priority: 9,
+      //         },
+      //         {
+      //           name: 'naive-ui',
+      //           test: /node_modules[\\/]naive-ui/,
+      //           priority: 8,
+      //         },
+      //         {
+      //           name: 'icons',
+      //           test: /node_modules[\\/]@iconify-json[\\/]/,
+      //           priority: 7,
+      //         },
+      //         {
+      //           name: 'utils',
+      //           test: /node_modules[\\/](dayjs|chroma-js|uuid|@vueuse)/,
+      //           priority: 6,
+      //         },
+      //         {
+      //           name: (id) =>
+      //             id.includes('src/components/fullScreenCanvas') ? 'fullScreenCanvas' : null,
+      //           test: (id) => id.includes('src/components/fullScreenCanvas'),
+      //           priority: 5,
+      //         },
+      //         {
+      //           name: 'vendor', // fallback
+      //           test: /node_modules/,
+      //           priority: 1,
+      //         },
+      //       ],
+      //     },
+      //   },
+      // },
     },
   }
 })