diff --git a/package.json b/package.json index b4d64ef..5a771dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kne-components/components-admin", - "version": "1.1.37", + "version": "1.1.38", "description": "用于实现一个后台管理系统的必要组件", "scripts": { "init": "husky", @@ -85,9 +85,11 @@ "dependencies": { "@ant-design/icons": "^6.0.0", "@kne/app-children-router": "^0.1.7", + "@kne/column-split": "^1.0.5", "@kne/count-down": "^0.2.2", "@kne/is-empty": "^1.0.1", "@kne/json-view": "^0.1.1", + "@kne/react-box": "^0.1.9", "@kne/react-icon": "^0.1.4", "@kne/react-intl": "^0.1.9", "@kne/react-org-chart": "^0.1.6", diff --git a/src/components/Apis/getApis.js b/src/components/Apis/getApis.js index 82899f1..572287f 100644 --- a/src/components/Apis/getApis.js +++ b/src/components/Apis/getApis.js @@ -162,6 +162,16 @@ const getApis = options => { retry: { url: `${prefix}/task/retry`, method: 'POST' + }, + statistics: { + getOverview: { + url: `${prefix}/task/statistics`, + method: 'GET' + }, + sse: { + url: `${prefix}/task/statistics/sse`, + method: 'GET' + } } }, tenantAdmin: { @@ -506,6 +516,16 @@ const getApis = options => { url: `${prefix}/message/templates/send`, method: 'POST' } + }, + statistics: { + getOverview: { + url: `${prefix}/message/statistics`, + method: 'GET' + }, + sse: { + url: `${prefix}/message/statistics/sse`, + method: 'GET' + } } }, mq: { diff --git a/src/components/MessageManger/Dashboard/HistorySection.js b/src/components/MessageManger/Dashboard/HistorySection.js new file mode 100644 index 0000000..1ea0461 --- /dev/null +++ b/src/components/MessageManger/Dashboard/HistorySection.js @@ -0,0 +1,267 @@ +import { createWithRemoteLoader } from '@kne/remote-loader'; +import Fetch from '@kne/react-fetch'; +import { useState, useRef } from 'react'; +import { Button, Col, Row, Space, Segmented } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { Card as BoxCard } from '@kne/react-box'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import { + PALETTE, + RANGE_OPTIONS, + tooltipStyle, + itemTooltipStyle, + pieSeries, + legendPieTypeStyle, + legendPieCodeStyle, + legendCenterStyle, + lineChartGrid, + lineChartGridWithRotatedLabels, + lineSmooth, + axisLineStyle, + axisLabelStyle, + splitLineStyle +} from './constants'; +import SectionHeader from './SectionHeader'; +import { getClientIanaTimezone } from '../utils'; +import style from './dashboard.module.scss'; + +const HistorySection = createWithRemoteLoader({ + modules: ['components-thirdparty:Echart'] +})( + withLocale(({ remoteModules, apis }) => { + const [Echart] = remoteModules; + const { formatMessage } = useIntl(); + const [range, setRange] = useState('7d'); + const reloadRef = useRef(() => {}); + + return ( + <> + + ({ + label: formatMessage({ id: `Range_${r}` }), + value: r + }))} + value={range} + onChange={setRange} + /> + + + } + /> + + { + reloadRef.current = reload; + + const templateStats = data?.templateStats || {}; + const byType = data?.byType || {}; + + // 发送趋势折线图 + const trendOption = (() => { + const recentTrend = data?.recentTrend || []; + const recentTrendByType = data?.recentTrendByType || []; + if (recentTrend.length === 0) return null; + + const dateMap = {}; + recentTrend.forEach(item => { + dateMap[item.date] = { date: item.date, total: item.count }; + }); + recentTrendByType.forEach(item => { + if (!dateMap[item.date]) { + dateMap[item.date] = { date: item.date, total: 0 }; + } + const key = item.type === 0 ? 'email' : 'sms'; + dateMap[item.date][key] = item.count; + }); + + const sorted = Object.values(dateMap).sort((a, b) => a.date.localeCompare(b.date)); + const dates = sorted.map(item => item.date); + const totals = sorted.map(item => item.total); + const emails = sorted.map(item => item.email || 0); + const smsList = sorted.map(item => item.sms || 0); + const manyPoints = dates.length > 14; + + return { + color: [PALETTE.total, PALETTE.email, PALETTE.sms], + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: [formatMessage({ id: 'TotalCount' }), formatMessage({ id: 'Email' }), formatMessage({ id: 'SMS' })] }, + grid: manyPoints ? lineChartGridWithRotatedLabels : lineChartGrid, + xAxis: { + type: 'category', + boundaryGap: false, + data: dates, + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { + ...axisLabelStyle, + fontSize: 11, + rotate: manyPoints ? 28 : 0, + hideOverlap: true + } + }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1 }, + series: [ + { + name: formatMessage({ id: 'TotalCount' }), + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 6, + data: totals, + lineStyle: { width: 2.5, color: PALETTE.total }, + itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' }, + areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(59,130,246,0.18)' }, { offset: 1, color: 'rgba(59,130,246,0.02)' }] } } + }, + { + name: formatMessage({ id: 'Email' }), + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 6, + data: emails, + lineStyle: { width: 2.5, color: PALETTE.email }, + itemStyle: { color: PALETTE.email, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' }, + areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(99,102,241,0.18)' }, { offset: 1, color: 'rgba(99,102,241,0.02)' }] } } + }, + { + name: formatMessage({ id: 'SMS' }), + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 6, + data: smsList, + lineStyle: { width: 2.5, color: PALETTE.sms }, + itemStyle: { color: PALETTE.sms, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' }, + areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(6,182,212,0.16)' }, { offset: 1, color: 'rgba(6,182,212,0.02)' }] } } + } + ] + }; + })(); + + // 编码饼图 + const codePieOption = (() => { + const byCode = data?.byCode || {}; + const entries = Object.entries(byCode); + if (entries.length === 0) return null; + return { + color: PALETTE.pie, + tooltip: itemTooltipStyle, + legend: legendPieCodeStyle, + series: [ + { + ...pieSeries(['50%', '42%'], ['44%', '62%']), + data: entries.map(([code, count]) => ({ name: code, value: count })) + } + ] + }; + })(); + + // 类型饼图 + const typePieOption = { + color: [PALETTE.email, PALETTE.sms], + tooltip: itemTooltipStyle, + legend: legendPieTypeStyle, + series: [ + { + ...pieSeries(['50%', '44%'], ['46%', '66%']), + data: [ + { name: formatMessage({ id: 'Email' }), value: Number(byType['0']) || 0 }, + { name: formatMessage({ id: 'SMS' }), value: Number(byType['1']) || 0 } + ] + } + ] + }; + + const historyStatItems = [ + { + label: formatMessage({ id: 'TotalRecords' }), + value: data?.totalRecords || 0, + color: PALETTE.total + }, + { + label: `${formatMessage({ id: 'Email' })}${formatMessage({ id: 'TotalRecords' })}`, + value: Number(byType['0']) || 0, + color: PALETTE.email + }, + { + label: `${formatMessage({ id: 'SMS' })}${formatMessage({ id: 'TotalRecords' })}`, + value: Number(byType['1']) || 0, + color: PALETTE.sms + }, + { + label: formatMessage({ id: 'TemplateTotal' }), + value: templateStats.total || 0, + color: '#d97706' + }, + { + label: formatMessage({ id: 'EnabledTemplates' }), + value: Number(templateStats.byStatus?.['0']) || 0, + color: PALETTE.enabled + }, + { + label: formatMessage({ id: 'DisabledTemplates' }), + value: Number(templateStats.byStatus?.['1']) || 0, + color: PALETTE.disabled + } + ]; + + return ( + <> +
+ {historyStatItems.map(item => ( +
+
{item.label}
+
+ {item.value} +
+
+ ))} +
+ + + {trendOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ + + + + {typePieOption ? : null} + + + + + {codePieOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ +
+ + ); + }} + /> + + ); + }) +); + +export default HistorySection; diff --git a/src/components/MessageManger/Dashboard/RealtimeSection.js b/src/components/MessageManger/Dashboard/RealtimeSection.js new file mode 100644 index 0000000..243aeb2 --- /dev/null +++ b/src/components/MessageManger/Dashboard/RealtimeSection.js @@ -0,0 +1,433 @@ +import { createWithRemoteLoader } from '@kne/remote-loader'; +import { useMemo } from 'react'; +import { Col, Row, Space, Tag } from 'antd'; +import { BarChartOutlined, MailOutlined, MessageOutlined } from '@ant-design/icons'; +import { Card as BoxCard, ColorfulCard } from '@kne/react-box'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import { PALETTE, TIME_PERIODS, tooltipStyle, legendCenterStyle, lineChartGrid, axisLineStyle, axisLabelStyle, splitLineStyle } from './constants'; +import useRealtimeStatisticsSSE from './useRealtimeStatisticsSSE'; +import style from './dashboard.module.scss'; + +const HOURS_IN_DAY = 24; +const STATUS_SERIES = [ + { key: 'success', label: '成功', color: '#10b981' }, + { key: 'running', label: '执行中', color: '#3b82f6' }, + { key: 'pending', label: '等待', color: '#f59e0b' }, + { key: 'canceled', label: '取消', color: '#94a3b8' }, + { key: 'failed', label: '错误', color: '#ef4444' } +]; + +/** + * 合并 hourlyTrend(按小时总量)与 hourlyTrendByType(按小时+类型),得到 0–23 点完整序列。 + * 新接口示例:hourlyTrend:[{hour:4,count:1}], hourlyTrendByType:[{hour:4,type:0,count:1}] + */ +const buildTodayHourlySlots = raw => { + const slots = Array.from({ length: HOURS_IN_DAY }, (_, h) => ({ + hour: h, + total: 0, + email: 0, + sms: 0, + hasTotalFromApi: false + })); + + const hourlyTrend = Array.isArray(raw?.hourlyTrend) ? raw.hourlyTrend : []; + const hourlyTrendByType = Array.isArray(raw?.hourlyTrendByType) ? raw.hourlyTrendByType : []; + const hasHourly = hourlyTrend.length > 0 || hourlyTrendByType.length > 0; + + if (hasHourly) { + // 参考 fastify-task:前端图表主口径使用后端聚合的 hourly 数据 + hourlyTrend.forEach(item => { + const h = Number(item?.hour); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + slots[h].total = Number(item?.count) || 0; + slots[h].hasTotalFromApi = true; + }); + + hourlyTrendByType.forEach(item => { + const h = Number(item?.hour); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + const n = Number(item?.count) || 0; + const typ = Number(item?.type); + if (typ === 0) slots[h].email += n; + else if (typ === 1) slots[h].sms += n; + }); + + for (let h = 0; h < HOURS_IN_DAY; h++) { + const s = slots[h]; + const parts = s.email + s.sms; + if (!s.hasTotalFromApi && parts > 0) { + s.total = parts; + } + } + } else { + // 仅在后端未返回 hourly 聚合时,退回 records 计算 + const records = Array.isArray(raw?.records) ? raw.records : []; + records.forEach(item => { + const date = item?.createdAt ? new Date(item.createdAt) : null; + if (!date || Number.isNaN(date.getTime())) return; + const h = date.getHours(); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + const typ = Number(item?.type); + if (typ === 0) slots[h].email += 1; + else if (typ === 1) slots[h].sms += 1; + slots[h].total += 1; + slots[h].hasTotalFromApi = true; + }); + } + + return slots; +}; + +const hasHourlyInput = raw => { + const records = raw?.records; + const a = raw?.hourlyTrend; + const b = raw?.hourlyTrendByType; + return ( + (Array.isArray(records) && records.length > 0) || + (Array.isArray(a) && a.length > 0) || + (Array.isArray(b) && b.length > 0) + ); +}; + +/** 当前接口:byType 为 { "0": n, "1": m },totalRecords 为数字 */ +const buildRealtimeDisplayStats = raw => { + if (!raw || typeof raw !== 'object') { + return { totalRecords: 0, email: 0, sms: 0 }; + } + const bt = raw.byType && typeof raw.byType === 'object' ? raw.byType : {}; + const email = Number(bt['0']) || 0; + const sms = Number(bt['1']) || 0; + let totalRecords = Number(raw.totalRecords); + if (!Number.isFinite(totalRecords)) totalRecords = 0; + return { totalRecords, email, sms }; +}; + +const RealtimeSection = createWithRemoteLoader({ + modules: ['components-thirdparty:Echart'] +})( + withLocale(({ remoteModules, apis }) => { + const [Echart] = remoteModules; + const { formatMessage } = useIntl(); + const sseUrl = apis?.messageManger?.statistics?.sse?.url; + const { realtimeData, isConnected, lastUpdatedAt } = useRealtimeStatisticsSSE(sseUrl); + + const displayStats = useMemo(() => buildRealtimeDisplayStats(realtimeData), [realtimeData]); + + const { emailPct, smsPct } = useMemo(() => { + const t = displayStats.totalRecords; + if (!t || t <= 0) return { emailPct: null, smsPct: null }; + const e = (displayStats.email / t) * 100; + const s = (displayStats.sms / t) * 100; + return { emailPct: e.toFixed(1), smsPct: s.toFixed(1) }; + }, [displayStats]); + + const lastUpdatedShort = useMemo(() => { + if (!lastUpdatedAt) return ''; + try { + return new Intl.DateTimeFormat(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).format(new Date(lastUpdatedAt)); + } catch { + return new Date(lastUpdatedAt).toLocaleTimeString(); + } + }, [lastUpdatedAt]); + + // 时段汇总:与 hourly 序列同源(buildTodayHourlySlots),按四小时段聚合 + const periodStats = useMemo(() => { + if (!realtimeData || !hasHourlyInput(realtimeData)) return []; + const slots = buildTodayHourlySlots(realtimeData); + return TIME_PERIODS.map(period => { + let email = 0; + let sms = 0; + let total = 0; + for (let h = period.start; h < period.end; h++) { + const s = slots[h]; + email += s.email; + sms += s.sms; + total += s.hasTotalFromApi ? s.total : s.email + s.sms; + } + return { ...period, total, email, sms }; + }); + }, [realtimeData]); + + /** 今日每小时趋势:按任务类型(hourlyTrendByType) */ + const hourlyOption = useMemo(() => { + const hourlyTrendByType = Array.isArray(realtimeData?.hourlyTrendByType) ? realtimeData.hourlyTrendByType : []; + if (hourlyTrendByType.length === 0) return null; + + const hours = Array.from({ length: HOURS_IN_DAY }, (_, h) => h); + const typeMap = {}; + hourlyTrendByType.forEach(item => { + const h = Number(item?.hour); + const type = String(item?.type); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY || !type) return; + if (!typeMap[type]) typeMap[type] = Array.from({ length: HOURS_IN_DAY }, () => 0); + typeMap[type][h] += Number(item?.count) || 0; + }); + + const typeKeys = Object.keys(typeMap); + if (typeKeys.length === 0) return null; + + return { + color: PALETTE.pie, + tooltip: tooltipStyle, + legend: { + ...legendCenterStyle, + data: typeKeys.map(key => `Type ${key}`) + }, + grid: { ...lineChartGrid, bottom: 28 }, + xAxis: { + type: 'category', + boundaryGap: false, + data: hours.map(h => `${String(h).padStart(2, '0')}:00`), + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { + ...axisLabelStyle, + fontSize: 10, + interval: 1, + rotate: 0 + } + }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 }, + series: typeKeys.map((key, index) => ({ + name: `Type ${key}`, + type: 'line', + symbol: 'circle', + symbolSize: 5, + smooth: 0.28, + data: typeMap[key], + lineStyle: { width: 2.5, color: PALETTE.pie[index % PALETTE.pie.length] }, + itemStyle: { color: PALETTE.pie[index % PALETTE.pie.length], borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' } + })) + }; + }, [realtimeData]); + + /** 时段对比:按状态(成功/执行中/等待/取消/错误) */ + const periodCompareOption = useMemo(() => { + const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : []; + if (hourlyTrendByStatus.length === 0) return null; + const labels = TIME_PERIODS.map(p => formatMessage({ id: p.id })); + const statusData = STATUS_SERIES.reduce((acc, cur) => ({ ...acc, [cur.key]: [0, 0, 0, 0] }), {}); + + hourlyTrendByStatus.forEach(item => { + const h = Number(item?.hour); + const status = String(item?.status || ''); + const count = Number(item?.count) || 0; + if (!Number.isFinite(h) || !statusData[status]) return; + const periodIndex = TIME_PERIODS.findIndex(p => h >= p.start && h < p.end); + if (periodIndex >= 0) statusData[status][periodIndex] += count; + }); + + return { + color: STATUS_SERIES.map(item => item.color), + tooltip: { + ...tooltipStyle, + trigger: 'axis', + axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } } + }, + legend: { ...legendCenterStyle, data: STATUS_SERIES.map(item => item.label) }, + grid: { ...lineChartGrid, bottom: 20 }, + xAxis: { + type: 'category', + data: labels, + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { color: '#64748b', fontSize: 12 } + }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 }, + series: STATUS_SERIES.map((item, index) => ({ + name: item.label, + type: 'bar', + data: statusData[item.key], + barMaxWidth: 24, + itemStyle: { color: item.color, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' }, + z: 10 - index + })) + }; + }, [realtimeData, formatMessage]); + + return ( + <> +
+
+

{formatMessage({ id: 'RealtimeData' })}

+ {realtimeData ? ( +
+ {formatMessage({ id: 'Today' })} + {' · '} + {formatMessage({ id: 'TotalCount' })} {displayStats.totalRecords} + {' · '} + {formatMessage({ id: 'Email' })}{' '} + {displayStats.email} + {emailPct != null ? ({emailPct}%) : null} + {' · '} + {formatMessage({ id: 'SMS' })}{' '} + {displayStats.sms} + {smsPct != null ? ({smsPct}%) : null} +
+ ) : ( +
{formatMessage({ id: isConnected ? 'WaitingForData' : 'RealtimeDisconnected' })}
+ )} +
+
+ + {formatMessage({ id: isConnected ? 'RealtimeConnected' : 'RealtimeDisconnected' })} + + {lastUpdatedAt ? ( + + {formatMessage({ id: 'LastUpdatedAt' })} {lastUpdatedShort} + + ) : null} +
+
+ + {realtimeData ? ( + <> +
+ } + title={{displayStats.totalRecords}} + description={ + + {formatMessage({ id: 'TodayRecords' })} + {displayStats.totalRecords > 0 && + displayStats.email + displayStats.sms !== displayStats.totalRecords ? ( + <> + {' · '} + {formatMessage({ id: 'Email' })}+{formatMessage({ id: 'SMS' })}={displayStats.email + displayStats.sms} + + ) : null} + + } + /> + } + title={{displayStats.email}} + description={ + + {formatMessage({ id: 'TodayEmail' })} + {emailPct != null ? ` · ${emailPct}%` : ''} + + } + /> + } + title={{displayStats.sms}} + description={ + + {formatMessage({ id: 'TodaySMS' })} + {smsPct != null ? ` · ${smsPct}%` : ''} + + } + /> +
+ +
+ {periodStats.map(period => ( +
+
+ + + {formatMessage({ id: period.id })} + + + {String(period.start).padStart(2, '0')}:00–{String(period.end).padStart(2, '0')}:00 + + +
+
+ {period.total} +
+
+ + + {formatMessage({ id: 'Email' })}{' '} + {period.email} + + + {formatMessage({ id: 'SMS' })}{' '} + {period.sms} + + +
+
+ ))} +
+ + + + + {periodCompareOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ + + + {hourlyOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ +
+ + ) : ( + +
+ {isConnected ? formatMessage({ id: 'WaitingForData' }) : formatMessage({ id: 'RealtimeDisconnected' })} +
+
+ )} + + ); + }) +); + +export default RealtimeSection; diff --git a/src/components/MessageManger/Dashboard/SectionHeader.js b/src/components/MessageManger/Dashboard/SectionHeader.js new file mode 100644 index 0000000..71db925 --- /dev/null +++ b/src/components/MessageManger/Dashboard/SectionHeader.js @@ -0,0 +1,10 @@ +import style from './dashboard.module.scss'; + +const SectionHeader = ({ title, extra }) => ( +
+

{title}

+ {extra} +
+); + +export default SectionHeader; diff --git a/src/components/MessageManger/Dashboard/StatCard.js b/src/components/MessageManger/Dashboard/StatCard.js new file mode 100644 index 0000000..77f86e5 --- /dev/null +++ b/src/components/MessageManger/Dashboard/StatCard.js @@ -0,0 +1,16 @@ +import classNames from 'classnames'; +import { Card, Statistic } from 'antd'; +import { PALETTE } from './constants'; +import style from './dashboard.module.scss'; + +const StatCard = ({ title, value, valueStyle, color, className }) => ( + + + +); + +export default StatCard; diff --git a/src/components/MessageManger/Dashboard/constants.js b/src/components/MessageManger/Dashboard/constants.js new file mode 100644 index 0000000..934e17c --- /dev/null +++ b/src/components/MessageManger/Dashboard/constants.js @@ -0,0 +1,152 @@ +export const RANGE_OPTIONS = ['7d', '1m', '1y']; + +export const buildUrlWithParams = (url, params = {}) => { + const query = Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&'); + if (!query) return url; + return `${url}${url.includes('?') ? '&' : '?'}${query}`; +}; + +export const PALETTE = { + email: '#6366f1', + sms: '#06b6d4', + total: '#3b82f6', + enabled: '#10b981', + disabled: '#f43f5e', + pie: ['#6366f1', '#06b6d4', '#f59e0b', '#10b981', '#8b5cf6', '#ec4899', '#ef4444', '#14b8a6'], + period: { dawn: '#818cf8', morning: '#f59e0b', afternoon: '#f97316', evening: '#6366f1' } +}; + +export const TIME_PERIODS = [ + { id: 'Dawn', start: 0, end: 6, color: PALETTE.period.dawn }, + { id: 'Morning', start: 6, end: 12, color: PALETTE.period.morning }, + { id: 'Afternoon', start: 12, end: 18, color: PALETTE.period.afternoon }, + { id: 'Evening', start: 18, end: 24, color: PALETTE.period.evening } +]; + +export const tooltipStyle = { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { color: 'rgba(100, 116, 139, 0.45)', width: 1 }, + label: { show: false } + }, + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + confine: true +}; + +export const itemTooltipStyle = { + trigger: 'item', + formatter: '{b}: {c} ({d}%)', + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + confine: true, + appendToBody: true, + /** 优先出现在指针左上,减少压住饼图与图例 */ + position(point, params, dom, rect, size) { + const x = point[0]; + const y = point[1]; + const cw = size?.contentSize?.[0] ?? 140; + const ch = size?.contentSize?.[1] ?? 48; + const vw = size?.viewSize?.[0] ?? 400; + const vh = size?.viewSize?.[1] ?? 300; + let left = x + 12; + let top = y - ch - 12; + if (top < 8) top = y + 12; + if (left + cw > vw - 8) left = Math.max(8, vw - cw - 8); + if (top + ch > vh - 8) top = Math.max(8, vh - ch - 8); + return [left, top]; + }, + extraCssText: 'border-radius:8px;box-shadow:0 4px 14px rgba(15,23,42,0.12);' +}; + +/** 饼图专用:关闭扇区放大,避免 hover 盖住图例;扇区间细缝更易读 */ +export const pieSeries = (center = ['50%', '46%'], radius = ['46%', '64%']) => ({ + type: 'pie', + radius, + center, + clockwise: true, + padAngle: 1.25, + avoidLabelOverlap: true, + itemStyle: { + borderRadius: 5, + borderColor: '#fff', + borderWidth: 2 + }, + label: { show: false }, + labelLine: { show: false }, + emphasis: { + scale: false, + itemStyle: { + shadowBlur: 14, + shadowColor: 'rgba(15,23,42,0.14)', + borderWidth: 3, + borderColor: '#fff' + }, + label: { show: false } + } +}); + +/** 消息类型分布:底部横向图例,不占右侧 */ +export const legendPieTypeStyle = { + orient: 'horizontal', + left: 'center', + bottom: 8, + icon: 'circle', + itemWidth: 10, + itemHeight: 10, + itemGap: 28, + textStyle: { color: '#64748b', fontSize: 12 } +}; + +/** 模板编码:图例放底部横向滚动,饼图居中偏上,避免与右侧长列表挤在一起 */ +export const legendPieCodeStyle = { + type: 'scroll', + orient: 'horizontal', + left: 'center', + bottom: 4, + width: '88%', + height: 44, + pageButtonItemGap: 8, + pageIconSize: 11, + pageTextStyle: { color: '#94a3b8' }, + icon: 'roundRect', + itemWidth: 10, + itemHeight: 10, + itemGap: 14, + textStyle: { color: '#64748b', fontSize: 11 } +}; + +export const legendCenterStyle = { + top: 4, + left: 'center', + itemGap: 18, + padding: [0, 0, 6, 0], + textStyle: { color: '#64748b', fontSize: 12 } +}; + +export const gridStyle = { left: '3%', right: '4%', bottom: '3%', top: 36, containLabel: true }; + +/** 折线图:边距与顶部留白,图例与坐标轴不挤占绘图区 */ +export const lineChartGrid = { left: 12, right: 16, bottom: 12, top: 52, containLabel: true }; + +/** 日期点较多时加大底部,配合斜向刻度 */ +export const lineChartGridWithRotatedLabels = { + ...lineChartGrid, + bottom: 20, + left: 14, + right: 14 +}; + +export const lineSmooth = 0.38; + +export const axisLineStyle = { lineStyle: { color: '#e2e8f0' } }; +export const axisLabelStyle = { color: '#94a3b8' }; +export const splitLineStyle = { lineStyle: { color: '#f1f5f9', type: 'dashed', width: 1 } }; diff --git a/src/components/MessageManger/Dashboard/dashboard.module.scss b/src/components/MessageManger/Dashboard/dashboard.module.scss new file mode 100644 index 0000000..fd995e9 --- /dev/null +++ b/src/components/MessageManger/Dashboard/dashboard.module.scss @@ -0,0 +1,293 @@ +.dashboardRoot { + width: 100%; + max-width: none; + margin: 0; + padding: 8px 16px 32px; + box-sizing: border-box; +} + +.sectionPanel { + background: transparent; + border: none; + box-shadow: none; + padding: 0; + border-radius: 0; +} + +/* 实时数据区块背景 */ +.realtimeSectionWrap { + background: linear-gradient(165deg, #f1f5f9 0%, #f8fafc 42%, #eef2ff 100%); + border-radius: 12px; + padding: 16px 18px 22px; + border: 1px solid rgba(226, 232, 240, 0.85); + box-sizing: border-box; +} + +.sectionDivider { + margin: 32px 0 !important; + border-color: rgba(226, 232, 240, 0.9) !important; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +/* 实时区:标题 + 一行汇总 + 状态,提高头部信息密度 */ +.realtimeHeader { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 14px; + margin-bottom: 12px; +} + +.realtimeHeaderMain { + flex: 1 1 200px; + min-width: 0; +} + +.realtimeTitle { + margin: 0 0 4px; + font-size: 16px; + font-weight: 650; + color: #0f172a; + letter-spacing: -0.02em; + line-height: 1.25; +} + +.realtimeSubline { + font-size: 12px; + color: #64748b; + line-height: 1.45; + word-break: break-word; +} + +.realtimeHeaderMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + flex-shrink: 0; +} + +.realtimeTime { + font-size: 11px; + color: #94a3b8; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sectionTitle { + margin: 0; + font-size: 18px; + font-weight: 650; + color: #0f172a; + letter-spacing: -0.02em; +} + +.metaMuted { + color: #94a3b8; + font-size: 12px; +} + +/* 顶部 KPI:由 ColorfulCard 承载,此处只做行布局 */ +.kpiRow { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: stretch; +} + +.kpiRowDense { + gap: 10px; +} + +.kpiCard { + flex: 1 1 200px; + min-width: min(100%, 200px); + max-width: 100%; +} + +.kpiCardDense { + flex: 1 1 140px; + min-width: min(100%, 140px); +} + +.kpiValue { + font-size: clamp(1.65rem, 3.6vw, 2.1rem); + font-weight: 750; + letter-spacing: -0.04em; + line-height: 1.15; + display: block; +} + +.kpiValueDense { + font-size: clamp(1.35rem, 2.8vw, 1.75rem); + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.12; + display: block; +} + +.kpiDescDense { + font-size: 11px; + line-height: 1.3; + margin-top: 2px; + opacity: 0.92; +} + +/* 时段:单容器 + 细分隔线,避免四张独立卡片 */ +.periodStrip { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + margin-top: 16px; + border-radius: 14px; + background: #FFF; + overflow: hidden; +} + +.periodStripCell { + flex: 1; + min-width: 0; + padding: 14px 12px; + text-align: center; + + &:not(:last-child) { + box-shadow: 1px 0 0 rgba(226, 232, 240, 0.95); + } +} + +@media (max-width: 640px) { + .periodStrip { + flex-direction: column; + flex-wrap: wrap; + } + + .periodStripCell:not(:last-child) { + box-shadow: 0 1px 0 rgba(226, 232, 240, 0.95); + } +} + +.periodStripLabel { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; + display: flex; + justify-content: center; +} + +.periodStripLabelInner { + justify-content: center !important; +} + +.periodStripLabel :global(.ant-tag), +.periodStripMeta :global(.ant-tag) { + text-transform: none; + letter-spacing: 0.01em; +} + +.periodStripValue { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.periodStripMeta { + margin-top: 8px; + width: 100%; + display: flex; + justify-content: center; +} + +.periodStripMetaInner { + justify-content: center !important; +} + +.chartsRow { + margin-top: 24px; +} + +.chartCol { + min-width: 0; +} + +/* 图表容器:统一内边距,避免贴边 */ +.chartCardSurface { + min-width: 0; + box-sizing: border-box; +} + +/* 历史:统计块与趋势图、双饼之间的垂直节奏 */ +.historyTrendCard { + margin-top: 20px; +} + +.historyPiesRow { + margin-top: 24px; +} + +/* 历史区:扁平统计格,左色条区分维度 */ +.historyStatsGrid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 1200px) { + .historyStatsGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 576px) { + .historyStatsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.historyStatCell { + padding: 14px 14px 14px 16px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid rgba(226, 232, 240, 0.55); + border-left-width: 3px; + border-left-style: solid; + min-width: 0; +} + +.historyStatLabel { + font-size: 12px; + color: #64748b; + line-height: 1.35; + margin-bottom: 6px; +} + +.historyStatValue { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.03em; + color: #0f172a; + line-height: 1.2; +} + +.emptyState { + text-align: center; + padding: 36px 16px; + color: #94a3b8; + font-size: 14px; +} + +.emptyStateLarge { + text-align: center; + padding: 44px 16px; + color: #94a3b8; + font-size: 14px; +} diff --git a/src/components/MessageManger/Dashboard/index.js b/src/components/MessageManger/Dashboard/index.js new file mode 100644 index 0000000..492e302 --- /dev/null +++ b/src/components/MessageManger/Dashboard/index.js @@ -0,0 +1,40 @@ +import '@kne/react-box/dist/index.css'; +import { createWithRemoteLoader } from '@kne/remote-loader'; +import { Divider } from 'antd'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import MessageMenu from '../Menu'; +import RealtimeSection from './RealtimeSection'; +import HistorySection from './HistorySection'; +import style from './dashboard.module.scss'; + +const Dashboard = createWithRemoteLoader({ + modules: ['components-core:Global@usePreset', 'components-core:Layout@Page'] +})( + withLocale(({ remoteModules, baseUrl, pageProps = {} }) => { + const [usePreset, Page] = remoteModules; + const { apis } = usePreset(); + const { formatMessage } = useIntl(); + + return ( + } + children={ +
+
+ +
+ +
+ +
+
+ } + /> + ); + }) +); + +export default Dashboard; diff --git a/src/components/MessageManger/Dashboard/useRealtimeStatisticsSSE.js b/src/components/MessageManger/Dashboard/useRealtimeStatisticsSSE.js new file mode 100644 index 0000000..9f2a97b --- /dev/null +++ b/src/components/MessageManger/Dashboard/useRealtimeStatisticsSSE.js @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { getToken } from '@kne/token-storage'; +import { buildUrlWithParams } from './constants'; +import { getClientIanaTimezone } from '../utils'; + +const isLikelyStatisticsPayload = obj => + obj && + typeof obj === 'object' && + !Array.isArray(obj) && + ('totalRecords' in obj || + 'byType' in obj || + 'hourlyTrend' in obj || + 'hourlyTrendByType' in obj || + 'byCode' in obj || + 'records' in obj); + +const unwrapStatisticsPayload = parsed => { + if (parsed == null) return null; + let cur = parsed; + if (typeof cur === 'string') { + try { + cur = JSON.parse(cur); + } catch { + return null; + } + } + if (!cur || typeof cur !== 'object') return null; + if (isLikelyStatisticsPayload(cur)) return cur; + if (cur.data != null && typeof cur.data === 'object' && !Array.isArray(cur.data) && isLikelyStatisticsPayload(cur.data)) { + return cur.data; + } + if (typeof cur.data === 'string') { + try { + return unwrapStatisticsPayload(JSON.parse(cur.data)); + } catch { + return null; + } + } + return null; +}; + +const useRealtimeStatisticsSSE = sseUrl => { + const [realtimeData, setRealtimeData] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + useEffect(() => { + if (!sseUrl || typeof window === 'undefined' || typeof window.EventSource !== 'function') { + return undefined; + } + + const source = new EventSource( + buildUrlWithParams(sseUrl, { + interval: 5, + token: getToken('X-User-Token'), + timezone: getClientIanaTimezone() + }) + ); + + source.onopen = () => setIsConnected(true); + source.onmessage = event => { + try { + const parsed = JSON.parse(event.data); + const nextData = unwrapStatisticsPayload(parsed); + if (!nextData) return; + setRealtimeData(prev => (prev && typeof prev === 'object' ? { ...prev, ...nextData } : nextData)); + setLastUpdatedAt(Date.now()); + setIsConnected(true); + } catch { + // ignore parse errors + } + }; + source.onerror = () => setIsConnected(false); + + return () => { + source.close(); + }; + }, [sseUrl]); + + return { realtimeData, isConnected, lastUpdatedAt }; +}; + +export default useRealtimeStatisticsSSE; diff --git a/src/components/MessageManger/Menu.js b/src/components/MessageManger/Menu.js index ca0c591..8283bb2 100644 --- a/src/components/MessageManger/Menu.js +++ b/src/components/MessageManger/Menu.js @@ -13,7 +13,8 @@ const MessageMenu = createWithRemoteLoader({ return ( diff --git a/src/components/MessageManger/index.js b/src/components/MessageManger/index.js index f67d13e..2a87316 100644 --- a/src/components/MessageManger/index.js +++ b/src/components/MessageManger/index.js @@ -5,6 +5,7 @@ import { useRef, useState } from 'react'; import withLocale from './withLocale'; import { buildMessageParams } from './utils'; import MessageMenu from './Menu'; +import Dashboard from './Dashboard'; import { getTemplateColumns, getRecordColumns } from './getColumns'; const TemplateList = createWithRemoteLoader({ @@ -134,7 +135,8 @@ const MessageManger = ({ baseUrl, children, ...props }) => { return ( }, + { index: true, element: }, + { path: 'templates', element: }, { path: 'records', element: } ]} > @@ -144,7 +146,7 @@ const MessageManger = ({ baseUrl, children, ...props }) => { }; export default MessageManger; -export { TemplateList, RecordList, MessageMenu }; +export { TemplateList, RecordList, MessageMenu, Dashboard }; export { default as enums } from './enums'; export { getTemplateColumns, getRecordColumns, TemplateColumnsLoader, RecordColumnsLoader } from './getColumns'; export { default as DetailContent } from './DetailContent'; diff --git a/src/components/MessageManger/locale/en-US.js b/src/components/MessageManger/locale/en-US.js index f45d39e..e38dffa 100644 --- a/src/components/MessageManger/locale/en-US.js +++ b/src/components/MessageManger/locale/en-US.js @@ -3,6 +3,7 @@ const messages = { MessageManger: 'Message Manager', TemplateList: 'Templates', RecordList: 'Records', + Dashboard: 'Dashboard', TemplateDetail: 'Template Detail', RecordDetail: 'Record Detail', Detail: 'Detail', @@ -38,7 +39,39 @@ const messages = { EmailAddress: 'Email Address', PhoneNumber: 'Phone Number', TemplateContent: 'Template Content', - PropsTip: 'Props example: {example}' + PropsTip: 'Props example: {example}', + TotalRecords: 'Total Records', + TemplateTotal: 'Total Templates', + EnabledTemplates: 'Enabled Templates', + DisabledTemplates: 'Disabled Templates', + TodayRecords: 'Today Records', + TodayEmail: 'Today Email', + RecentTrend: 'Recent Trend', + ByCodeStats: 'By Code Statistics', + TodayHourlyTrend: 'Today Hourly Trend', + Date: 'Date', + Hour: 'Hour', + TotalCount: 'Total Count', + Range_7d: 'Last 7 Days', + Range_1m: 'Last Month', + Range_1y: 'Last Year', + Refresh: 'Refresh', + RealtimeConnected: 'Realtime Connected', + RealtimeDisconnected: 'Realtime Disconnected', + LastUpdatedAt: 'Last Updated', + TypeDistribution: 'Type Distribution', + TemplateStatus: 'Template Status', + NoData: 'No Data', + HistoricalData: 'Historical Data', + RealtimeData: 'Real-time Data', + Today: 'Today', + TodaySMS: 'Today SMS', + Dawn: 'Dawn', + Morning: 'Morning', + Afternoon: 'Afternoon', + Evening: 'Evening', + PeriodCompare: 'Period Compare', + WaitingForData: 'Waiting for data...' }; export default messages; diff --git a/src/components/MessageManger/locale/zh-CN.js b/src/components/MessageManger/locale/zh-CN.js index f2eaad8..1772921 100644 --- a/src/components/MessageManger/locale/zh-CN.js +++ b/src/components/MessageManger/locale/zh-CN.js @@ -3,6 +3,7 @@ const messages = { MessageManger: '消息管理', TemplateList: '消息模板', RecordList: '发送记录', + Dashboard: '数据面板', TemplateDetail: '模板详情', RecordDetail: '发送详情', Detail: '详情', @@ -38,7 +39,39 @@ const messages = { EmailAddress: '邮箱地址', PhoneNumber: '手机号', TemplateContent: '模板内容', - PropsTip: '模板变量示例:{example}' + PropsTip: '模板变量示例:{example}', + TotalRecords: '发送总数', + TemplateTotal: '模板总数', + EnabledTemplates: '启用模板', + DisabledTemplates: '禁用模板', + TodayRecords: '今日发送', + TodayEmail: '今日邮件', + RecentTrend: '发送趋势', + ByCodeStats: '模板编码统计', + TodayHourlyTrend: '今日每小时趋势', + Date: '日期', + Hour: '小时', + TotalCount: '总数', + Range_7d: '近7天', + Range_1m: '近1个月', + Range_1y: '近1年', + Refresh: '刷新', + RealtimeConnected: '实时已连接', + RealtimeDisconnected: '实时未连接', + LastUpdatedAt: '最后更新', + TypeDistribution: '消息类型分布', + TemplateStatus: '模板状态分布', + NoData: '暂无数据', + HistoricalData: '历史数据', + RealtimeData: '实时数据', + Today: '今日', + TodaySMS: '今日短信', + Dawn: '凌晨', + Morning: '上午', + Afternoon: '下午', + Evening: '晚间', + PeriodCompare: '时段对比', + WaitingForData: '等待数据...' }; export default messages; diff --git a/src/components/MessageManger/utils.js b/src/components/MessageManger/utils.js index c89db67..7e3ea87 100644 --- a/src/components/MessageManger/utils.js +++ b/src/components/MessageManger/utils.js @@ -1,3 +1,12 @@ +/** 浏览器 IANA 时区,传给 statistics 接口与 SSE,与后端「今日」划界一致 */ +export const getClientIanaTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { + return ''; + } +}; + const unwrapValue = value => { if (value && typeof value === 'object' && !Array.isArray(value)) { if (Object.prototype.hasOwnProperty.call(value, 'value')) { diff --git a/src/components/Task/Dashboard/HistorySection.js b/src/components/Task/Dashboard/HistorySection.js new file mode 100644 index 0000000..faf5882 --- /dev/null +++ b/src/components/Task/Dashboard/HistorySection.js @@ -0,0 +1,499 @@ +import { createWithRemoteLoader } from '@kne/remote-loader'; +import Fetch from '@kne/react-fetch'; +import { useState, useRef } from 'react'; +import { Button, Col, Row, Space, Segmented, Tag } from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import { Card as BoxCard } from '@kne/react-box'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import { + PALETTE, RANGE_OPTIONS, TASK_STATUS_LIST, STATUS_COLOR_MAP, TASK_TYPE_COLOR_MAP, + tooltipStyle, legendCenterStyle, + lineChartGrid, lineChartGridWithRotatedLabels, lineSmooth, + axisLineStyle, axisLabelStyle, splitLineStyle, formatDuration +} from './constants'; +import SectionHeader from './SectionHeader'; +import style from './dashboard.module.scss'; + +const HistorySection = createWithRemoteLoader({ + modules: ['components-thirdparty:Echart', 'components-core:Enum'] +})( + withLocale(({ remoteModules, apis }) => { + const [Echart, Enum] = remoteModules; + const { formatMessage } = useIntl(); + const [range, setRange] = useState('7d'); + const reloadRef = useRef(() => {}); + + return ( + <> + + ({ + label: formatMessage({ id: `Range_${r}` }), + value: r + }))} + value={range} + onChange={setRange} + /> + + + } + /> + + { + reloadRef.current = reload; + + const byStatus = data?.byStatus || {}; + const byType = data?.byType || {}; + + // 趋势折线图(按任务类型分线) + const trendOption = (() => { + const recentTrend = data?.recentTrend || []; + const recentTrendByType = data?.recentTrendByType || []; + if (recentTrend.length === 0) return null; + + const dateMap = {}; + recentTrend.forEach(item => { + dateMap[item.date] = { date: item.date, total: item.count }; + }); + recentTrendByType.forEach(item => { + if (!dateMap[item.date]) { + dateMap[item.date] = { date: item.date, total: 0 }; + } + dateMap[item.date][item.type] = item.count; + }); + + const sorted = Object.values(dateMap).sort((a, b) => a.date.localeCompare(b.date)); + const dates = sorted.map(item => item.date); + const totals = sorted.map(item => item.total); + const manyPoints = dates.length > 14; + + // 收集所有出现过的 type + const typeSet = new Set(); + recentTrendByType.forEach(item => typeSet.add(item.type)); + const typeList = Array.from(typeSet); + + const typeLines = typeList.map((type, i) => ({ + name: type, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + data: sorted.map(item => item[type] || 0), + lineStyle: { width: 1.5, color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] }, + itemStyle: { color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length], borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' }, + areaStyle: { color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] + '12' } + })); + + return { + color: [PALETTE.total, ...typeList.map((_, i) => TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length])], + tooltip: tooltipStyle, + legend: { + ...legendCenterStyle, + data: [formatMessage({ id: 'TotalCount' }), ...typeList] + }, + grid: manyPoints ? lineChartGridWithRotatedLabels : lineChartGrid, + xAxis: { + type: 'category', boundaryGap: false, data: dates, + axisLine: axisLineStyle, axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 11, rotate: manyPoints ? 28 : 0, hideOverlap: true } + }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1 }, + series: [ + { + name: formatMessage({ id: 'TotalCount' }), type: 'line', smooth: lineSmooth, + symbol: 'circle', symbolSize: 6, data: totals, + lineStyle: { width: 2.5, color: PALETTE.total }, + itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' }, + areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(59,130,246,0.18)' }, { offset: 1, color: 'rgba(59,130,246,0.02)' }] } } + }, + ...typeLines + ] + }; + })(); + + // 历史区块顶部:状态 / 类型占比(标签 + 横向 bar,与下方饼图数据源一致) + const statusEntries = TASK_STATUS_LIST.map(status => ({ + status, + count: Number(byStatus[status]) || 0 + })).filter(({ count }) => count > 0); + const statusTotal = statusEntries.reduce((sum, { count }) => sum + count, 0); + const statusDistItems = statusEntries + .map(({ status, count }) => ({ + key: status, + label: formatMessage({ id: status.charAt(0).toUpperCase() + status.slice(1) }), + value: count, + color: STATUS_COLOR_MAP[status] + })) + .sort((a, b) => b.value - a.value); + + const statusHBarOption = + statusDistItems.length > 0 + ? { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.12)' } }, + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + formatter: params => { + const p = params[0]; + return `${p.marker} ${p.name}: ${p.value}`; + } + }, + grid: { left: 6, right: 28, top: 6, bottom: 6, containLabel: true }, + xAxis: { + type: 'value', + minInterval: 1, + splitLine: splitLineStyle, + axisLine: { show: false }, + axisLabel: axisLabelStyle, + axisTick: { show: false } + }, + yAxis: { + type: 'category', + data: statusDistItems.map(d => d.label), + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 11 } + }, + series: [ + { + type: 'bar', + data: statusDistItems.map(d => ({ + value: d.value, + itemStyle: { color: d.color, borderRadius: [0, 6, 6, 0] } + })), + barMaxWidth: 26, + label: { show: true, position: 'right', color: '#475569', fontSize: 11 } + } + ] + } + : null; + + const typeEntries = Object.entries(byType) + .map(([type, count]) => ({ type: String(type), count: Number(count) || 0 })) + .filter(({ count }) => count > 0) + .sort((a, b) => b.count - a.count); + const typeTotal = typeEntries.reduce((sum, { count }) => sum + count, 0); + + const durationTrend = data?.durationTrend || []; + + const buildWaitExecDurationOption = rows => { + if (!rows || rows.length === 0) return null; + const dates = rows.map(item => item.date); + const avgWait = rows.map(item => item.avgWaitingTime || 0); + const avgExec = rows.map(item => item.avgExecutionTime || 0); + const manyPoints = dates.length > 14; + return { + color: [PALETTE.waiting, PALETTE.running], + tooltip: { + ...tooltipStyle, + formatter: params => { + const title = params[0]?.axisValue || ''; + const lines = params + .map(p => `${p.marker} ${p.seriesName}: ${formatDuration(p.value)}`) + .join('
'); + return `
${title}
${lines}`; + } + }, + legend: { + ...legendCenterStyle, + data: [formatMessage({ id: 'AvgWaitingTime' }), formatMessage({ id: 'AvgExecutionTime' })] + }, + grid: manyPoints ? lineChartGridWithRotatedLabels : lineChartGrid, + xAxis: { + type: 'category', + boundaryGap: false, + data: dates, + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 11, rotate: manyPoints ? 28 : 0, hideOverlap: true } + }, + yAxis: { + type: 'value', + ...splitLineStyle, + axisLabel: { ...axisLabelStyle, formatter: v => formatDuration(v) } + }, + series: [ + { + name: formatMessage({ id: 'AvgWaitingTime' }), + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + data: avgWait, + lineStyle: { width: 1.5, color: PALETTE.waiting }, + itemStyle: { color: PALETTE.waiting } + }, + { + name: formatMessage({ id: 'AvgExecutionTime' }), + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + data: avgExec, + lineStyle: { width: 1.5, color: PALETTE.running }, + itemStyle: { color: PALETTE.running } + } + ] + }; + }; + + const manualDurationRows = durationTrend.map(day => { + const b = day?.byRunnerType?.manual; + return { + date: day.date, + avgWaitingTime: b?.avgWaitingTime ?? 0, + avgExecutionTime: b?.avgExecutionTime ?? 0 + }; + }); + const systemDurationRows = durationTrend.map(day => { + const b = day?.byRunnerType?.system; + return { + date: day.date, + avgWaitingTime: b?.avgWaitingTime ?? 0, + avgExecutionTime: b?.avgExecutionTime ?? 0 + }; + }); + const manualDurationOption = buildWaitExecDurationOption(manualDurationRows); + const systemDurationOption = buildWaitExecDurationOption(systemDurationRows); + + return ( + <> +
+
+ + {formatMessage({ id: 'TotalTasks' })} + + + {(data?.totalTasks ?? 0).toLocaleString()} + +
+
+ +
+
+
{formatMessage({ id: 'StatusDistribution' })}
+
+ {statusHBarOption ? ( +
+
+ + {statusDistItems.map(d => ( + + {d.label}{' '} + {d.value} + {statusTotal > 0 + ? ` (${Math.round((d.value / statusTotal) * 100)}%)` + : ''} + + ))} + +
+
+ +
+
+ ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+
+
+ + {taskTypeList => { + const typeLabelMap = {}; + (taskTypeList || []).forEach(item => { + const v = item.value; + const label = item.label || item.description || String(v); + typeLabelMap[v] = label; + typeLabelMap[String(v)] = label; + }); + const typeDistItems = typeEntries.map(({ type, count }, i) => ({ + key: type, + label: typeLabelMap[type] ?? type, + value: count, + color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] + })); + const typeHBarOption = + typeDistItems.length > 0 + ? { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.12)' } }, + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + formatter: params => { + const p = params[0]; + return `${p.marker} ${p.name}: ${p.value}`; + } + }, + grid: { left: 6, right: 28, top: 6, bottom: 6, containLabel: true }, + xAxis: { + type: 'value', + minInterval: 1, + splitLine: splitLineStyle, + axisLine: { show: false }, + axisLabel: axisLabelStyle, + axisTick: { show: false } + }, + yAxis: { + type: 'category', + data: typeDistItems.map(d => d.label), + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 11 } + }, + series: [ + { + type: 'bar', + data: typeDistItems.map(d => ({ + value: d.value, + itemStyle: { color: d.color, borderRadius: [0, 6, 6, 0] } + })), + barMaxWidth: 26, + label: { show: true, position: 'right', color: '#475569', fontSize: 11 } + } + ] + } + : null; + return ( +
+
+ {formatMessage({ id: 'TaskTypeDistribution' })} +
+
+ {typeHBarOption ? ( +
+
+ + {typeDistItems.map(d => ( + + {d.label}{' '} + {d.value} + {typeTotal > 0 + ? ` (${Math.round((d.value / typeTotal) * 100)}%)` + : ''} + + ))} + +
+
+ +
+
+ ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+
+ ); + }} +
+
+ + + {trendOption ? ( + + {taskTypeList => { + const typeLabelMap = {}; + (taskTypeList || []).forEach(item => { + typeLabelMap[item.value] = item.label || item.description || item.value; + }); + const resolvedOption = { + ...trendOption, + legend: { + ...trendOption.legend, + data: [ + trendOption.legend.data[0], + ...trendOption.legend.data.slice(1).map(name => typeLabelMap[name] || name) + ] + }, + series: trendOption.series.map((s, i) => { + if (i === 0) return s; + return { ...s, name: typeLabelMap[s.name] || s.name }; + }) + }; + return ; + }} + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ + {durationTrend.length > 0 ? ( + + + + {manualDurationOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ + + + {systemDurationOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ +
+ ) : null} + + ); + }} + /> + + ); + }) +); + +export default HistorySection; diff --git a/src/components/Task/Dashboard/RealtimeSection.js b/src/components/Task/Dashboard/RealtimeSection.js new file mode 100644 index 0000000..363a5c1 --- /dev/null +++ b/src/components/Task/Dashboard/RealtimeSection.js @@ -0,0 +1,600 @@ +import { createWithRemoteLoader } from '@kne/remote-loader'; +import { useMemo } from 'react'; +import { Col, Row, Space, Tag } from 'antd'; +import { BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { Card as BoxCard, ColorfulCard } from '@kne/react-box'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import { + PALETTE, + TIME_PERIODS, + TASK_TYPE_COLOR_MAP, + STATUS_COLOR_MAP, + tooltipStyle, + legendCenterStyle, + lineChartGrid, + lineSmooth, + axisLineStyle, + axisLabelStyle, + splitLineStyle, + formatDuration +} from './constants'; +import useRealtimeStatisticsSSE from './useRealtimeStatisticsSSE'; +import style from './dashboard.module.scss'; + +const HOURS_IN_DAY = 24; + +const buildTodayHourlySlots = raw => { + const slots = Array.from({ length: HOURS_IN_DAY }, (_, h) => ({ + hour: h, total: 0, hasTotalFromApi: false, byType: {} + })); + + (raw?.hourlyTrend || []).forEach(item => { + const h = Number(item?.hour); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + slots[h].total = Number(item?.count) || 0; + slots[h].hasTotalFromApi = true; + }); + + (raw?.hourlyTrendByType || []).forEach(item => { + const h = Number(item?.hour); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + const n = Number(item?.count) || 0; + slots[h].byType[item.type] = (slots[h].byType[item.type] || 0) + n; + }); + + const hasByTypeHourly = Object.values(slots).some(item => Object.keys(item.byType).length > 0); + // 兼容后端只返回 records 的场景:从 records 反推每小时按类型统计 + if (!hasByTypeHourly && Array.isArray(raw?.records) && raw.records.length > 0) { + raw.records.forEach(record => { + const createdAt = record?.createdAt ? new Date(record.createdAt) : null; + if (!createdAt || Number.isNaN(createdAt.getTime())) return; + const h = createdAt.getHours(); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; + const typeKey = String(record?.type); + slots[h].byType[typeKey] = (slots[h].byType[typeKey] || 0) + 1; + if (!slots[h].hasTotalFromApi) { + slots[h].total += 1; + } + }); + } + + return slots; +}; + +const hasHourlyInput = raw => { + const a = raw?.hourlyTrend; + const b = raw?.hourlyTrendByType; + const r = raw?.records; + return ( + (Array.isArray(a) && a.length > 0) || + (Array.isArray(b) && b.length > 0) || + (Array.isArray(r) && r.length > 0) + ); +}; + +const RealtimeSection = createWithRemoteLoader({ + modules: ['components-thirdparty:Echart', 'components-core:Enum'] +})( + withLocale(({ remoteModules, apis }) => { + const [Echart, Enum] = remoteModules; + const { formatMessage } = useIntl(); + const sseUrl = apis?.task?.statistics?.sse?.url; + const { realtimeData, isConnected, lastUpdatedAt } = useRealtimeStatisticsSSE(sseUrl); + + const byStatus = realtimeData?.byStatus || {}; + const byRunnerType = realtimeData?.byRunnerType || {}; + const totalTasks = realtimeData?.totalTasks || 0; + const successCount = Number(byStatus.success) || 0; + const failedCount = Number(byStatus.failed) || 0; + const runningCount = Number(byStatus.running) || 0; + const waitingCount = Number(byStatus.waiting) || 0; + const canceledCount = Number(byStatus.canceled) || 0; + const manualTaskCount = Number(byRunnerType.manual) || 0; + const manualPendingCount = useMemo(() => { + const records = Array.isArray(realtimeData?.records) ? realtimeData.records : []; + return records.reduce((acc, item) => { + const isManual = String(item?.runnerType || '') === 'manual'; + const isPending = String(item?.status || '') === 'pending'; + return isManual && isPending ? acc + 1 : acc; + }, 0); + }, [realtimeData]); + const manualExecutedCount = Math.max(0, manualTaskCount - manualPendingCount); + const manualDurationDisplay = useMemo(() => { + const manualDur = realtimeData?.todayDuration?.byRunnerType?.manual; + if (!manualDur || typeof manualDur !== 'object') return null; + return { + avgWaitingTime: formatDuration(manualDur.avgWaitingTime), + avgExecutionTime: formatDuration(manualDur.avgExecutionTime), + avgTotalTime: formatDuration(manualDur.avgTotalTime) + }; + }, [realtimeData]); + + const lastUpdatedShort = useMemo(() => { + if (!lastUpdatedAt) return ''; + try { + return new Intl.DateTimeFormat(undefined, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }).format(new Date(lastUpdatedAt)); + } catch { + return new Date(lastUpdatedAt).toLocaleTimeString(); + } + }, [lastUpdatedAt]); + + const periodStats = useMemo(() => { + if (!realtimeData || !hasHourlyInput(realtimeData)) return []; + const slots = buildTodayHourlySlots(realtimeData); + return TIME_PERIODS.map(period => { + let total = 0; + const periodByType = {}; + for (let h = period.start; h < period.end; h++) { + const s = slots[h]; + total += s.total; + Object.entries(s.byType).forEach(([type, count]) => { + periodByType[type] = (periodByType[type] || 0) + count; + }); + } + return { ...period, total, byType: periodByType }; + }); + }, [realtimeData]); + + const hourlyOption = useMemo(() => { + if (!realtimeData || !hasHourlyInput(realtimeData)) return null; + + const slots = buildTodayHourlySlots(realtimeData); + const hours = slots.map(s => `${String(s.hour).padStart(2, '0')}:00`); + + // 收集所有出现过的 type + const typeSet = new Set(); + slots.forEach(s => Object.keys(s.byType).forEach(t => typeSet.add(t))); + const typeList = Array.from(typeSet); + if (typeList.length === 0) return null; + + const typeSeries = typeList.map((type, i) => ({ + name: type, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + showSymbol: true, + data: slots.map(s => s.byType[type] || 0), + lineStyle: { width: 1.5, color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] }, + itemStyle: { color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length], borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' }, + areaStyle: { color: TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length] + '18' } + })); + + return { + color: typeList.map((_, i) => TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length]), + tooltip: tooltipStyle, + legend: { + ...legendCenterStyle, + data: typeList + }, + grid: { ...lineChartGrid, bottom: 28 }, + xAxis: { + type: 'category', boundaryGap: false, data: hours, + axisLine: axisLineStyle, axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 10, interval: 1 } + }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 }, + series: typeSeries + }; + }, [realtimeData]); + + const periodCompareOption = useMemo(() => { + const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : []; + if (hourlyTrendByStatus.length === 0) return null; + const labels = TIME_PERIODS.map(p => formatMessage({ id: p.id })); + const statusKeys = ['success', 'running', 'waiting', 'canceled', 'failed']; + const statusLabelMap = { + success: formatMessage({ id: 'Success' }), + running: formatMessage({ id: 'Running' }), + waiting: formatMessage({ id: 'Waiting' }), + canceled: formatMessage({ id: 'Canceled' }), + failed: formatMessage({ id: 'Failed' }) + }; + const statusData = statusKeys.reduce((acc, key) => ({ ...acc, [key]: [0, 0, 0, 0] }), {}); + + hourlyTrendByStatus.forEach(item => { + const h = Number(item?.hour); + const status = String(item?.status || ''); + const count = Number(item?.count) || 0; + if (!Number.isFinite(h) || !statusData[status]) return; + const periodIndex = TIME_PERIODS.findIndex(p => h >= p.start && h < p.end); + if (periodIndex >= 0) statusData[status][periodIndex] += count; + }); + + return { + color: statusKeys.map(key => STATUS_COLOR_MAP[key] || PALETTE.total), + tooltip: { ...tooltipStyle, axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } } }, + legend: { ...legendCenterStyle, data: statusKeys.map(key => statusLabelMap[key]) }, + grid: { ...lineChartGrid, bottom: 20 }, + xAxis: { type: 'category', data: labels, axisLine: axisLineStyle, axisTick: { show: false }, axisLabel: { color: '#64748b', fontSize: 12 } }, + yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 }, + series: statusKeys.map(key => ({ + name: statusLabelMap[key], + type: 'bar', + data: statusData[key], + barMaxWidth: 32, + barGap: '15%', + itemStyle: { color: STATUS_COLOR_MAP[key] || PALETTE.total, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' } + })) + }; + }, [realtimeData, formatMessage]); + + const durationDisplay = useMemo(() => { + const dur = realtimeData?.todayDuration; + if (!dur) return null; + return { + avgWaitingTime: formatDuration(dur.avgWaitingTime), + avgExecutionTime: formatDuration(dur.avgExecutionTime), + avgTotalTime: formatDuration(dur.avgTotalTime) + }; + }, [realtimeData]); + + const durationByTypeOption = useMemo(() => { + const byType = realtimeData?.todayDuration?.byType; + if (!byType || typeof byType !== 'object') return null; + const entries = Object.entries(byType).filter(([, value]) => value && typeof value === 'object'); + if (entries.length === 0) return null; + + return { + color: [PALETTE.running, PALETTE.waiting, PALETTE.total], + tooltip: { + ...tooltipStyle, + axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } }, + formatter: params => { + const title = params?.[0]?.axisValue || ''; + const lines = (params || []).map(p => `${p.marker} ${p.seriesName}: ${formatDuration(p.value)}`).join('
'); + return `
${title}
${lines}`; + } + }, + legend: { + ...legendCenterStyle, + data: [ + formatMessage({ id: 'AvgExecutionTime' }), + formatMessage({ id: 'AvgWaitingTime' }), + formatMessage({ id: 'AvgTotalTime' }) + ] + }, + grid: { ...lineChartGrid, bottom: 20 }, + xAxis: { + type: 'category', + data: entries.map(([type]) => type), + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, interval: 0, rotate: entries.length > 6 ? 20 : 0 } + }, + yAxis: { + type: 'value', + ...splitLineStyle, + axisLabel: { ...axisLabelStyle, formatter: value => formatDuration(value) }, + min: 0 + }, + series: [ + { + name: formatMessage({ id: 'AvgExecutionTime' }), + type: 'bar', + data: entries.map(([, value]) => Number(value.avgExecutionTime) || 0), + barMaxWidth: 28, + itemStyle: { color: PALETTE.running, borderRadius: [4, 4, 0, 0] } + }, + { + name: formatMessage({ id: 'AvgWaitingTime' }), + type: 'bar', + data: entries.map(([, value]) => Number(value.avgWaitingTime) || 0), + barMaxWidth: 28, + itemStyle: { color: PALETTE.waiting, borderRadius: [4, 4, 0, 0] } + }, + { + name: formatMessage({ id: 'AvgTotalTime' }), + type: 'bar', + data: entries.map(([, value]) => Number(value.avgTotalTime) || 0), + barMaxWidth: 28, + itemStyle: { color: PALETTE.total, borderRadius: [4, 4, 0, 0] } + } + ] + }; + }, [realtimeData, formatMessage]); + + return ( + <> +
+
+

{formatMessage({ id: 'RealtimeData' })}

+ {realtimeData ? ( +
+ {formatMessage({ id: 'Today' })} + {' · '} + {formatMessage({ id: 'TotalCount' })} {totalTasks} + {' · '} + {formatMessage({ id: 'Success' })} {successCount} + {' · '} + {formatMessage({ id: 'Failed' })} {failedCount} + {durationDisplay && ( + <> + {' · '} + {formatMessage({ id: 'AvgTotalTime' })} {durationDisplay.avgTotalTime} + + )} +
+ ) : ( +
{formatMessage({ id: isConnected ? 'WaitingForData' : 'RealtimeDisconnected' })}
+ )} +
+
+ + {formatMessage({ id: isConnected ? 'RealtimeConnected' : 'RealtimeDisconnected' })} + + {lastUpdatedAt ? ( + + {formatMessage({ id: 'LastUpdatedAt' })} {lastUpdatedShort} + + ) : null} +
+
+ + {realtimeData ? ( + + {taskTypeList => { + const typeLabelMap = {}; + (taskTypeList || []).forEach(item => { + const label = item.label || item.description || item.value; + typeLabelMap[String(item.value)] = label; + typeLabelMap[item.value] = label; + }); + const getTaskTypeLabel = type => typeLabelMap[String(type)] || typeLabelMap[type] || String(type); + const resolvedHourlyOption = hourlyOption + ? { + ...hourlyOption, + legend: { + ...hourlyOption.legend, + data: hourlyOption.legend.data.map(name => getTaskTypeLabel(name)) + }, + series: hourlyOption.series.map(s => ({ ...s, name: getTaskTypeLabel(s.name) })) + } + : null; + const resolvedDurationByTypeOption = durationByTypeOption + ? { + ...durationByTypeOption, + xAxis: { + ...durationByTypeOption.xAxis, + data: durationByTypeOption.xAxis.data.map(type => getTaskTypeLabel(type)) + } + } + : null; + + return ( + <> +
+
+ {formatMessage({ id: 'ManualExecutionStats' })} + {manualPendingCount} +
+
+ } + title={{manualPendingCount}} + description={{formatMessage({ id: 'ManualPendingTasks' })}} + /> + } + title={{manualExecutedCount}} + description={{formatMessage({ id: 'ManualExecutedTasks' })}} + /> +
+ {manualDurationDisplay && ( +
+ } + title={{manualDurationDisplay.avgExecutionTime}} + description={{formatMessage({ id: 'ManualAvgExecutionTime' })}} + /> + } + title={{manualDurationDisplay.avgWaitingTime}} + description={{formatMessage({ id: 'ManualAvgWaitingTime' })}} + /> + } + title={{manualDurationDisplay.avgTotalTime}} + description={{formatMessage({ id: 'ManualAvgTotalTime' })}} + /> +
+ )} +
+ +
{formatMessage({ id: 'RealtimeTaskOverview' })}
+
+ } + title={{totalTasks}} + description={{formatMessage({ id: 'TodayTasks' })}} + /> + } + title={{successCount}} + description={{formatMessage({ id: 'TodaySuccess' })}} + /> + } + title={{failedCount}} + description={{formatMessage({ id: 'TodayFailed' })}} + /> + } + title={{runningCount}} + description={{formatMessage({ id: 'Running' })}} + /> + } + title={{waitingCount}} + description={{formatMessage({ id: 'Waiting' })}} + /> + } + title={{canceledCount}} + description={{formatMessage({ id: 'Canceled' })}} + /> +
+ +
+
{formatMessage({ id: 'ExecutionTimeStatistics' })}
+
+ {durationDisplay ? ( + <> + } + title={{durationDisplay.avgExecutionTime}} + description={{formatMessage({ id: 'AvgExecutionTime' })}} + /> + } + title={{durationDisplay.avgWaitingTime}} + description={{formatMessage({ id: 'AvgWaitingTime' })}} + /> + } + title={{durationDisplay.avgTotalTime}} + description={{formatMessage({ id: 'AvgTotalTime' })}} + /> + + ) : ( + +
{formatMessage({ id: 'NoData' })}
+
+ )} +
+ + + {resolvedDurationByTypeOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ +
+ {periodStats.map(period => ( +
+
+ + + {formatMessage({ id: period.id })} + + + {String(period.start).padStart(2, '0')}:00–{String(period.end).padStart(2, '0')}:00 + + +
+
+ {period.total} +
+
+ + {Object.entries(period.byType) + .map(([type, count]) => ({ type, count: Number(count) || 0 })) + .filter(({ count }) => count > 0) + .map(({ type, count }, i) => { + const color = TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length]; + return ( + + {getTaskTypeLabel(type)}{' '} + {count} + + ); + })} + +
+
+ ))} +
+ + + + {periodCompareOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ + + + {resolvedHourlyOption ? ( + + ) : ( +
{formatMessage({ id: 'NoData' })}
+ )} +
+ +
+ + ); + }} + + ) : ( + +
+ {isConnected ? formatMessage({ id: 'WaitingForData' }) : formatMessage({ id: 'RealtimeDisconnected' })} +
+
+ )} + + ); + }) +); + +export default RealtimeSection; diff --git a/src/components/Task/Dashboard/SectionHeader.js b/src/components/Task/Dashboard/SectionHeader.js new file mode 100644 index 0000000..71db925 --- /dev/null +++ b/src/components/Task/Dashboard/SectionHeader.js @@ -0,0 +1,10 @@ +import style from './dashboard.module.scss'; + +const SectionHeader = ({ title, extra }) => ( +
+

{title}

+ {extra} +
+); + +export default SectionHeader; diff --git a/src/components/Task/Dashboard/constants.js b/src/components/Task/Dashboard/constants.js new file mode 100644 index 0000000..e829b56 --- /dev/null +++ b/src/components/Task/Dashboard/constants.js @@ -0,0 +1,159 @@ +export const RANGE_OPTIONS = ['7d', '1m', '1y']; + +export const buildUrlWithParams = (url, params = {}) => { + const query = Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join('&'); + if (!query) return url; + return `${url}${url.includes('?') ? '&' : '?'}${query}`; +}; + +export const PALETTE = { + total: '#3b82f6', + success: '#10b981', + failed: '#ef4444', + running: '#f59e0b', + pending: '#6366f1', + canceled: '#94a3b8', + waiting: '#06b6d4', + manual: '#8b5cf6', + system: '#3b82f6', + pie: ['#3b82f6', '#10b981', '#ef4444', '#f59e0b', '#8b5cf6', '#06b6d4', '#94a3b8', '#ec4899'], + period: { dawn: '#818cf8', morning: '#f59e0b', afternoon: '#f97316', evening: '#6366f1' } +}; + +export const TIME_PERIODS = [ + { id: 'Dawn', start: 0, end: 6, color: PALETTE.period.dawn }, + { id: 'Morning', start: 6, end: 12, color: PALETTE.period.morning }, + { id: 'Afternoon', start: 12, end: 18, color: PALETTE.period.afternoon }, + { id: 'Evening', start: 18, end: 24, color: PALETTE.period.evening } +]; + +export const TASK_STATUS_LIST = ['pending', 'running', 'waiting', 'success', 'failed', 'canceled']; + +export const STATUS_COLOR_MAP = { + pending: PALETTE.pending, + running: PALETTE.running, + waiting: PALETTE.waiting, + success: PALETTE.success, + failed: PALETTE.failed, + canceled: PALETTE.canceled +}; + +export const TASK_TYPE_COLOR_MAP = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6']; + +export const tooltipStyle = { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { color: 'rgba(100, 116, 139, 0.45)', width: 1 }, + label: { show: false } + }, + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + confine: true +}; + +export const itemTooltipStyle = { + trigger: 'item', + formatter: '{b}: {c} ({d}%)', + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e2e8f0', + borderWidth: 1, + textStyle: { color: '#334155', fontSize: 12 }, + confine: true, + position(point, params, dom, rect, size) { + const x = point[0]; + const y = point[1]; + const cw = size?.contentSize?.[0] ?? 140; + const ch = size?.contentSize?.[1] ?? 48; + const vw = size?.viewSize?.[0] ?? 400; + const vh = size?.viewSize?.[1] ?? 300; + let left = x + 12; + let top = y - ch - 12; + if (top < 8) top = y + 12; + if (left + cw > vw - 8) left = Math.max(8, vw - cw - 8); + if (top + ch > vh - 8) top = Math.max(8, vh - ch - 8); + return [left, top]; + }, + extraCssText: 'border-radius:8px;box-shadow:0 4px 14px rgba(15,23,42,0.12);' +}; + +export const pieSeries = (center = ['50%', '46%'], radius = ['46%', '64%']) => ({ + type: 'pie', + radius, + center, + clockwise: true, + padAngle: 1.25, + avoidLabelOverlap: true, + itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 }, + label: { show: false }, + labelLine: { show: false }, + emphasis: { + scale: false, + itemStyle: { shadowBlur: 14, shadowColor: 'rgba(15,23,42,0.14)', borderWidth: 3, borderColor: '#fff' }, + label: { show: false } + } +}); + +export const legendPieStyle = { + type: 'scroll', + orient: 'horizontal', + left: 'center', + bottom: 4, + width: '88%', + height: 44, + pageButtonItemGap: 8, + pageIconSize: 11, + pageTextStyle: { color: '#94a3b8' }, + icon: 'roundRect', + itemWidth: 10, + itemHeight: 10, + itemGap: 14, + textStyle: { color: '#64748b', fontSize: 11 } +}; + +export const legendCenterStyle = { + top: 4, + left: 'center', + itemGap: 18, + padding: [0, 0, 6, 0], + textStyle: { color: '#64748b', fontSize: 12 } +}; + +export const lineChartGrid = { left: 12, right: 16, bottom: 12, top: 52, containLabel: true }; +export const lineChartGridWithRotatedLabels = { ...lineChartGrid, bottom: 20, left: 14, right: 14 }; +export const lineSmooth = 0.38; +export const axisLineStyle = { lineStyle: { color: '#e2e8f0' } }; +export const axisLabelStyle = { color: '#94a3b8' }; +export const splitLineStyle = { lineStyle: { color: '#f1f5f9', type: 'dashed', width: 1 } }; + +export const formatDuration = ms => { + if (ms == null || !Number.isFinite(ms) || ms < 0) return '-'; + if (ms === 0) return '0ms'; + + const units = [ + { label: 'ms', value: 1 }, + { label: 's', value: 1000 }, + { label: 'min', value: 60 * 1000 }, + { label: 'h', value: 60 * 60 * 1000 }, + { label: 'd', value: 24 * 60 * 60 * 1000 }, + { label: 'mon', value: 30 * 24 * 60 * 60 * 1000 }, + { label: 'y', value: 365 * 24 * 60 * 60 * 1000 } + ]; + + let unit = units[0]; + for (let i = 0; i < units.length; i++) { + const next = units[i + 1]; + unit = units[i]; + if (!next || ms < next.value) break; + } + + const raw = ms / unit.value; + const rounded = Math.round(raw * 10) / 10; + const text = Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1); + return `${text}${unit.label}`; +}; diff --git a/src/components/Task/Dashboard/dashboard.module.scss b/src/components/Task/Dashboard/dashboard.module.scss new file mode 100644 index 0000000..30972bf --- /dev/null +++ b/src/components/Task/Dashboard/dashboard.module.scss @@ -0,0 +1,465 @@ +.dashboardRoot { + width: 100%; + max-width: none; + margin: 0; + padding: 8px 16px 32px; + box-sizing: border-box; +} + +.sectionPanel { + background: transparent; + border: none; + box-shadow: none; + padding: 0; + border-radius: 0; +} + +.realtimeSectionWrap { + background: linear-gradient(165deg, #f1f5f9 0%, #f8fafc 42%, #eef2ff 100%); + border-radius: 12px; + padding: 16px 18px 22px; + border: 1px solid rgba(226, 232, 240, 0.85); + box-sizing: border-box; +} + +.sectionDivider { + margin: 32px 0 !important; + border-color: rgba(226, 232, 240, 0.9) !important; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.realtimeHeader { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 14px; + margin-bottom: 12px; +} + +.realtimeHeaderMain { + flex: 1 1 200px; + min-width: 0; +} + +.realtimeTitle { + margin: 0 0 4px; + font-size: 16px; + font-weight: 650; + color: #0f172a; + letter-spacing: -0.02em; + line-height: 1.25; +} + +.realtimeSubline { + font-size: 12px; + color: #64748b; + line-height: 1.45; + word-break: break-word; +} + +.realtimeHeaderMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + flex-shrink: 0; +} + +.realtimeTime { + font-size: 11px; + color: #94a3b8; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.sectionTitle { + margin: 0; + font-size: 18px; + font-weight: 650; + color: #0f172a; + letter-spacing: -0.02em; +} + +.kpiRow { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: stretch; +} + +.kpiRowDense { + gap: 10px; +} + +.kpiCard { + flex: 1 1 160px; + min-width: min(100%, 160px); + max-width: 100%; +} + +.kpiCardDense { + flex: 1 1 120px; + min-width: min(100%, 120px); +} + +.kpiValueDense { + font-size: clamp(1.35rem, 2.8vw, 1.75rem); + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.12; + display: block; +} + +.kpiDescDense { + font-size: 11px; + line-height: 1.3; + margin-top: 2px; + opacity: 0.92; +} + +.realtimeGroupTitle { + margin-top: 14px; + margin-bottom: 10px; + font-size: 13px; + font-weight: 700; + color: #334155; + letter-spacing: 0.02em; +} + +.realtimeSectionDivider { + height: 1px; + margin: 14px 0 6px; + background: rgba(226, 232, 240, 0.95); +} + +.durationByTypeCard { + margin-top: 12px; +} + +.manualExecutionPanel { + margin-top: 2px; + margin-bottom: 12px; + padding: 10px 10px 2px; + border: 1px solid rgba(239, 68, 68, 0.24); + background: linear-gradient(180deg, rgba(254, 242, 242, 0.72) 0%, rgba(255, 255, 255, 0.78) 100%); + border-radius: 10px; +} + +.manualExecutionHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 8px; +} + +.manualExecutionTitle { + font-size: 12px; + color: #b91c1c; + font-weight: 700; + letter-spacing: 0.02em; +} + +.manualExecutionValue { + font-size: 1.2rem; + color: #dc2626; + font-weight: 750; + font-variant-numeric: tabular-nums; +} + +.manualPendingCard { + border: 1px solid rgba(239, 68, 68, 0.28); + box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1); +} + +.manualPendingValue { + font-size: clamp(1.8rem, 3.8vw, 2.3rem); + font-weight: 800; +} + +.manualDurationRow { + margin-top: 8px; +} + +.periodStrip { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + margin-top: 16px; + border-radius: 14px; + background: #FFF; + overflow: hidden; +} + +.periodStripCell { + flex: 1; + min-width: 0; + padding: 14px 12px; + text-align: center; + + &:not(:last-child) { + box-shadow: 1px 0 0 rgba(226, 232, 240, 0.95); + } +} + +@media (max-width: 640px) { + .periodStrip { + flex-direction: column; + flex-wrap: wrap; + } + + .periodStripCell:not(:last-child) { + box-shadow: 0 1px 0 rgba(226, 232, 240, 0.95); + } +} + +.periodStripLabel { + font-size: 11px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; + display: flex; + justify-content: center; +} + +.periodStripLabelInner { + justify-content: center !important; +} + +.periodStripLabel :global(.ant-tag), +.periodStripMeta :global(.ant-tag) { + text-transform: none; + letter-spacing: 0.01em; +} + +.periodStripValue { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.periodStripMeta { + margin-top: 8px; + width: 100%; + display: flex; + justify-content: center; +} + +.periodStripMetaInner { + justify-content: center !important; +} + +.chartsRow { + margin-top: 24px; +} + +.chartCol { + min-width: 0; +} + +.chartCardSurface { + min-width: 0; + box-sizing: border-box; +} + +.historyTrendCard { + margin-top: 20px; +} + +.historyDurationRunnerRow { + margin-top: 0; +} + +/* 紧跟占比条后的第一张趋势图:纵向更紧凑 */ +.historyTrendTightTop { + margin-top: 10px; +} + +/* 总任务数:在占比区域外,与下方图表卡片区分 */ +.historyOverviewMeta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 14px; + margin: 0 0 8px; +} + +.historyTotalHighlight { + display: inline-flex; + align-items: baseline; + gap: 8px; +} + +.historyTotalHighlightLabel { + font-size: 12px; + font-weight: 500; + color: #64748b; + letter-spacing: 0.02em; +} + +.historyTotalHighlightValue { + font-size: clamp(1.2rem, 2.8vw, 1.45rem); + font-weight: 750; + letter-spacing: -0.04em; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +/* 状态 + 类型两条占比:同一外框,无 BoxCard */ +.historyBarsCombined { + border-radius: 10px; + border: 1px solid rgba(226, 232, 240, 0.9); + overflow: hidden; + background: #fff; + box-sizing: border-box; +} + +.historyBarRow { + display: flex; + align-items: stretch; + min-height: 0; +} + +.historyBarRowLabel { + flex: 0 0 84px; + display: flex; + align-items: center; + justify-content: center; + padding: 6px 6px; + font-size: 11px; + font-weight: 600; + color: #475569; + line-height: 1.3; + text-align: center; + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); + border-right: 1px solid rgba(226, 232, 240, 0.95); + box-sizing: border-box; +} + +.historyBarRowSplit { + flex: 1 1 0; + min-width: 0; + min-height: 0; + padding: 6px 8px 8px; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-start; + background: #fff; +} + +.historySplitLegendBar { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; +} + +.historySplitTagRow { + display: flex; + justify-content: center; + width: 100%; +} + +.historySplitTagInner { + justify-content: center !important; +} + +.historySplitTagRow :global(.ant-tag) { + text-transform: none; +} + +.historySplitBar { + width: 100%; + min-width: 0; +} + +.historyBarRowDivider { + height: 1px; + background: rgba(226, 232, 240, 0.95); + flex-shrink: 0; +} + +.historyBarSplitEmpty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 72px; + color: #94a3b8; + font-size: 13px; +} + +@media (max-width: 576px) { + .historyBarRow { + flex-direction: column; + } + + .historyBarRowLabel { + flex: 0 0 auto; + width: 100%; + justify-content: flex-start; + padding: 6px 10px; + border-right: none; + border-bottom: 1px solid rgba(226, 232, 240, 0.95); + text-align: left; + } + + .historyBarRowSplit { + width: 100%; + } +} + +.historyStatsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.historyStatCell { + padding: 14px 14px 14px 16px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid rgba(226, 232, 240, 0.55); + border-left-width: 3px; + border-left-style: solid; + min-width: 0; +} + +.historyStatLabel { + font-size: 12px; + color: #64748b; + line-height: 1.35; + margin-bottom: 6px; +} + +.historyStatValue { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.03em; + color: #0f172a; + line-height: 1.2; +} + +.emptyState { + text-align: center; + padding: 36px 16px; + color: #94a3b8; + font-size: 14px; +} + +.emptyStateLarge { + text-align: center; + padding: 44px 16px; + color: #94a3b8; + font-size: 14px; +} diff --git a/src/components/Task/Dashboard/index.js b/src/components/Task/Dashboard/index.js new file mode 100644 index 0000000..a43356d --- /dev/null +++ b/src/components/Task/Dashboard/index.js @@ -0,0 +1,40 @@ +import '@kne/react-box/dist/index.css'; +import { createWithRemoteLoader } from '@kne/remote-loader'; +import { Divider } from 'antd'; +import withLocale from '../withLocale'; +import { useIntl } from '@kne/react-intl'; +import TaskMenu from '../Menu'; +import RealtimeSection from './RealtimeSection'; +import HistorySection from './HistorySection'; +import style from './dashboard.module.scss'; + +const Dashboard = createWithRemoteLoader({ + modules: ['components-core:Global@usePreset', 'components-core:Layout@Page'] +})( + withLocale(({ remoteModules, baseUrl, pageProps = {} }) => { + const [usePreset, Page] = remoteModules; + const { apis } = usePreset(); + const { formatMessage } = useIntl(); + + return ( + } + children={ +
+
+ +
+ +
+ +
+
+ } + /> + ); + }) +); + +export default Dashboard; diff --git a/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js new file mode 100644 index 0000000..3fd4d50 --- /dev/null +++ b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { getToken } from '@kne/token-storage'; +import { buildUrlWithParams } from './constants'; + +const getClientIanaTimezone = () => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { + return ''; + } +}; + +const isLikelyTaskStatisticsPayload = obj => + obj && + typeof obj === 'object' && + !Array.isArray(obj) && + ('totalTasks' in obj || + 'byStatus' in obj || + 'hourlyTrend' in obj || + 'hourlyTrendByStatus' in obj || + 'byType' in obj || + 'todayDuration' in obj); + +const unwrapStatisticsPayload = parsed => { + if (parsed == null) return null; + let cur = parsed; + if (typeof cur === 'string') { + try { + cur = JSON.parse(cur); + } catch { + return null; + } + } + if (!cur || typeof cur !== 'object') return null; + if (isLikelyTaskStatisticsPayload(cur)) return cur; + if (cur.data != null && typeof cur.data === 'object' && !Array.isArray(cur.data) && isLikelyTaskStatisticsPayload(cur.data)) { + return cur.data; + } + if (typeof cur.data === 'string') { + try { + return unwrapStatisticsPayload(JSON.parse(cur.data)); + } catch { + return null; + } + } + return null; +}; + +const useRealtimeStatisticsSSE = sseUrl => { + const [realtimeData, setRealtimeData] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + useEffect(() => { + if (!sseUrl || typeof window === 'undefined' || typeof window.EventSource !== 'function') { + return undefined; + } + + const source = new EventSource( + buildUrlWithParams(sseUrl, { + interval: 5, + token: getToken('X-User-Token'), + timezone: getClientIanaTimezone() + }) + ); + + source.onopen = () => setIsConnected(true); + source.onmessage = event => { + try { + const parsed = JSON.parse(event.data); + const nextData = unwrapStatisticsPayload(parsed); + if (!nextData) return; + setRealtimeData(prev => (prev && typeof prev === 'object' ? { ...prev, ...nextData } : nextData)); + setLastUpdatedAt(Date.now()); + setIsConnected(true); + } catch { + // ignore parse errors + } + }; + source.onerror = () => setIsConnected(false); + + return () => { + source.close(); + }; + }, [sseUrl]); + + return { realtimeData, isConnected, lastUpdatedAt }; +}; + +export default useRealtimeStatisticsSSE; diff --git a/src/components/Task/Menu.js b/src/components/Task/Menu.js index de422fd..7d2b630 100644 --- a/src/components/Task/Menu.js +++ b/src/components/Task/Menu.js @@ -11,7 +11,8 @@ const Menu = createWithRemoteLoader({ return ( { const location = useLocation(); @@ -10,6 +11,10 @@ const Task = ({ baseUrl, getManualTaskAction, children, ...props }) => { list={[ { index: true, + element: + }, + { + path: 'my', element: }, { @@ -27,4 +32,4 @@ export default Task; export { default as enums } from './enums'; export { default as Actions } from './Actions'; export { default as getColumns, ColumnsLoader } from './getColumns'; -export { MyTask, AllTask }; +export { MyTask, AllTask, Dashboard }; diff --git a/src/components/Task/locale/en-US.js b/src/components/Task/locale/en-US.js index 0b5227c..13ffbb7 100644 --- a/src/components/Task/locale/en-US.js +++ b/src/components/Task/locale/en-US.js @@ -1,5 +1,6 @@ const locale = { // Menu + Dashboard: 'Dashboard', MyTask: 'My Task', AllTask: 'All Task', // Columns @@ -53,7 +54,47 @@ const locale = { LogItem: 'Log', RequestTime: 'Request Time', Message: 'Message', - RequestData: 'Request Data' + RequestData: 'Request Data', + // Dashboard + RealtimeData: 'Real-time Data', + HistoricalData: 'Historical Data', + RealtimeConnected: 'Realtime Connected', + RealtimeDisconnected: 'Realtime Disconnected', + LastUpdatedAt: 'Last Updated', + Today: 'Today', + TotalCount: 'Total Count', + NoData: 'No Data', + WaitingForData: 'Waiting for data...', + TodayTasks: 'Today Tasks', + TodaySuccess: 'Today Success', + TodayFailed: 'Today Failed', + TodayPending: 'Today Pending', + AvgExecutionTime: 'Avg Execution Time', + AvgWaitingTime: 'Avg Waiting Time', + AvgTotalTime: 'Avg Total Time', + RealtimeTaskOverview: 'Task Execution Overview', + ManualExecutionStats: 'Manual Execution Tasks', + ManualExecutionTasks: 'Manual Task Count', + ManualPendingTasks: 'Manual Pending Count', + ManualExecutedTasks: 'Manual Executed Count', + ManualAvgExecutionTime: 'Manual Avg Execution Time', + ManualAvgWaitingTime: 'Manual Avg Waiting Time', + ManualAvgTotalTime: 'Manual Avg Total Time', + ExecutionTimeStatistics: 'Execution Time Statistics', + ExecutionTimeByTaskType: 'Execution Time by Task Type', + TotalTasks: 'Total Tasks', + Range_7d: 'Last 7 Days', + Range_1m: 'Last Month', + Range_1y: 'Last Year', + Refresh: 'Refresh', + Dawn: 'Dawn', + Morning: 'Morning', + Afternoon: 'Afternoon', + Evening: 'Evening', + PeriodCompare: 'Period Compare', + TodayHourlyTrend: 'Today Hourly Trend', + StatusDistribution: 'Status Distribution', + TaskTypeDistribution: 'Task Type Distribution' }; export default locale; diff --git a/src/components/Task/locale/zh-CN.js b/src/components/Task/locale/zh-CN.js index ea339a4..8ed2aac 100644 --- a/src/components/Task/locale/zh-CN.js +++ b/src/components/Task/locale/zh-CN.js @@ -1,5 +1,6 @@ const locale = { // Menu + Dashboard: '数据面板', MyTask: '我的任务', AllTask: '全部任务', // Columns @@ -32,7 +33,7 @@ const locale = { Waiting: '等待操作', Success: '成功', Failed: '失败', - Canceled: '取消', + Canceled: '已取消', // Messages ConfirmCancelTask: '确定要取消任务吗?', CancelTaskSuccess: '取消任务成功', @@ -53,7 +54,47 @@ const locale = { LogItem: '日志', RequestTime: '请求时间', Message: '消息', - RequestData: '请求数据' + RequestData: '请求数据', + // Dashboard + RealtimeData: '实时数据', + HistoricalData: '历史数据', + RealtimeConnected: '实时已连接', + RealtimeDisconnected: '实时未连接', + LastUpdatedAt: '最后更新', + Today: '今日', + TotalCount: '总数', + NoData: '暂无数据', + WaitingForData: '等待数据...', + TodayTasks: '今日任务', + TodaySuccess: '今日成功', + TodayFailed: '今日失败', + TodayPending: '今日待执行', + AvgExecutionTime: '平均执行时间', + AvgWaitingTime: '平均等待时间', + AvgTotalTime: '平均总耗时', + RealtimeTaskOverview: '任务执行概览', + ManualExecutionStats: '手动执行任务', + ManualExecutionTasks: '手动执行数量', + ManualPendingTasks: '手动待执行数', + ManualExecutedTasks: '手动已执行数', + ManualAvgExecutionTime: '手动平均执行时间', + ManualAvgWaitingTime: '手动平均等待时间', + ManualAvgTotalTime: '手动平均总耗时', + ExecutionTimeStatistics: '执行时间统计', + ExecutionTimeByTaskType: '按任务类型执行时间对比', + TotalTasks: '任务总数', + Range_7d: '近7天', + Range_1m: '近1个月', + Range_1y: '近1年', + Refresh: '刷新', + Dawn: '凌晨', + Morning: '上午', + Afternoon: '下午', + Evening: '晚间', + PeriodCompare: '时段对比', + TodayHourlyTrend: '今日每小时趋势', + StatusDistribution: '任务状态分布', + TaskTypeDistribution: '任务类型分布' }; export default locale; diff --git a/src/mockPreset/index.js b/src/mockPreset/index.js index aa5dea8..846cfbe 100644 --- a/src/mockPreset/index.js +++ b/src/mockPreset/index.js @@ -38,6 +38,127 @@ const apis = merge({}, getApis(), { loader: () => { return { code: 0, data: { success: true } }; } + }, + statistics: { + getOverview: { + loader: ({ params }) => { + const range = params?.range || '7d'; + const now = new Date(); + const days = range === '1y' ? 365 : range === '1m' ? 30 : 7; + const recentTrend = []; + const recentTrendByStatus = []; + const recentTrendByType = []; + const durationTrend = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + const success = Math.floor(Math.random() * 30) + 5; + const failed = Math.floor(Math.random() * 8) + 1; + const running = Math.floor(Math.random() * 5); + const pending = Math.floor(Math.random() * 6) + 1; + const canceled = Math.floor(Math.random() * 3); + const total = success + failed + running + pending + canceled; + recentTrend.push({ date: dateStr, count: total }); + recentTrendByStatus.push({ date: dateStr, status: 'success', count: success }); + recentTrendByStatus.push({ date: dateStr, status: 'failed', count: failed }); + recentTrendByStatus.push({ date: dateStr, status: 'running', count: running }); + recentTrendByStatus.push({ date: dateStr, status: 'pending', count: pending }); + recentTrendByStatus.push({ date: dateStr, status: 'canceled', count: canceled }); + recentTrendByType.push({ date: dateStr, type: 'export', count: Math.floor(total * 0.6) }); + recentTrendByType.push({ date: dateStr, type: 'import', count: Math.ceil(total * 0.4) }); + durationTrend.push({ + date: dateStr, + completedCount: success + failed + canceled, + successCount: success, + failedCount: failed, + canceledCount: canceled, + avgWaitingTime: Math.floor(Math.random() * 3000) + 500, + avgExecutionTime: Math.floor(Math.random() * 8000) + 2000, + avgTotalTime: Math.floor(Math.random() * 10000) + 3000, + byType: { + export: { count: Math.floor(total * 0.6), avgWaitingTime: Math.floor(Math.random() * 2000) + 500, avgExecutionTime: Math.floor(Math.random() * 6000) + 2000, avgTotalTime: Math.floor(Math.random() * 8000) + 3000 }, + import: { count: Math.ceil(total * 0.4), avgWaitingTime: Math.floor(Math.random() * 2000) + 500, avgExecutionTime: Math.floor(Math.random() * 6000) + 2000, avgTotalTime: Math.floor(Math.random() * 8000) + 3000 } + }, + byRunnerType: { + manual: { count: Math.floor(total * 0.4), avgWaitingTime: Math.floor(Math.random() * 2000) + 500, avgExecutionTime: Math.floor(Math.random() * 6000) + 2000, avgTotalTime: Math.floor(Math.random() * 8000) + 3000 }, + system: { count: Math.ceil(total * 0.6), avgWaitingTime: Math.floor(Math.random() * 2000) + 500, avgExecutionTime: Math.floor(Math.random() * 6000) + 2000, avgTotalTime: Math.floor(Math.random() * 8000) + 3000 } + } + }); + } + const totalSuccess = recentTrendByStatus.filter(item => item.status === 'success').reduce((sum, item) => sum + item.count, 0); + const totalFailed = recentTrendByStatus.filter(item => item.status === 'failed').reduce((sum, item) => sum + item.count, 0); + const totalRunning = recentTrendByStatus.filter(item => item.status === 'running').reduce((sum, item) => sum + item.count, 0); + const totalPending = recentTrendByStatus.filter(item => item.status === 'pending').reduce((sum, item) => sum + item.count, 0); + const totalCanceled = recentTrendByStatus.filter(item => item.status === 'canceled').reduce((sum, item) => sum + item.count, 0); + const totalWaiting = recentTrendByStatus.filter(item => item.status === 'waiting').reduce((sum, item) => sum + item.count, 0); + return { + range, + rangeLabel: range === '7d' ? '近7天' : range === '1m' ? '近1个月' : '近1年', + totalTasks: totalSuccess + totalFailed + totalRunning + totalPending + totalCanceled + totalWaiting, + byStatus: { success: totalSuccess, failed: totalFailed, running: totalRunning, pending: totalPending, canceled: totalCanceled, waiting: totalWaiting }, + byType: { export: Math.floor((totalSuccess + totalFailed) * 0.6), import: Math.ceil((totalSuccess + totalFailed) * 0.4) }, + byRunnerType: { manual: Math.floor((totalSuccess + totalFailed) * 0.4), system: Math.ceil((totalSuccess + totalFailed) * 0.6) }, + byTargetType: { project: Math.floor((totalSuccess + totalFailed) * 0.5), document: Math.ceil((totalSuccess + totalFailed) * 0.5) }, + recentTrend, + recentTrendByStatus, + recentTrendByType, + durationTrend + }; + } + }, + sse: { + loader: () => { + const now = new Date(); + const currentHour = now.getHours(); + const hourlyTrend = []; + const hourlyTrendByStatus = []; + const hourlyTrendByType = []; + for (let h = 0; h <= currentHour; h++) { + const success = Math.floor(Math.random() * 6) + 1; + const failed = Math.floor(Math.random() * 3); + const running = Math.floor(Math.random() * 2); + const pending = Math.floor(Math.random() * 3); + const canceled = Math.floor(Math.random() * 2); + const total = success + failed + running + pending + canceled; + const exportCount = Math.floor(total * 0.6); + const importCount = total - exportCount; + hourlyTrend.push({ hour: h, count: total }); + if (success > 0) hourlyTrendByStatus.push({ hour: h, status: 'success', count: success }); + if (failed > 0) hourlyTrendByStatus.push({ hour: h, status: 'failed', count: failed }); + if (running > 0) hourlyTrendByStatus.push({ hour: h, status: 'running', count: running }); + if (pending > 0) hourlyTrendByStatus.push({ hour: h, status: 'pending', count: pending }); + if (canceled > 0) hourlyTrendByStatus.push({ hour: h, status: 'canceled', count: canceled }); + if (exportCount > 0) hourlyTrendByType.push({ hour: h, type: 'export', count: exportCount }); + if (importCount > 0) hourlyTrendByType.push({ hour: h, type: 'import', count: importCount }); + } + const totalTasks = hourlyTrend.reduce((sum, item) => sum + item.count, 0); + const totalSuccess = hourlyTrendByStatus.filter(item => item.status === 'success').reduce((sum, item) => sum + item.count, 0); + const totalFailed = hourlyTrendByStatus.filter(item => item.status === 'failed').reduce((sum, item) => sum + item.count, 0); + const totalRunning = hourlyTrendByStatus.filter(item => item.status === 'running').reduce((sum, item) => sum + item.count, 0); + const totalPending = hourlyTrendByStatus.filter(item => item.status === 'pending').reduce((sum, item) => sum + item.count, 0); + const totalCanceled = hourlyTrendByStatus.filter(item => item.status === 'canceled').reduce((sum, item) => sum + item.count, 0); + return { + date: now.toISOString().split('T')[0], + totalTasks, + byStatus: { success: totalSuccess, failed: totalFailed, running: totalRunning, pending: totalPending, canceled: totalCanceled }, + byType: { export: Math.floor(totalTasks * 0.6), import: Math.ceil(totalTasks * 0.4) }, + byRunnerType: { manual: Math.floor(totalTasks * 0.4), system: Math.ceil(totalTasks * 0.6) }, + hourlyTrend, + hourlyTrendByStatus, + hourlyTrendByType, + todayDuration: { + completedCount: totalSuccess + totalFailed + totalCanceled, + successCount: totalSuccess, + failedCount: totalFailed, + canceledCount: totalCanceled, + avgWaitingTime: Math.floor(Math.random() * 3000) + 500, + avgExecutionTime: Math.floor(Math.random() * 8000) + 2000, + avgTotalTime: Math.floor(Math.random() * 10000) + 3000 + } + }; + } + } } }, signature: { @@ -484,6 +605,80 @@ const apis = merge({}, getApis(), { send: { loader: () => ({ code: 0, data: { id: `record_${Date.now()}` } }) } + }, + statistics: { + getOverview: { + loader: ({ params }) => { + const range = params?.range || '7d'; + const now = new Date(); + const days = range === '1y' ? 365 : range === '1m' ? 30 : 7; + const recentTrend = []; + const recentTrendByType = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split('T')[0]; + const emailCount = Math.floor(Math.random() * 30) + 5; + const smsCount = Math.floor(Math.random() * 15) + 1; + recentTrend.push({ date: dateStr, count: emailCount + smsCount }); + recentTrendByType.push({ date: dateStr, type: 0, count: emailCount }); + recentTrendByType.push({ date: dateStr, type: 1, count: smsCount }); + } + const totalRecords = recentTrend.reduce((sum, item) => sum + item.count, 0); + const emailTotal = recentTrendByType.filter(item => item.type === 0).reduce((sum, item) => sum + item.count, 0); + const smsTotal = recentTrendByType.filter(item => item.type === 1).reduce((sum, item) => sum + item.count, 0); + return { + range, + rangeLabel: range === '7d' ? '近7天' : range === '1m' ? '近1个月' : '近1年', + totalRecords, + byType: { '0': emailTotal, '1': smsTotal }, + byCode: { welcome: Math.floor(totalRecords * 0.4), verify: Math.floor(totalRecords * 0.3), notification: Math.floor(totalRecords * 0.2), alert: totalRecords - Math.floor(totalRecords * 0.4) - Math.floor(totalRecords * 0.3) - Math.floor(totalRecords * 0.2) }, + templateStats: { + total: messageMangerData.templates.pageData.length, + byStatus: { '0': messageMangerData.templates.pageData.filter(t => Number(t.status) === 0).length, '1': messageMangerData.templates.pageData.filter(t => Number(t.status) === 1).length }, + byType: { '0': messageMangerData.templates.pageData.filter(t => Number(t.type) === 0).length, '1': messageMangerData.templates.pageData.filter(t => Number(t.type) === 1).length } + }, + recentTrend, + recentTrendByType + }; + } + }, + sse: { + loader: () => { + const now = new Date(); + const currentHour = now.getHours(); + const hourlyTrend = []; + const hourlyTrendByType = []; + for (let h = 0; h <= currentHour; h++) { + const email = Math.floor(Math.random() * 8) + 2; + const sms = Math.floor(Math.random() * 4); + hourlyTrend.push({ hour: h, count: email + sms }); + hourlyTrendByType.push({ hour: h, type: 0, count: email }); + if (sms > 0) hourlyTrendByType.push({ hour: h, type: 1, count: sms }); + } + const totalRecords = hourlyTrend.reduce((sum, item) => sum + item.count, 0); + const emailTotal = hourlyTrendByType.filter(item => item.type === 0).reduce((sum, item) => sum + item.count, 0); + const smsTotal = hourlyTrendByType.filter(item => item.type === 1).reduce((sum, item) => sum + item.count, 0); + return { + date: now.toISOString().split('T')[0], + totalRecords, + byType: { '0': emailTotal, '1': smsTotal }, + byCode: { welcome: Math.floor(totalRecords * 0.4), verify: Math.floor(totalRecords * 0.3), notification: totalRecords - Math.floor(totalRecords * 0.4) - Math.floor(totalRecords * 0.3) }, + hourlyTrend, + hourlyTrendByType, + intervalTrend: [], + records: [ + { + id: 'mock_record_1', + name: 'demo@example.com', + type: 0, + code: 'welcome', + createdAt: now.toISOString() + } + ] + }; + } + } } }, mq: {