diff --git a/internal/controllers/dashboard_controller.go b/internal/controllers/dashboard_controller.go index 677e50c..76acfb0 100644 --- a/internal/controllers/dashboard_controller.go +++ b/internal/controllers/dashboard_controller.go @@ -1,6 +1,9 @@ package controllers import ( + "sort" + "time" + "baihu/internal/constant" "baihu/internal/database" "baihu/internal/models" @@ -57,3 +60,110 @@ func (dc *DashboardController) GetSentence(c *gin.Context) { "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) +} diff --git a/internal/router/router.go b/internal/router/router.go index b4dea7f..dd93731 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -102,6 +102,8 @@ func Setup(c *Controllers) *gin.Engine { // Dashboard stats authorized.GET("/stats", c.Dashboard.GetStats) authorized.GET("/sentence", c.Dashboard.GetSentence) + authorized.GET("/sendstats", c.Dashboard.GetSendStats) + authorized.GET("/taskstats", c.Dashboard.GetTaskStats) // Task routes tasks := authorized.Group("/tasks") diff --git a/web/package-lock.json b/web/package-lock.json index 1506e4b..17954e8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,8 @@ "@vueuse/core": "^14.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "apexcharts": "^5.3.6", + "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -25,8 +27,10 @@ "tailwindcss": "^4.1.18", "vaul-vue": "^0.4.1", "vue": "^3.5.24", + "vue-chartjs": "^5.3.3", "vue-router": "^4.6.4", "vue-sonner": "^2.0.9", + "vue3-apexcharts": "^1.10.0", "xterm": "^5.3.0" }, "devDependencies": { @@ -673,6 +677,12 @@ "@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": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", @@ -975,6 +985,62 @@ "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": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1584,6 +1650,12 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", @@ -1591,6 +1663,20 @@ "dev": true, "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": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1603,6 +1689,18 @@ "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": { "version": "0.7.1", "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": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", @@ -2772,6 +2880,16 @@ "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": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", diff --git a/web/package.json b/web/package.json index 3fde7dc..f87f039 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,8 @@ "@vueuse/core": "^14.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", + "apexcharts": "^5.3.6", + "chart.js": "^4.5.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", @@ -26,8 +28,10 @@ "tailwindcss": "^4.1.18", "vaul-vue": "^0.4.1", "vue": "^3.5.24", + "vue-chartjs": "^5.3.3", "vue-router": "^4.6.4", "vue-sonner": "^2.0.9", + "vue3-apexcharts": "^1.10.0", "xterm": "^5.3.0" }, "devDependencies": { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2f4e335..c805b64 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -102,7 +102,9 @@ export const api = { }, dashboard: { stats: () => request('/stats'), - sentence: () => request<{ sentence: string }>('/sentence') + sentence: () => request<{ sentence: string }>('/sentence'), + sendStats: () => request('/sendstats'), + taskStats: () => request('/taskstats') }, settings: { changePassword: (data: { old_password: string; new_password: string }) => @@ -289,3 +291,16 @@ export interface LoginLogListResponse { page: 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 +} diff --git a/web/src/views/dashboard/Dashboard.vue b/web/src/views/dashboard/Dashboard.vue index bce374e..32715b8 100644 --- a/web/src/views/dashboard/Dashboard.vue +++ b/web/src/views/dashboard/Dashboard.vue @@ -3,23 +3,14 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { ListTodo, FileCode, Variable, Clock, Play, ScrollText } from 'lucide-vue-next' 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 stats = ref({ tasks: 0, scripts: 0, envs: 0, logs: 0, scheduled: 0, running: 0 }) -const recentTasks = ref([]) - -onMounted(async () => { - 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 sendStats = ref([]) +const taskStats = ref([]) +const chartsLoaded = ref(false) const statItems = [ { key: 'tasks', label: '任务总数', icon: ListTodo, route: '/tasks' }, { key: 'scripts', label: '脚本数量', icon: FileCode, route: '/editor' }, @@ -32,10 +23,197 @@ const statItems = [ function navigateTo(route?: string) { 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 `
+
${w.globals.categoryLabels[dataPointIndex]}
+
+
+ 总数: + ${total} 次 +
+
+ 成功: + ${success} 次 +
+
+ 失败: + ${failed} 次 +
+
+
+ 成功率: + ${rate}% +
+
+
+
` + } + }, + 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 {} +})