feat: add dashboard cahrts display
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"baihu/internal/constant"
|
"baihu/internal/constant"
|
||||||
"baihu/internal/database"
|
"baihu/internal/database"
|
||||||
"baihu/internal/models"
|
"baihu/internal/models"
|
||||||
@@ -57,3 +60,110 @@ func (dc *DashboardController) GetSentence(c *gin.Context) {
|
|||||||
"sentence": constant.GetRandomSentence(),
|
"sentence": constant.GetRandomSentence(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DailyStats 每日统计数据
|
||||||
|
type DailyStats struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSendStats 获取最近30天发送统计
|
||||||
|
func (dc *DashboardController) GetSendStats(c *gin.Context) {
|
||||||
|
// 获取最近30天的日期范围
|
||||||
|
now := time.Now()
|
||||||
|
startDay := now.AddDate(0, 0, -29).Format("2006-01-02")
|
||||||
|
|
||||||
|
var stats []models.SendStats
|
||||||
|
database.DB.Where("day >= ?", startDay).Find(&stats)
|
||||||
|
|
||||||
|
// 按日期聚合
|
||||||
|
dayMap := make(map[string]*DailyStats)
|
||||||
|
for _, s := range stats {
|
||||||
|
if _, ok := dayMap[s.Day]; !ok {
|
||||||
|
dayMap[s.Day] = &DailyStats{Day: s.Day}
|
||||||
|
}
|
||||||
|
ds := dayMap[s.Day]
|
||||||
|
ds.Total += s.Num
|
||||||
|
if s.Status == "success" {
|
||||||
|
ds.Success += s.Num
|
||||||
|
} else {
|
||||||
|
ds.Failed += s.Num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充缺失的日期
|
||||||
|
result := make([]DailyStats, 0, 30)
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
if ds, ok := dayMap[day]; ok {
|
||||||
|
result = append(result, *ds)
|
||||||
|
} else {
|
||||||
|
result = append(result, DailyStats{Day: day})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期排序
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].Day < result[j].Day
|
||||||
|
})
|
||||||
|
|
||||||
|
utils.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStats 任务执行统计
|
||||||
|
type TaskStats struct {
|
||||||
|
TaskID uint `json:"task_id"`
|
||||||
|
TaskName string `json:"task_name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskStats 获取最近30天任务执行占比
|
||||||
|
func (dc *DashboardController) GetTaskStats(c *gin.Context) {
|
||||||
|
now := time.Now()
|
||||||
|
startDay := now.AddDate(0, 0, -29).Format("2006-01-02")
|
||||||
|
|
||||||
|
// 按 task_id 聚合统计
|
||||||
|
var results []struct {
|
||||||
|
TaskID uint
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
database.DB.Model(&models.SendStats{}).
|
||||||
|
Select("task_id, SUM(num) as total").
|
||||||
|
Where("day >= ?", startDay).
|
||||||
|
Group("task_id").
|
||||||
|
Order("total DESC").
|
||||||
|
Find(&results)
|
||||||
|
|
||||||
|
// 获取任务名称
|
||||||
|
taskIDs := make([]uint, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
taskIDs = append(taskIDs, r.TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasks []models.Task
|
||||||
|
if len(taskIDs) > 0 {
|
||||||
|
database.DB.Where("id IN ?", taskIDs).Find(&tasks)
|
||||||
|
}
|
||||||
|
taskNameMap := make(map[uint]string)
|
||||||
|
for _, t := range tasks {
|
||||||
|
taskNameMap[t.ID] = t.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建结果
|
||||||
|
stats := make([]TaskStats, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
name := taskNameMap[r.TaskID]
|
||||||
|
if name == "" {
|
||||||
|
name = "未知任务"
|
||||||
|
}
|
||||||
|
stats = append(stats, TaskStats{
|
||||||
|
TaskID: r.TaskID,
|
||||||
|
TaskName: name,
|
||||||
|
Count: r.Total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, stats)
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ func Setup(c *Controllers) *gin.Engine {
|
|||||||
// Dashboard stats
|
// Dashboard stats
|
||||||
authorized.GET("/stats", c.Dashboard.GetStats)
|
authorized.GET("/stats", c.Dashboard.GetStats)
|
||||||
authorized.GET("/sentence", c.Dashboard.GetSentence)
|
authorized.GET("/sentence", c.Dashboard.GetSentence)
|
||||||
|
authorized.GET("/sendstats", c.Dashboard.GetSendStats)
|
||||||
|
authorized.GET("/taskstats", c.Dashboard.GetTaskStats)
|
||||||
|
|
||||||
// Task routes
|
// Task routes
|
||||||
tasks := authorized.Group("/tasks")
|
tasks := authorized.Group("/tasks")
|
||||||
|
|||||||
Generated
+118
@@ -14,6 +14,8 @@
|
|||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"apexcharts": "^5.3.6",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -25,8 +27,10 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vaul-vue": "^0.4.1",
|
"vaul-vue": "^0.4.1",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9",
|
"vue-sonner": "^2.0.9",
|
||||||
|
"vue3-apexcharts": "^1.10.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -673,6 +677,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@monaco-editor/loader": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
@@ -975,6 +985,62 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@svgdotjs/svg.draggable.js": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgdotjs/svg.filter.js": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgdotjs/svg.js": {
|
||||||
|
"version": "3.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz",
|
||||||
|
"integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Fuzzyma"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgdotjs/svg.resize.js": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4",
|
||||||
|
"@svgdotjs/svg.select.js": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgdotjs/svg.select.js": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.17",
|
"version": "0.5.17",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
@@ -1584,6 +1650,12 @@
|
|||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@yr/monotone-cubic-spline": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/alien-signals": {
|
"node_modules/alien-signals": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz",
|
||||||
@@ -1591,6 +1663,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/apexcharts": {
|
||||||
|
"version": "5.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.6.tgz",
|
||||||
|
"integrity": "sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"dependencies": {
|
||||||
|
"@svgdotjs/svg.draggable.js": "^3.0.4",
|
||||||
|
"@svgdotjs/svg.filter.js": "^3.0.8",
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4",
|
||||||
|
"@svgdotjs/svg.resize.js": "^2.0.2",
|
||||||
|
"@svgdotjs/svg.select.js": "^4.0.1",
|
||||||
|
"@yr/monotone-cubic-spline": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
@@ -1603,6 +1689,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -2718,6 +2816,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
@@ -2772,6 +2880,16 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue3-apexcharts": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.10.0.tgz",
|
||||||
|
"integrity": "sha512-sBma2In4rU5n/JBrv8KVb8if+IoY019Dse2yRDD/eRU1WGZHK07zuy9erefKzbJ7T3wP9+Jsy9bH6Vdjy85HZg==",
|
||||||
|
"license": "see LICENSE in LICENSE",
|
||||||
|
"peerDependencies": {
|
||||||
|
"apexcharts": ">=4.0.0",
|
||||||
|
"vue": ">=3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xterm": {
|
"node_modules/xterm": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"apexcharts": "^5.3.6",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -26,8 +28,10 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"vaul-vue": "^0.4.1",
|
"vaul-vue": "^0.4.1",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"vue-sonner": "^2.0.9",
|
"vue-sonner": "^2.0.9",
|
||||||
|
"vue3-apexcharts": "^1.10.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+16
-1
@@ -102,7 +102,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
stats: () => request<Stats>('/stats'),
|
stats: () => request<Stats>('/stats'),
|
||||||
sentence: () => request<{ sentence: string }>('/sentence')
|
sentence: () => request<{ sentence: string }>('/sentence'),
|
||||||
|
sendStats: () => request<DailyStats[]>('/sendstats'),
|
||||||
|
taskStats: () => request<TaskStatsItem[]>('/taskstats')
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
changePassword: (data: { old_password: string; new_password: string }) =>
|
changePassword: (data: { old_password: string; new_password: string }) =>
|
||||||
@@ -289,3 +291,16 @@ export interface LoginLogListResponse {
|
|||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
day: string
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatsItem {
|
||||||
|
task_id: number
|
||||||
|
task_name: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,23 +3,14 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ListTodo, FileCode, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
|
import { ListTodo, FileCode, Variable, Clock, Play, ScrollText } from 'lucide-vue-next'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import { api, type Task, type Stats } from '@/api'
|
import { api, type Stats, type DailyStats, type TaskStatsItem } from '@/api'
|
||||||
|
import ApexCharts from 'apexcharts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const stats = ref<Stats>({ tasks: 0, scripts: 0, envs: 0, logs: 0, scheduled: 0, running: 0 })
|
const stats = ref<Stats>({ tasks: 0, scripts: 0, envs: 0, logs: 0, scheduled: 0, running: 0 })
|
||||||
const recentTasks = ref<Task[]>([])
|
const sendStats = ref<DailyStats[]>([])
|
||||||
|
const taskStats = ref<TaskStatsItem[]>([])
|
||||||
onMounted(async () => {
|
const chartsLoaded = ref(false)
|
||||||
try {
|
|
||||||
const [statsData, tasksRes] = await Promise.all([
|
|
||||||
api.dashboard.stats(),
|
|
||||||
api.tasks.list({ page: 1, page_size: 5 })
|
|
||||||
])
|
|
||||||
stats.value = statsData
|
|
||||||
recentTasks.value = tasksRes.data
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const statItems = [
|
const statItems = [
|
||||||
{ key: 'tasks', label: '任务总数', icon: ListTodo, route: '/tasks' },
|
{ key: 'tasks', label: '任务总数', icon: ListTodo, route: '/tasks' },
|
||||||
{ key: 'scripts', label: '脚本数量', icon: FileCode, route: '/editor' },
|
{ key: 'scripts', label: '脚本数量', icon: FileCode, route: '/editor' },
|
||||||
@@ -32,10 +23,197 @@ const statItems = [
|
|||||||
function navigateTo(route?: string) {
|
function navigateTo(route?: string) {
|
||||||
if (route) router.push(route)
|
if (route) router.push(route)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderLineChart = () => {
|
||||||
|
const options = {
|
||||||
|
series: [
|
||||||
|
{ name: '执行总数', data: sendStats.value.map(item => item.total) },
|
||||||
|
{ name: '执行成功', data: sendStats.value.map(item => item.success) },
|
||||||
|
{ name: '执行失败', data: sendStats.value.map(item => item.failed) }
|
||||||
|
],
|
||||||
|
chart: {
|
||||||
|
type: 'line',
|
||||||
|
height: 240,
|
||||||
|
toolbar: { show: false },
|
||||||
|
background: 'transparent',
|
||||||
|
animations: {
|
||||||
|
enabled: true,
|
||||||
|
easing: 'easeinout',
|
||||||
|
speed: 800,
|
||||||
|
animateGradually: { enabled: true, delay: 150 },
|
||||||
|
dynamicAnimation: { enabled: true, speed: 350 }
|
||||||
|
},
|
||||||
|
dropShadow: {
|
||||||
|
enabled: true,
|
||||||
|
color: '#000',
|
||||||
|
top: 18,
|
||||||
|
left: 7,
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stroke: { curve: 'smooth', width: 3, lineCap: 'round' },
|
||||||
|
markers: {
|
||||||
|
size: 6,
|
||||||
|
colors: ['#3b82f6', '#10b981', '#ef4444'],
|
||||||
|
strokeColors: '#fff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
hover: { size: 8, sizeOffset: 3 }
|
||||||
|
},
|
||||||
|
xaxis: {
|
||||||
|
categories: sendStats.value.map(item => item.day.slice(5)),
|
||||||
|
axisBorder: { show: false },
|
||||||
|
axisTicks: { show: false },
|
||||||
|
labels: { style: { colors: '#64748b', fontSize: '12px', fontFamily: 'Inter, sans-serif' } }
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
labels: {
|
||||||
|
style: { colors: '#64748b', fontSize: '12px', fontFamily: 'Inter, sans-serif' },
|
||||||
|
formatter: (val: number) => val + ' 次'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors: ['#3b82f6', '#10b981', '#ef4444'],
|
||||||
|
fill: {
|
||||||
|
type: 'gradient',
|
||||||
|
gradient: {
|
||||||
|
shade: 'light',
|
||||||
|
type: 'vertical',
|
||||||
|
shadeIntensity: 0.5,
|
||||||
|
gradientToColors: ['#60a5fa', '#34d399', '#f87171'],
|
||||||
|
inverseColors: false,
|
||||||
|
opacityFrom: 0.8,
|
||||||
|
opacityTo: 0.1,
|
||||||
|
stops: [0, 100]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
borderColor: '#e2e8f0',
|
||||||
|
strokeDashArray: 3,
|
||||||
|
xaxis: { lines: { show: false } },
|
||||||
|
yaxis: { lines: { show: true } },
|
||||||
|
padding: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
horizontalAlign: 'right',
|
||||||
|
floating: true,
|
||||||
|
offsetY: -25,
|
||||||
|
offsetX: -5,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
markers: { width: 8, height: 8, radius: 4 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
shared: true,
|
||||||
|
intersect: false,
|
||||||
|
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||||
|
style: { fontSize: '12px', fontFamily: 'Inter, sans-serif' },
|
||||||
|
x: { show: true },
|
||||||
|
marker: { show: true },
|
||||||
|
custom: ({ series, dataPointIndex, w }: { series: number[][], dataPointIndex: number, w: any }) => {
|
||||||
|
const total = series[0]?.[dataPointIndex] ?? 0
|
||||||
|
const success = series[1]?.[dataPointIndex] ?? 0
|
||||||
|
const failed = series[2]?.[dataPointIndex] ?? 0
|
||||||
|
const rate = total > 0 ? ((success / total) * 100).toFixed(1) : '0.0'
|
||||||
|
return `<div class="bg-card text-foreground p-3 rounded-lg shadow-lg border border-border">
|
||||||
|
<div class="font-medium mb-2">${w.globals.categoryLabels[dataPointIndex]}</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center"><span class="w-2 h-2 bg-blue-500 rounded-full mr-2"></span><span class="text-sm text-muted-foreground">总数:</span></span>
|
||||||
|
<span class="text-sm font-medium">${total} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span><span class="text-sm text-muted-foreground">成功:</span></span>
|
||||||
|
<span class="text-sm font-medium">${success} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span><span class="text-sm text-muted-foreground">失败:</span></span>
|
||||||
|
<span class="text-sm font-medium">${failed} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border pt-1 mt-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-muted-foreground">成功率:</span>
|
||||||
|
<span class="text-sm font-medium">${rate}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responsive: [{ breakpoint: 768, options: { chart: { height: 200 }, legend: { position: 'bottom', offsetY: 0 } } }]
|
||||||
|
}
|
||||||
|
new ApexCharts(document.querySelector("#stats-chart"), options).render()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPieChart = () => {
|
||||||
|
if (taskStats.value.length === 0) return
|
||||||
|
const options = {
|
||||||
|
series: taskStats.value.map(item => item.count),
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
height: 240,
|
||||||
|
toolbar: { show: false },
|
||||||
|
background: 'transparent',
|
||||||
|
animations: {
|
||||||
|
enabled: true,
|
||||||
|
easing: 'easeinout',
|
||||||
|
speed: 800,
|
||||||
|
animateGradually: { enabled: true, delay: 150 },
|
||||||
|
dynamicAnimation: { enabled: true, speed: 350 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
labels: taskStats.value.map(item => item.task_name),
|
||||||
|
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'],
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
markers: { width: 8, height: 8, radius: 4 }
|
||||||
|
},
|
||||||
|
plotOptions: {
|
||||||
|
pie: {
|
||||||
|
donut: { size: '0%' },
|
||||||
|
expandOnClick: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dataLabels: {
|
||||||
|
enabled: true,
|
||||||
|
formatter: (val: number) => val.toFixed(1) + '%',
|
||||||
|
style: { fontSize: '10px', fontFamily: 'Inter, sans-serif', fontWeight: 'bold' }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||||
|
style: { fontSize: '12px', fontFamily: 'Inter, sans-serif' },
|
||||||
|
y: { formatter: (val: number) => val + ' 次' }
|
||||||
|
},
|
||||||
|
responsive: [{ breakpoint: 768, options: { chart: { height: 200 }, legend: { position: 'bottom' } } }]
|
||||||
|
}
|
||||||
|
new ApexCharts(document.querySelector("#pie-chart"), options).render()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [statsData, sendStatsData, taskStatsData] = await Promise.all([
|
||||||
|
api.dashboard.stats(),
|
||||||
|
api.dashboard.sendStats(),
|
||||||
|
api.dashboard.taskStats()
|
||||||
|
])
|
||||||
|
stats.value = statsData
|
||||||
|
sendStats.value = sendStatsData
|
||||||
|
taskStats.value = taskStatsData
|
||||||
|
setTimeout(() => {
|
||||||
|
renderLineChart()
|
||||||
|
renderPieChart()
|
||||||
|
chartsLoaded.value = true
|
||||||
|
}, 100)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold tracking-tight">数据仪表</h2>
|
<h2 class="text-2xl font-bold tracking-tight">数据仪表</h2>
|
||||||
<p class="text-muted-foreground">查看系统运行状态和统计数据</p>
|
<p class="text-muted-foreground">查看系统运行状态和统计数据</p>
|
||||||
@@ -53,32 +231,37 @@ function navigateTo(route?: string) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div class="grid gap-4 lg:grid-cols-10">
|
||||||
<CardHeader>
|
<Card class="lg:col-span-7 h-[340px]">
|
||||||
<CardTitle>最近任务</CardTitle>
|
<CardHeader class="pb-2">
|
||||||
<CardDescription>最近创建的定时任务</CardDescription>
|
<CardTitle>执行统计</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>最近30天任务执行情况</CardDescription>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div class="space-y-2">
|
<CardContent>
|
||||||
<div v-if="recentTasks.length === 0" class="text-sm text-muted-foreground py-8 text-center">
|
<div id="stats-chart" class="w-full h-[240px]">
|
||||||
暂无任务
|
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
</div>
|
加载中...
|
||||||
<div
|
|
||||||
v-for="task in recentTasks"
|
|
||||||
:key="task.id"
|
|
||||||
class="flex items-center justify-between py-2 border-b last:border-0"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-muted-foreground text-sm">#{{ task.id }}</span>
|
|
||||||
<span class="font-medium">{{ task.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 text-muted-foreground">
|
|
||||||
<code class="text-xs bg-muted px-2 py-0.5 rounded">{{ task.schedule }}</code>
|
|
||||||
<span class="w-2 h-2 rounded-full" :class="task.enabled ? 'bg-green-500' : 'bg-gray-400'" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
<Card class="lg:col-span-3 h-[340px]">
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardTitle>任务占比</CardTitle>
|
||||||
|
<CardDescription>最近30天任务执行分布</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div id="pie-chart" class="w-full h-[240px]">
|
||||||
|
<div v-if="!chartsLoaded" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="taskStats.length === 0" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user