From 53818dc7a20d2c6f8e374d051093f7bca719d5d1 Mon Sep 17 00:00:00 2001 From: Linzp Date: Thu, 14 May 2026 13:26:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=80=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/components/Apis/getApis.js | 12 + .../MessageManger/Dashboard/HistorySection.js | 71 ++- .../Dashboard/RealtimeSection.js | 306 ++++++++---- .../Task/Dashboard/HistorySection.js | 456 ++++++++++++++---- .../Task/Dashboard/RealtimeSection.js | 371 ++++++++++---- src/components/Task/Dashboard/constants.js | 30 +- .../Task/Dashboard/dashboard.module.scss | 102 +++- .../Dashboard/useRealtimeStatisticsSSE.js | 6 +- src/components/Task/README.md | 67 +++ src/components/Task/doc/api.md | 67 +++ src/components/Task/locale/en-US.js | 12 +- src/components/Task/locale/zh-CN.js | 12 +- src/mockPreset/index.js | 87 +++- 14 files changed, 1289 insertions(+), 312 deletions(-) diff --git a/package.json b/package.json index d37c9a6..4f88228 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kne-components/components-admin", - "version": "1.1.39", + "version": "1.1.40", "description": "用于实现一个后台管理系统的必要组件", "scripts": { "init": "husky", diff --git a/src/components/Apis/getApis.js b/src/components/Apis/getApis.js index 572287f..652e811 100644 --- a/src/components/Apis/getApis.js +++ b/src/components/Apis/getApis.js @@ -164,10 +164,22 @@ const getApis = options => { method: 'POST' }, statistics: { + /** + * 历史统计看板(GET)。 + * Query:`range`(7d|1m|1y)、`timezone`(IANA,与「今日」划界一致)。 + * 响应字段约定见 `src/components/Task/doc/api.md`「任务统计 HTTP」。 + */ getOverview: { url: `${prefix}/task/statistics`, method: 'GET' }, + /** + * 实时统计(Server-Sent Events,GET)。 + * Query:`interval`、`token`、`timezone`(见 `useRealtimeStatisticsSSE` 与 Task `getClientIanaTimezone`)。 + * 每条 `message` 的 `data` 为 JSON 对象(或 `{ data: {...} }` 包裹),字段约定见 `src/components/Task/doc/api.md`「任务实时统计 SSE」。 + * 看板手动区:条数用 `waitingByRunnerType.manual` / `completedToday.manual`;主时长用 + * `waitingQueueMaxWaitMsByRunnerType.manual`(队列最长等待)、`completedTodayTotalDurationMsByRunnerType.manual`(当日完成创建→完成之和)。 + */ sse: { url: `${prefix}/task/statistics/sse`, method: 'GET' diff --git a/src/components/MessageManger/Dashboard/HistorySection.js b/src/components/MessageManger/Dashboard/HistorySection.js index 1ea0461..3698f57 100644 --- a/src/components/MessageManger/Dashboard/HistorySection.js +++ b/src/components/MessageManger/Dashboard/HistorySection.js @@ -26,6 +26,24 @@ import SectionHeader from './SectionHeader'; import { getClientIanaTimezone } from '../utils'; import style from './dashboard.module.scss'; +const RANGE_DAY_COUNT = { '7d': 7, '1m': 30, '1y': 365 }; + +/** 本地日历上从 range 起点到今天的日期轴(YYYY-MM-DD) */ +const buildLocalDateAxisForRange = rangeKey => { + const days = RANGE_DAY_COUNT[rangeKey] || 7; + const dates = []; + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - i); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + dates.push(`${y}-${m}-${day}`); + } + return dates; +}; + const HistorySection = createWithRemoteLoader({ modules: ['components-thirdparty:Echart'] })( @@ -70,12 +88,18 @@ const HistorySection = createWithRemoteLoader({ const trendOption = (() => { const recentTrend = data?.recentTrend || []; const recentTrendByType = data?.recentTrendByType || []; - if (recentTrend.length === 0) return null; + const axisDates = recentTrend.length > 0 ? null : buildLocalDateAxisForRange(range); const dateMap = {}; - recentTrend.forEach(item => { - dateMap[item.date] = { date: item.date, total: item.count }; - }); + if (recentTrend.length > 0) { + recentTrend.forEach(item => { + dateMap[item.date] = { date: item.date, total: item.count }; + }); + } else { + axisDates.forEach(d => { + dateMap[d] = { date: d, total: 0 }; + }); + } recentTrendByType.forEach(item => { if (!dateMap[item.date]) { dateMap[item.date] = { date: item.date, total: 0 }; @@ -155,7 +179,32 @@ const HistorySection = createWithRemoteLoader({ const codePieOption = (() => { const byCode = data?.byCode || {}; const entries = Object.entries(byCode); - if (entries.length === 0) return null; + if (entries.length === 0) { + return { + tooltip: { show: false }, + legend: { show: false }, + graphic: [ + { + type: 'text', + left: 'center', + top: 'center', + style: { + text: formatMessage({ id: 'NoData' }), + fill: '#94a3b8', + fontSize: 14, + fontWeight: 500 + } + } + ], + series: [ + { + ...pieSeries(['50%', '42%'], ['44%', '62%']), + silent: true, + data: [{ value: 1, name: '', itemStyle: { color: '#f1f5f9' }, label: { show: false } }] + } + ] + }; + } return { color: PALETTE.pie, tooltip: itemTooltipStyle, @@ -232,11 +281,7 @@ const HistorySection = createWithRemoteLoader({ - {trendOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
@@ -247,11 +292,7 @@ const HistorySection = createWithRemoteLoader({ - {codePieOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
diff --git a/src/components/MessageManger/Dashboard/RealtimeSection.js b/src/components/MessageManger/Dashboard/RealtimeSection.js index 243aeb2..82f5ccd 100644 --- a/src/components/MessageManger/Dashboard/RealtimeSection.js +++ b/src/components/MessageManger/Dashboard/RealtimeSection.js @@ -49,6 +49,7 @@ const buildTodayHourlySlots = raw => { if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY) return; const n = Number(item?.count) || 0; const typ = Number(item?.type); + if (!Number.isFinite(typ)) return; if (typ === 0) slots[h].email += n; else if (typ === 1) slots[h].sms += n; }); @@ -79,15 +80,17 @@ const buildTodayHourlySlots = raw => { 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) - ); +/** 消息实时统计:type 0 邮件、1 短信(与 enums messageManagerType 一致) */ +const messageTypeLabel = (typeKey, formatMessage) => { + if (typeKey === '0') return formatMessage({ id: 'Email' }); + if (typeKey === '1') return formatMessage({ id: 'SMS' }); + return `${formatMessage({ id: 'Type' })} ${typeKey}`; +}; + +const messageTypeLineColor = (typeKey, index) => { + if (typeKey === '0') return PALETTE.email; + if (typeKey === '1') return PALETTE.sms; + return PALETTE.pie[index % PALETTE.pie.length]; }; /** 当前接口:byType 为 { "0": n, "1": m },totalRecords 为数字 */ @@ -140,7 +143,7 @@ const RealtimeSection = createWithRemoteLoader({ // 时段汇总:与 hourly 序列同源(buildTodayHourlySlots),按四小时段聚合 const periodStats = useMemo(() => { - if (!realtimeData || !hasHourlyInput(realtimeData)) return []; + if (!realtimeData) return []; const slots = buildTodayHourlySlots(realtimeData); return TIME_PERIODS.map(period => { let email = 0; @@ -156,84 +159,220 @@ const RealtimeSection = createWithRemoteLoader({ }); }, [realtimeData]); - /** 今日每小时趋势:按任务类型(hourlyTrendByType) */ + /** 今日每小时趋势:有 hourlyTrendByType 时按类型分线,否则按 slots 展示总量与邮件/短信 */ const hourlyOption = useMemo(() => { + if (!realtimeData) return null; + + const hours = Array.from({ length: HOURS_IN_DAY }, (_, h) => `${String(h).padStart(2, '0')}:00`); 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; + if (hourlyTrendByType.length > 0) { + const typeMap = {}; + hourlyTrendByType.forEach(item => { + const h = Number(item?.hour); + const typeNum = Number(item?.type); + if (!Number.isFinite(h) || h < 0 || h >= HOURS_IN_DAY || !Number.isFinite(typeNum)) return; + const type = String(typeNum); + if (!typeMap[type]) typeMap[type] = Array.from({ length: HOURS_IN_DAY }, () => 0); + typeMap[type][h] += Number(item?.count) || 0; + }); + + const typeKeys = Object.keys(typeMap).sort((a, b) => Number(a) - Number(b)); + if (typeKeys.length > 0) { + return { + color: typeKeys.map((key, index) => messageTypeLineColor(key, index)), + tooltip: tooltipStyle, + legend: { + ...legendCenterStyle, + data: typeKeys.map(key => messageTypeLabel(key, formatMessage)) + }, + grid: { ...lineChartGrid, bottom: 28 }, + xAxis: { + type: 'category', + boundaryGap: false, + data: hours, + 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) => { + const color = messageTypeLineColor(key, index); + return { + name: messageTypeLabel(key, formatMessage), + type: 'line', + symbol: 'circle', + symbolSize: 5, + smooth: 0.28, + data: typeMap[key], + lineStyle: { width: 2.5, color }, + itemStyle: { color, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' } + }; + }) + }; + } + } + + const slots = buildTodayHourlySlots(realtimeData); + const totalLabel = formatMessage({ id: 'TotalCount' }); + const emailLabel = formatMessage({ id: 'Email' }); + const smsLabel = formatMessage({ id: 'SMS' }); + const lineFor = (data, color) => ({ + type: 'line', + symbol: 'circle', + symbolSize: 5, + smooth: 0.28, + data, + lineStyle: { width: 2.5, color }, + itemStyle: { color, borderColor: '#fff', borderWidth: 2 }, + emphasis: { focus: 'series' } + }); return { - color: PALETTE.pie, + color: [PALETTE.total, PALETTE.email, PALETTE.sms], tooltip: tooltipStyle, - legend: { - ...legendCenterStyle, - data: typeKeys.map(key => `Type ${key}`) - }, + legend: { ...legendCenterStyle, data: [totalLabel, emailLabel, smsLabel] }, grid: { ...lineChartGrid, bottom: 28 }, xAxis: { type: 'category', boundaryGap: false, - data: hours.map(h => `${String(h).padStart(2, '0')}:00`), + data: hours, axisLine: axisLineStyle, axisTick: { show: false }, - axisLabel: { - ...axisLabelStyle, - fontSize: 10, - interval: 1, - rotate: 0 - } + 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' } - })) + series: [ + { name: totalLabel, ...lineFor(slots.map(s => (s.hasTotalFromApi ? s.total : s.email + s.sms)), PALETTE.total) }, + { name: emailLabel, ...lineFor(slots.map(s => s.email), PALETTE.email) }, + { name: smsLabel, ...lineFor(slots.map(s => s.sms), PALETTE.sms) } + ] }; - }, [realtimeData]); + }, [realtimeData, formatMessage]); - /** 时段对比:按状态(成功/执行中/等待/取消/错误) */ + /** + * 时段对比:优先按状态(hourlyTrendByStatus);消息统计接口无该字段时, + * 用 hourly 数据按时段汇总邮件/短信,与上方时段条一致。 + */ 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; + const hourlyTrendByStatus = Array.isArray(realtimeData?.hourlyTrendByStatus) ? realtimeData.hourlyTrendByStatus : []; + + if (hourlyTrendByStatus.length > 0) { + 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 + })) + }; + } + + if (!realtimeData) { + const emailLabel = formatMessage({ id: 'Email' }); + const smsLabel = formatMessage({ id: 'SMS' }); + return { + color: [PALETTE.email, PALETTE.sms], + tooltip: { + ...tooltipStyle, + trigger: 'axis', + axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } } + }, + legend: { ...legendCenterStyle, data: [emailLabel, smsLabel] }, + 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: [ + { + name: emailLabel, + type: 'bar', + data: [0, 0, 0, 0], + barMaxWidth: 28, + barGap: '12%', + itemStyle: { color: PALETTE.email, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' }, + z: 2 + }, + { + name: smsLabel, + type: 'bar', + data: [0, 0, 0, 0], + barMaxWidth: 28, + barGap: '12%', + itemStyle: { color: PALETTE.sms, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' }, + z: 1 + } + ] + }; + } + + const slots = buildTodayHourlySlots(realtimeData); + const emailData = TIME_PERIODS.map(period => { + let sum = 0; + for (let h = period.start; h < period.end; h++) sum += slots[h].email; + return sum; }); + const smsData = TIME_PERIODS.map(period => { + let sum = 0; + for (let h = period.start; h < period.end; h++) sum += slots[h].sms; + return sum; + }); + + const emailLabel = formatMessage({ id: 'Email' }); + const smsLabel = formatMessage({ id: 'SMS' }); return { - color: STATUS_SERIES.map(item => item.color), + color: [PALETTE.email, PALETTE.sms], tooltip: { ...tooltipStyle, trigger: 'axis', axisPointer: { type: 'shadow', shadowStyle: { color: 'rgba(148,163,184,0.15)' } } }, - legend: { ...legendCenterStyle, data: STATUS_SERIES.map(item => item.label) }, + legend: { ...legendCenterStyle, data: [emailLabel, smsLabel] }, grid: { ...lineChartGrid, bottom: 20 }, xAxis: { type: 'category', @@ -243,15 +382,28 @@ const RealtimeSection = createWithRemoteLoader({ 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 - })) + series: [ + { + name: emailLabel, + type: 'bar', + data: emailData, + barMaxWidth: 28, + barGap: '12%', + itemStyle: { color: PALETTE.email, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' }, + z: 2 + }, + { + name: smsLabel, + type: 'bar', + data: smsData, + barMaxWidth: 28, + barGap: '12%', + itemStyle: { color: PALETTE.sms, borderRadius: [4, 4, 0, 0] }, + emphasis: { focus: 'series' }, + z: 1 + } + ] }; }, [realtimeData, formatMessage]); @@ -400,20 +552,12 @@ const RealtimeSection = createWithRemoteLoader({ - {periodCompareOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
- {hourlyOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
diff --git a/src/components/Task/Dashboard/HistorySection.js b/src/components/Task/Dashboard/HistorySection.js index 0556a08..2819e50 100644 --- a/src/components/Task/Dashboard/HistorySection.js +++ b/src/components/Task/Dashboard/HistorySection.js @@ -16,6 +16,59 @@ import SectionHeader from './SectionHeader'; import { getClientIanaTimezone } from '../utils'; import style from './dashboard.module.scss'; +const RANGE_DAY_COUNT = { '7d': 7, '1m': 30, '1y': 365 }; + +/** 每小时趋势「按状态」折线:避免 running 使用琥珀色易与其它图表橙色混淆 */ +const HOURLY_STATUS_LINE_COLOR_MAP = { + ...STATUS_COLOR_MAP, + running: '#7c3aed' +}; + +/** 本地日历上从 range 起点到今天的日期轴(YYYY-MM-DD) */ +const buildLocalDateAxisForRange = rangeKey => { + const days = RANGE_DAY_COUNT[rangeKey] || 7; + const dates = []; + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - i); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + dates.push(`${y}-${m}-${day}`); + } + return dates; +}; + +const buildEmptyHorizontalBarOption = (placeholder, splitLineStyle, axisLineStyle, axisLabelStyle) => ({ + tooltip: { show: false }, + grid: { left: 6, right: 28, top: 10, bottom: 10, containLabel: true }, + xAxis: { + type: 'value', + max: 1, + min: 0, + splitLine: splitLineStyle, + axisLine: { show: false }, + axisLabel: axisLabelStyle, + axisTick: { show: false } + }, + yAxis: { + type: 'category', + data: [placeholder], + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 11 } + }, + series: [ + { + type: 'bar', + data: [{ value: 0, itemStyle: { color: '#e2e8f0', borderRadius: [0, 6, 6, 0] } }], + barMaxWidth: 26, + label: { show: false } + } + ] +}); + const HistorySection = createWithRemoteLoader({ modules: ['components-thirdparty:Echart', 'components-core:Enum'] })( @@ -23,6 +76,8 @@ const HistorySection = createWithRemoteLoader({ const [Echart, Enum] = remoteModules; const { formatMessage } = useIntl(); const [range, setRange] = useState('7d'); + /** 历史每小时趋势:按任务类型 | 按任务状态 */ + const [hourlyHistoryDim, setHourlyHistoryDim] = useState('type'); const reloadRef = useRef(() => {}); return ( @@ -60,12 +115,18 @@ const HistorySection = createWithRemoteLoader({ const trendOption = (() => { const recentTrend = data?.recentTrend || []; const recentTrendByType = data?.recentTrendByType || []; - if (recentTrend.length === 0) return null; + const axisDates = recentTrend.length > 0 ? null : buildLocalDateAxisForRange(range); const dateMap = {}; - recentTrend.forEach(item => { - dateMap[item.date] = { date: item.date, total: item.count }; - }); + if (recentTrend.length > 0) { + recentTrend.forEach(item => { + dateMap[item.date] = { date: item.date, total: item.count }; + }); + } else { + axisDates.forEach(d => { + dateMap[d] = { date: d, total: 0 }; + }); + } recentTrendByType.forEach(item => { if (!dateMap[item.date]) { dateMap[item.date] = { date: item.date, total: 0 }; @@ -78,7 +139,6 @@ const HistorySection = createWithRemoteLoader({ 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); @@ -182,7 +242,7 @@ const HistorySection = createWithRemoteLoader({ } ] } - : null; + : buildEmptyHorizontalBarOption(formatMessage({ id: 'NoData' }), splitLineStyle, axisLineStyle, axisLabelStyle); const typeEntries = Object.entries(byType) .map(([type, count]) => ({ type: String(type), count: Number(count) || 0 })) @@ -193,7 +253,6 @@ const HistorySection = createWithRemoteLoader({ 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 => sanitizeStatisticsDurationMs(item.avgWaitingTime) ?? 0); const avgExec = rows.map(item => sanitizeStatisticsDurationMs(item.avgExecutionTime) ?? 0); @@ -253,22 +312,35 @@ const HistorySection = createWithRemoteLoader({ }; }; - 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 durationAxisDates = buildLocalDateAxisForRange(range); + const manualDurationRows = durationTrend.length + ? durationTrend.map(day => { + const b = day?.byRunnerType?.manual; + return { + date: day.date, + avgWaitingTime: b?.avgWaitingTime ?? 0, + avgExecutionTime: b?.avgExecutionTime ?? 0 + }; + }) + : durationAxisDates.map(date => ({ + date, + avgWaitingTime: 0, + avgExecutionTime: 0 + })); + const systemDurationRows = durationTrend.length + ? durationTrend.map(day => { + const b = day?.byRunnerType?.system; + return { + date: day.date, + avgWaitingTime: b?.avgWaitingTime ?? 0, + avgExecutionTime: b?.avgExecutionTime ?? 0 + }; + }) + : durationAxisDates.map(date => ({ + date, + avgWaitingTime: 0, + avgExecutionTime: 0 + })); const manualDurationOption = buildWaitExecDurationOption(manualDurationRows); const systemDurationOption = buildWaitExecDurationOption(systemDurationRows); @@ -289,8 +361,8 @@ const HistorySection = createWithRemoteLoader({
{formatMessage({ id: 'StatusDistribution' })}
- {statusHBarOption ? ( -
+
+ {statusDistItems.length > 0 ? (
{statusDistItems.map(d => ( @@ -312,16 +384,14 @@ const HistorySection = createWithRemoteLoader({ ))}
-
- -
+ ) : null} +
+
- ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
@@ -383,15 +453,15 @@ const HistorySection = createWithRemoteLoader({ } ] } - : null; + : buildEmptyHorizontalBarOption(formatMessage({ id: 'NoData' }), splitLineStyle, axisLineStyle, axisLabelStyle); return (
{formatMessage({ id: 'TaskTypeDistribution' })}
- {typeHBarOption ? ( -
+
+ {typeDistItems.length > 0 ? (
{typeDistItems.map(d => ( @@ -413,16 +483,14 @@ const HistorySection = createWithRemoteLoader({ ))}
-
- -
+ ) : null} +
+
- ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
); @@ -431,63 +499,261 @@ const HistorySection = createWithRemoteLoader({
- {trendOption ? ( - - {taskTypeList => { - const typeLabelMap = {}; - (taskTypeList || []).forEach(item => { - typeLabelMap[item.value] = item.label || item.description || item.value; + + {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 ; + }} + + + + + + + + + + + + + + + + + + {taskTypeList => { + const typeLabelMap = {}; + (taskTypeList || []).forEach(item => { + typeLabelMap[String(item.value)] = item.label || item.description || item.value; + }); + + const hourlyCompletionTrend = data?.hourlyCompletionTrend || []; + const rangeDates = new Set(buildLocalDateAxisForRange(range)); + const hourLabels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); + const empty24 = () => Array.from({ length: 24 }, () => 0); + + const statusCountField = { + success: 'successCount', + failed: 'failedCount', + canceled: 'canceledCount', + running: 'runningCount', + pending: 'pendingCount', + waiting: 'waitingCount' + }; + + const baseAxes = { + xAxis: { + type: 'category', + boundaryGap: false, + data: hourLabels, + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: { ...axisLabelStyle, fontSize: 10, interval: 1 } + }, + yAxis: { + type: 'value', + ...splitLineStyle, + axisLabel: { ...axisLabelStyle, minInterval: 1 }, + min: 0 + } + }; + + let option; + if (hourlyHistoryDim === 'status') { + const seriesData = {}; + TASK_STATUS_LIST.forEach(s => { + seriesData[s] = empty24(); + }); + hourlyCompletionTrend.forEach(r => { + if (!r?.date || !rangeDates.has(r.date)) return; + const h = Number(r.hour); + if (!Number.isFinite(h) || h < 0 || h >= 24) return; + if (r.status != null && (r.count != null || r.totalCompleted != null)) { + const st = String(r.status); + if (seriesData[st]) { + seriesData[st][h] += Number(r.count != null ? r.count : r.totalCompleted) || 0; + } + return; + } + TASK_STATUS_LIST.forEach(st => { + const field = statusCountField[st]; + if (field && r[field] != null) { + seriesData[st][h] += Number(r[field]) || 0; + } }); - const resolvedOption = { - ...trendOption, - legend: { - ...trendOption.legend, - data: [ - trendOption.legend.data[0], - ...trendOption.legend.data.slice(1).map(name => typeLabelMap[name] || name) - ] + }); + const activeStatuses = TASK_STATUS_LIST.filter(st => + seriesData[st].reduce((a, b) => a + b, 0) > 0 + ); + if (activeStatuses.length === 0) { + const totals = empty24(); + hourlyCompletionTrend.forEach(r => { + if (!r?.date || !rangeDates.has(r.date)) return; + const hh = Number(r.hour); + if (!Number.isFinite(hh) || hh < 0 || hh >= 24) return; + totals[hh] += Number(r.totalCompleted) || 0; + }); + const totalLabel = formatMessage({ id: 'TotalCount' }); + option = { + color: [PALETTE.total], + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: [totalLabel] }, + grid: { ...lineChartGrid, bottom: 28 }, + ...baseAxes, + series: [ + { + name: totalLabel, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 5, + data: totals, + lineStyle: { width: 2, color: PALETTE.total }, + itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' }, + areaStyle: { color: `${PALETTE.total}18` } + } + ] + }; + } else { + const legendData = activeStatuses.map(s => + formatMessage({ id: s.charAt(0).toUpperCase() + s.slice(1) }) + ); + const series = activeStatuses.map((st, i) => ({ + name: legendData[i], + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + data: seriesData[st], + lineStyle: { width: 1.5, color: HOURLY_STATUS_LINE_COLOR_MAP[st] }, + itemStyle: { color: HOURLY_STATUS_LINE_COLOR_MAP[st], borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' } + })); + option = { + color: activeStatuses.map(s => HOURLY_STATUS_LINE_COLOR_MAP[s]), + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: legendData }, + grid: { ...lineChartGrid, bottom: 28 }, + ...baseAxes, + series + }; + } + } else { + const byType = {}; + hourlyCompletionTrend.forEach(r => { + if (!r?.date || !rangeDates.has(r.date)) return; + const h = Number(r.hour); + if (!Number.isFinite(h) || h < 0 || h >= 24) return; + const t = r.type != null && r.type !== '' ? String(r.type) : null; + if (!t) return; + if (!byType[t]) byType[t] = empty24(); + byType[t][h] += Number(r.totalCompleted) || 0; + }); + const typeKeys = Object.keys(byType).sort(); + if (typeKeys.length === 0) { + const totals = empty24(); + hourlyCompletionTrend.forEach(r => { + if (!r?.date || !rangeDates.has(r.date)) return; + const h = Number(r.hour); + if (!Number.isFinite(h) || h < 0 || h >= 24) return; + totals[h] += Number(r.totalCompleted) || 0; + }); + const totalLabel = formatMessage({ id: 'TotalCount' }); + option = { + color: [PALETTE.total], + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: [totalLabel] }, + grid: { ...lineChartGrid, bottom: 28 }, + ...baseAxes, + series: [ + { + name: totalLabel, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 5, + data: totals, + lineStyle: { width: 2, color: PALETTE.total }, + itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' }, + areaStyle: { color: `${PALETTE.total}18` } + } + ] + }; + } else { + const legendData = typeKeys.map(t => typeLabelMap[t] || t); + const series = typeKeys.map((t, i) => ({ + name: typeLabelMap[t] || t, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + data: byType[t], + 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 }, - series: trendOption.series.map((s, i) => { - if (i === 0) return s; - return { ...s, name: typeLabelMap[s.name] || s.name }; - }) + emphasis: { focus: 'series' } + })); + option = { + color: typeKeys.map((_, i) => TASK_TYPE_COLOR_MAP[i % TASK_TYPE_COLOR_MAP.length]), + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: legendData }, + grid: { ...lineChartGrid, bottom: 28 }, + ...baseAxes, + series }; - return ; - }} - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} - + } + } - {durationTrend.length > 0 ? ( - - - - {manualDurationOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} -
- - + return ( + } > - {systemDurationOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
- -
- ) : null} + ); + }} + ); }} diff --git a/src/components/Task/Dashboard/RealtimeSection.js b/src/components/Task/Dashboard/RealtimeSection.js index ce3c7f6..94934e8 100644 --- a/src/components/Task/Dashboard/RealtimeSection.js +++ b/src/components/Task/Dashboard/RealtimeSection.js @@ -1,7 +1,7 @@ import { createWithRemoteLoader } from '@kne/remote-loader'; -import { useMemo, useCallback } from 'react'; +import { useMemo, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Col, Row, Space, Tag } from 'antd'; +import { Col, Row, Segmented, 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'; @@ -20,13 +20,25 @@ import { splitLineStyle, formatDuration, pickStatisticsDurationMs, - sanitizeStatisticsDurationMs + sanitizeStatisticsDurationMs, + resolveDurationMsForDashboard, + sortTaskTypeKeys } from './constants'; import useRealtimeStatisticsSSE from './useRealtimeStatisticsSSE'; import style from './dashboard.module.scss'; const HOURS_IN_DAY = 24; +/** 取第一个有效非负整数;用于统计类字段的多来源兼容 */ +const pickNonNegativeInt = (...candidates) => { + for (const v of candidates) { + if (v === undefined || v === null) continue; + const n = Number(v); + if (Number.isFinite(n) && n >= 0) return Math.trunc(n); + } + return 0; +}; + const buildTodayHourlySlots = raw => { const slots = Array.from({ length: HOURS_IN_DAY }, (_, h) => ({ hour: h, total: 0, hasTotalFromApi: false, byType: {} @@ -49,12 +61,6 @@ const buildTodayHourlySlots = raw => { return slots; }; -const hasHourlyInput = raw => { - const a = raw?.hourlyTrend; - const b = raw?.hourlyTrendByType; - return (Array.isArray(a) && a.length > 0) || (Array.isArray(b) && b.length > 0); -}; - const RealtimeSection = createWithRemoteLoader({ modules: ['components-thirdparty:Echart', 'components-core:Enum'] })( @@ -82,6 +88,8 @@ const RealtimeSection = createWithRemoteLoader({ ); const sseUrl = apis?.task?.statistics?.sse?.url; const { realtimeData, isConnected, lastUpdatedAt } = useRealtimeStatisticsSSE(sseUrl); + /** 执行时间统计三张卡 + 按类型耗时图:全部 | 手动 | 自动(todayDuration / byTypeByRunnerType) */ + const [durationByTypeRunnerMode, setDurationByTypeRunnerMode] = useState('all'); const byStatus = realtimeData?.byStatus || {}; const byRunnerType = realtimeData?.byRunnerType || {}; @@ -93,25 +101,35 @@ const RealtimeSection = createWithRemoteLoader({ const canceledCount = Number(byStatus.canceled) || 0; const manualRt = realtimeData?.runnerTypeStats?.manual; const hasManualRunnerStats = manualRt && typeof manualRt === 'object'; - const pendingByRunnerType = realtimeData?.pendingByRunnerType || {}; const manualTaskCount = hasManualRunnerStats ? Number(manualRt.total) || 0 : Number(byRunnerType.manual) || 0; - const manualPendingCount = hasManualRunnerStats - ? Number(manualRt.pending) || 0 - : Number(pendingByRunnerType.manual) || 0; - const manualExecutedCount = hasManualRunnerStats - ? Number(manualRt.executed) || 0 - : Math.max(0, manualTaskCount - (Number(pendingByRunnerType.manual) || 0)); - const manualDurationDisplay = useMemo(() => { - const manualDur = realtimeData?.todayDuration?.byRunnerType?.manual; - if (!manualDur || typeof manualDur !== 'object') return null; - return { - avgWaitingTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgWaitingTime)), - avgExecutionTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgExecutionTime)), - avgTotalTime: formatDuration(sanitizeStatisticsDurationMs(manualDur.avgTotalTime)) - }; - }, [realtimeData]); + /** + * 手动「等待操作」:**主展示条数**(waitingByRunnerType.manual 等);次展示队列内 + * 最长等待(`waitingQueueMaxWaitMsByRunnerType.manual`,当前时间 − createdAt)。 + */ + const manualPendingCount = pickNonNegativeInt( + realtimeData?.waitingByRunnerType?.manual, + manualRt?.waiting, + manualRt?.waitingCount + ); + /** + * 手动「当日完成」:**主展示条数**(completedToday.manual);次展示当日完成任务的 + * 创建→完成耗时之和(`completedTodayTotalDurationMsByRunnerType.manual`)。 + */ + const manualExecutedCount = pickNonNegativeInt(realtimeData?.completedToday?.manual); + + const manualPendingMaxWaitDisplay = useMemo(() => { + if (manualPendingCount <= 0) return '—'; + const ms = resolveDurationMsForDashboard(realtimeData?.waitingQueueMaxWaitMsByRunnerType?.manual); + return ms != null ? formatDuration(ms) : '—'; + }, [realtimeData, manualPendingCount]); + + const manualCompletedTotalDurationDisplay = useMemo(() => { + if (manualExecutedCount <= 0) return '—'; + const ms = resolveDurationMsForDashboard(realtimeData?.completedTodayTotalDurationMsByRunnerType?.manual); + return ms != null ? formatDuration(ms) : '—'; + }, [realtimeData, manualExecutedCount]); const lastUpdatedShort = useMemo(() => { if (!lastUpdatedAt) return ''; @@ -125,7 +143,7 @@ const RealtimeSection = createWithRemoteLoader({ }, [lastUpdatedAt]); const periodStats = useMemo(() => { - if (!realtimeData || !hasHourlyInput(realtimeData)) return []; + if (!realtimeData) return []; const slots = buildTodayHourlySlots(realtimeData); return TIME_PERIODS.map(period => { let total = 0; @@ -141,17 +159,53 @@ const RealtimeSection = createWithRemoteLoader({ }); }, [realtimeData]); + /** 与「今日每小时趋势」series 顺序一致;下方时段条按此列表取色,避免同色不同 type */ + const todayHourlyTaskTypeOrder = useMemo(() => { + if (!realtimeData) return []; + const slots = buildTodayHourlySlots(realtimeData); + const typeSet = new Set(); + slots.forEach(s => Object.keys(s.byType).forEach(t => typeSet.add(t))); + return sortTaskTypeKeys(typeSet); + }, [realtimeData]); + const hourlyOption = useMemo(() => { - if (!realtimeData || !hasHourlyInput(realtimeData)) return null; + if (!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 typeList = todayHourlyTaskTypeOrder; + + if (typeList.length === 0) { + const totalLabel = formatMessage({ id: 'TotalCount' }); + return { + color: [PALETTE.total], + tooltip: tooltipStyle, + legend: { ...legendCenterStyle, data: [totalLabel] }, + 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: [ + { + name: totalLabel, + type: 'line', + smooth: lineSmooth, + symbol: 'circle', + symbolSize: 4, + showSymbol: true, + data: slots.map(s => s.total), + lineStyle: { width: 1.5, color: PALETTE.total }, + itemStyle: { color: PALETTE.total, borderColor: '#fff', borderWidth: 1 }, + emphasis: { focus: 'series' }, + areaStyle: { color: `${PALETTE.total}18` } + } + ] + }; + } const typeSeries = typeList.map((type, i) => ({ name: type, @@ -183,11 +237,10 @@ const RealtimeSection = createWithRemoteLoader({ yAxis: { type: 'value', ...splitLineStyle, axisLabel: axisLabelStyle, minInterval: 1, min: 0 }, series: typeSeries }; - }, [realtimeData]); + }, [realtimeData, formatMessage, todayHourlyTaskTypeOrder]); 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 = { @@ -230,27 +283,140 @@ const RealtimeSection = createWithRemoteLoader({ const durationDisplay = useMemo(() => { const dur = realtimeData?.todayDuration; if (!dur) return null; + + if (durationByTypeRunnerMode === 'all') { + return { + avgWaitingTime: formatDuration(pickStatisticsDurationMs(dur, 'avgWaitingTime')), + avgExecutionTime: formatDuration(pickStatisticsDurationMs(dur, 'avgExecutionTime')), + avgTotalTime: formatDuration(pickStatisticsDurationMs(dur, 'avgTotalTime')) + }; + } + + const source = + dur.byRunnerType && typeof dur.byRunnerType === 'object' + ? dur.byRunnerType[durationByTypeRunnerMode] + : undefined; + const pickSlice = field => formatDuration(pickStatisticsDurationMs(source, field, [])); + return { - avgWaitingTime: formatDuration(pickStatisticsDurationMs(dur, 'avgWaitingTime')), - avgExecutionTime: formatDuration(pickStatisticsDurationMs(dur, 'avgExecutionTime')), - avgTotalTime: formatDuration(pickStatisticsDurationMs(dur, 'avgTotalTime')) + avgWaitingTime: pickSlice('avgWaitingTime'), + avgExecutionTime: pickSlice('avgExecutionTime'), + avgTotalTime: pickSlice('avgTotalTime') }; - }, [realtimeData]); + }, [realtimeData, durationByTypeRunnerMode]); const durationByTypeOption = useMemo(() => { - const todayDur = realtimeData?.todayDuration; - if (!todayDur || typeof todayDur !== 'object') return null; + if (!realtimeData) return null; - const durationByType = - todayDur.byType && typeof todayDur.byType === 'object' ? todayDur.byType : {}; - const countByType = + const todayDur = realtimeData?.todayDuration; + const byRunnerSplit = todayDur?.byTypeByRunnerType; + let durationByType = {}; + let countByType = realtimeData.byType && typeof realtimeData.byType === 'object' ? realtimeData.byType : {}; + if (durationByTypeRunnerMode === 'manual') { + const m = byRunnerSplit?.manual; + if (m && typeof m === 'object') { + durationByType = m; + countByType = Object.fromEntries( + Object.keys(durationByType).map(k => { + const d = durationByType[k]; + const c = d && typeof d === 'object' ? Number(d.count) || 0 : 0; + return [k, c]; + }) + ); + } else { + durationByType = {}; + countByType = {}; + } + } else if (durationByTypeRunnerMode === 'system') { + const s = byRunnerSplit?.system; + if (s && typeof s === 'object') { + durationByType = s; + countByType = Object.fromEntries( + Object.keys(durationByType).map(k => { + const d = durationByType[k]; + const c = d && typeof d === 'object' ? Number(d.count) || 0 : 0; + return [k, c]; + }) + ); + } else { + durationByType = {}; + countByType = {}; + } + } else { + durationByType = + todayDur && typeof todayDur === 'object' && todayDur.byType && typeof todayDur.byType === 'object' + ? todayDur.byType + : {}; + countByType = + realtimeData.byType && typeof realtimeData.byType === 'object' ? realtimeData.byType : {}; + } + const typeSet = new Set([ ...Object.keys(countByType).filter(k => (Number(countByType[k]) || 0) > 0), ...Object.keys(durationByType).filter(k => durationByType[k] && typeof durationByType[k] === 'object') ]); - if (typeSet.size === 0) return null; + const noDataLabel = formatMessage({ id: 'NoData' }); + if (typeSet.size === 0) { + 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: [noDataLabel], + axisLine: axisLineStyle, + axisTick: { show: false }, + axisLabel: axisLabelStyle + }, + yAxis: { + type: 'value', + ...splitLineStyle, + axisLabel: { ...axisLabelStyle, formatter: value => formatDuration(value) }, + min: 0 + }, + series: [ + { + name: formatMessage({ id: 'AvgExecutionTime' }), + type: 'bar', + data: [0], + barMaxWidth: 28, + itemStyle: { color: PALETTE.running, borderRadius: [4, 4, 0, 0] } + }, + { + name: formatMessage({ id: 'AvgWaitingTime' }), + type: 'bar', + data: [0], + barMaxWidth: 28, + itemStyle: { color: PALETTE.waiting, borderRadius: [4, 4, 0, 0] } + }, + { + name: formatMessage({ id: 'AvgTotalTime' }), + type: 'bar', + data: [0], + barMaxWidth: 28, + itemStyle: { color: PALETTE.total, borderRadius: [4, 4, 0, 0] } + } + ] + }; + } const entries = Array.from(typeSet) .map(type => { @@ -322,7 +488,7 @@ const RealtimeSection = createWithRemoteLoader({ } ] }; - }, [realtimeData, formatMessage]); + }, [realtimeData, formatMessage, durationByTypeRunnerMode]); return ( <> @@ -406,45 +572,42 @@ const RealtimeSection = createWithRemoteLoader({
} - title={{manualPendingCount}} - description={{formatMessage({ id: 'ManualPendingTasks' })}} + style={{ textAlign: 'left' }} + title={{manualPendingCount}} + description={ +
+ {formatMessage({ id: 'ManualPendingTasks' })} +
+ {formatMessage({ id: 'ManualPendingMaxWaitLabel' })} + + {manualPendingMaxWaitDisplay} + +
+
+ } /> } + style={{ textAlign: 'left' }} title={{manualExecutedCount}} - description={{formatMessage({ id: 'ManualExecutedTasks' })}} + description={ +
+ {formatMessage({ id: 'ManualExecutedTasks' })} +
+ {formatMessage({ id: 'ManualCompletedTotalDurationLabel' })} + + {manualCompletedTotalDurationDisplay} + +
+
+ } />
- {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' })}
@@ -494,7 +657,20 @@ const RealtimeSection = createWithRemoteLoader({
-
{formatMessage({ id: 'ExecutionTimeStatistics' })}
+
+ {formatMessage({ id: 'ExecutionTimeStatistics' })} + +
{durationDisplay ? ( <> @@ -527,12 +703,11 @@ const RealtimeSection = createWithRemoteLoader({ )}
- - {resolvedDurationByTypeOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} + +
@@ -567,12 +742,18 @@ const RealtimeSection = createWithRemoteLoader({
- {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 ( + {sortTaskTypeKeys( + Object.entries(period.byType) + .filter(([, c]) => (Number(c) || 0) > 0) + .map(([type]) => type) + ).map(type => { + const count = Number(period.byType[type]) || 0; + const ci = todayHourlyTaskTypeOrder.indexOf(type); + const color = + TASK_TYPE_COLOR_MAP[ + (ci >= 0 ? ci : 0) % TASK_TYPE_COLOR_MAP.length + ]; + return ( {count} ); - })} + })}
@@ -595,20 +776,12 @@ const RealtimeSection = createWithRemoteLoader({ - {periodCompareOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
- {resolvedHourlyOption ? ( - - ) : ( -
{formatMessage({ id: 'NoData' })}
- )} +
diff --git a/src/components/Task/Dashboard/constants.js b/src/components/Task/Dashboard/constants.js index 43f2358..9111e63 100644 --- a/src/components/Task/Dashboard/constants.js +++ b/src/components/Task/Dashboard/constants.js @@ -1,3 +1,5 @@ +import { defaultColors } from '@kne/react-box'; + export const RANGE_OPTIONS = ['7d', '1m', '1y']; export const buildUrlWithParams = (url, params = {}) => { @@ -41,7 +43,21 @@ export const STATUS_COLOR_MAP = { canceled: PALETTE.canceled }; -export const TASK_TYPE_COLOR_MAP = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6']; +/** + * 任务类型折线/柱状:色值全部来自 @kne/react-box 的 defaultColors(与 ColorfulCard 同源)。 + * 顺序按 key 打散,减轻相邻系列里橙/黄等相近色贴在一起;不含 Black,避免细线对比度不足。 + */ +const TASK_TYPE_COLOR_KEYS = ['Purple', 'Green', 'Orange', 'Blue', 'Pink', 'Yellow', 'Red', 'Gray']; + +export const TASK_TYPE_COLOR_MAP = TASK_TYPE_COLOR_KEYS.map(key => defaultColors[key]); + +/** 任务类型 key 稳定排序:折线图 series 与下方 Tag 等共用,保证同一 type 色标一致 */ +export const sortTaskTypeKeys = types => { + const arr = types == null ? [] : [...types]; + return arr.sort((a, b) => + String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }) + ); +}; export const tooltipStyle = { trigger: 'axis', @@ -134,6 +150,9 @@ export const splitLineStyle = { lineStyle: { color: '#f1f5f9', type: 'dashed', w /** 接口约定为毫秒;超出此上限视为异常数据(如错误聚合),不参与展示与加权回算 */ export const MAX_STATISTICS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; +/** 看板「多任务耗时合计」等展示用:允许大于单任务校验上限,仍防极端异常值 */ +export const DASHBOARD_AGGREGATE_DURATION_CAP_MS = 5 * 365 * 24 * 60 * 60 * 1000; + export const sanitizeStatisticsDurationMs = value => { const n = Number(value); if (!Number.isFinite(n) || n < 0) return null; @@ -141,6 +160,15 @@ export const sanitizeStatisticsDurationMs = value => { return n; }; +/** 单任务仍走 sanitize;超长队列等待或合计耗时在 cap 内用于展示 */ +export const resolveDurationMsForDashboard = raw => { + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return null; + const sane = sanitizeStatisticsDurationMs(n); + if (sane != null) return sane; + return Math.min(n, DASHBOARD_AGGREGATE_DURATION_CAP_MS); +}; + /** * 优先使用容器顶层字段(经 sanitize);无效时按 count 对 byType、byRunnerType 做加权回算(毫秒)。 */ diff --git a/src/components/Task/Dashboard/dashboard.module.scss b/src/components/Task/Dashboard/dashboard.module.scss index 3ba3d10..57b549a 100644 --- a/src/components/Task/Dashboard/dashboard.module.scss +++ b/src/components/Task/Dashboard/dashboard.module.scss @@ -117,6 +117,11 @@ display: block; } +/** 手动面板首张 KPI:标题数字与下方块左对齐 */ +.manualKpiCard { + text-align: left; +} + .kpiDescDense { font-size: 11px; line-height: 1.3; @@ -124,6 +129,72 @@ opacity: 0.92; } +.kpiDescMeta { + display: block; + margin-top: 3px; + font-size: 10px; + font-weight: 500; + opacity: 0.88; +} + +/** 手动 KPI:主数字下方说明 + 带标签的时间块(左对齐) */ +.kpiManualCardDesc { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + margin-top: 4px; + width: 100%; + text-align: left; +} + +.kpiManualCardPurpose { + font-size: 11px; + font-weight: 600; + line-height: 1.35; + color: #475569; + letter-spacing: 0.01em; + text-align: left; +} + +.manualKpiTimeBlock { + text-align: left; + padding: 8px 10px 9px; + border-radius: 9px; + border: 1px solid rgba(148, 163, 184, 0.38); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.94) 100%); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} + +.manualKpiTimeBlockPending { + border-color: rgba(239, 68, 68, 0.28); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(254, 242, 242, 0.55) 100%); +} + +.manualKpiTimeBlockDone { + border-color: rgba(16, 185, 129, 0.3); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.55) 100%); +} + +.manualKpiTimeLabel { + display: block; + font-size: 11px; + font-weight: 700; + color: #334155; + letter-spacing: 0.02em; + line-height: 1.25; +} + +.manualKpiTimeValue { + display: block; + margin-top: 6px; + font-size: clamp(0.9rem, 2.6vw, 1.05rem); + font-weight: 750; + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; + line-height: 1.15; +} + .realtimeGroupTitle { margin-top: 14px; margin-bottom: 10px; @@ -133,6 +204,20 @@ letter-spacing: 0.02em; } +.executionTimeStatHead { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + margin-top: 14px; + margin-bottom: 10px; +} + +.executionTimeStatHead .realtimeGroupTitle { + margin: 0; +} + .realtimeSectionDivider { height: 1px; margin: 14px 0 6px; @@ -185,15 +270,6 @@ 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; @@ -290,6 +366,14 @@ margin-top: 10px; } +/* 每小时趋势 / 按类型耗时:Segmented 字重(卡片标题在 @kne/react-box 内为 h4,切换区走 extra) */ +.historyHourlyTrendSegmented, +.durationByTypeRunnerSegmented { + :global(.ant-segmented-item-label) { + font-weight: 400; + } +} + /* 总任务数:在占比区域外,与下方图表卡片区分 */ .historyOverviewMeta { display: flex; diff --git a/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js index 11b99ce..29a0be8 100644 --- a/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js +++ b/src/components/Task/Dashboard/useRealtimeStatisticsSSE.js @@ -3,6 +3,8 @@ import { getToken } from '@kne/token-storage'; import { buildUrlWithParams } from './constants'; import { getClientIanaTimezone } from '../utils'; +/** 任务实时统计 SSE:负载字段约定见 `src/components/Task/doc/api.md`「任务实时统计 SSE」。 */ + const isLikelyTaskStatisticsPayload = obj => obj && typeof obj === 'object' && @@ -14,7 +16,9 @@ const isLikelyTaskStatisticsPayload = obj => 'byType' in obj || 'todayDuration' in obj || 'pendingByRunnerType' in obj || - 'runnerTypeStats' in obj); + 'runnerTypeStats' in obj || + 'waitingByRunnerType' in obj || + 'completedToday' in obj); const unwrapStatisticsPayload = parsed => { if (parsed == null) return null; diff --git a/src/components/Task/README.md b/src/components/Task/README.md index 66f6a71..602fa37 100644 --- a/src/components/Task/README.md +++ b/src/components/Task/README.md @@ -634,3 +634,70 @@ render(); - success: 成功 - failed: 失败 - canceled: 取消 + +--- + +## 任务统计 HTTP + +**路径**:`GET /api/v1/task/statistics`(与 `apis.task.statistics.getOverview` 对应) + +**Query** + +| 参数 | 说明 | +| --- | --- | +| `range` | 历史区间:`7d` \| `1m` \| `1y` | +| `timezone` | 浏览器 IANA 时区(如 `Asia/Shanghai`),与后端按日历日聚合的划界一致 | + +**响应**(节选,以实际后端为准) + +| 字段 | 说明 | +| --- | --- | +| `recentTrend` / `recentTrendByType` / `recentTrendByStatus` | 历史趋势 | +| `durationTrend` | 耗时趋势 | +| `hourlyCompletionTrend` | 历史「每小时趋势」:`date`、`hour`、`type`、`totalCompleted` 及按状态拆分字段等 | +| `byStatus` / `byType` / `byRunnerType` | 聚合计数 | + +--- + +## 任务实时统计 SSE + +**路径**:`GET /api/v1/task/statistics/sse`(与 `apis.task.statistics.sse` 对应) + +**客户端**:使用 `EventSource` 拉流;前端会在 URL 上追加 query(见 `useRealtimeStatisticsSSE`): + +| 参数 | 说明 | +| --- | --- | +| `interval` | 推送间隔(秒),如 `5` | +| `token` | 鉴权,如 `X-User-Token` | +| `timezone` | IANA 时区,与「今日」、当日完成数划界一致 | + +**每条事件的 `data`** + +为 JSON 字符串,解析后为对象(或 `{ "data": { ... } }`,前端会解包)。对象内为**增量或全量**字段均可;前端对 SSE 负载做浅合并。 + +### 看板「手动执行任务」指标(须与实现对齐) + +前端见 `Dashboard/RealtimeSection.js`:「等待操作」用 `pickNonNegativeInt` 读取 `waiting` 相关字段;「当日完成」**仅**读取 **`completedToday.manual`**。未下发时显示 `0`。 + +| 指标 | 含义 | 字段 | +| --- | --- | --- | +| 手动 · 当前为 **waiting**(等待操作)的任务数 | 快照:当前处于 `waiting` 状态且 `runnerType === manual` 的任务数量 | **`waitingByRunnerType.manual`**,或 **`runnerTypeStats.manual.waiting`** | +| 系统 · 同上 | `runnerType === system` | **`waitingByRunnerType.system`**,或 **`runnerTypeStats.system.waiting`** | +| 手动 · **当日完成**任务数 | 完成时间落在「当日」(由后端按 `timezone` 划界)的手动任务数 | **`completedToday.manual`**(顶层字段,与 `waitingByRunnerType` 并列) | +| 系统 · 当日完成 | 同上 | **`completedToday.system`** | + +(不再使用 `todayCompletedByRunnerType`、`runnerTypeStats.*.completedToday`、`todayCompleted`、`executedToday` 等别名。) + +### 其它常用 SSE 字段(节选) + +| 字段 | 说明 | +| --- | --- | +| `date` | 服务端「今日」日期(YYYY-MM-DD,可与 timezone 对齐校验) | +| `totalTasks` | 今日任务量等总览 | +| `byStatus` | 各状态计数(含 `waiting`,与 `pending` 不同) | +| `byType` / `byRunnerType` | 按类型 / 按执行方式聚合 | +| `hourlyTrend` / `hourlyTrendByStatus` / `hourlyTrendByType` | 今日按小时序列 | +| `completedToday` | **当日完成**按 `manual` / `system` 分组(顶层对象,与 `waitingByRunnerType` 并列) | +| `pendingByRunnerType` | **pending(等待执行)** 按 `manual` / `system` 分组;与看板「手动等待操作数」(`waiting`)无关 | +| `runnerTypeStats` | 可按 runner 扩展;看板「手动等待操作数」仅读 **`waiting` / `waitingCount`**;**`executed`** 勿用作「当日完成」(当日完成仅 **`completedToday`**) | +| `todayDuration` | 今日耗时统计(均值等) | diff --git a/src/components/Task/doc/api.md b/src/components/Task/doc/api.md index fd25a7b..e4f30c4 100644 --- a/src/components/Task/doc/api.md +++ b/src/components/Task/doc/api.md @@ -77,3 +77,70 @@ - success: 成功 - failed: 失败 - canceled: 取消 + +--- + +## 任务统计 HTTP + +**路径**:`GET /api/v1/task/statistics`(与 `apis.task.statistics.getOverview` 对应) + +**Query** + +| 参数 | 说明 | +| --- | --- | +| `range` | 历史区间:`7d` \| `1m` \| `1y` | +| `timezone` | 浏览器 IANA 时区(如 `Asia/Shanghai`),与后端按日历日聚合的划界一致 | + +**响应**(节选,以实际后端为准) + +| 字段 | 说明 | +| --- | --- | +| `recentTrend` / `recentTrendByType` / `recentTrendByStatus` | 历史趋势 | +| `durationTrend` | 耗时趋势 | +| `hourlyCompletionTrend` | 历史「每小时趋势」:`date`、`hour`、`type`、`totalCompleted` 及按状态拆分字段等 | +| `byStatus` / `byType` / `byRunnerType` | 聚合计数 | + +--- + +## 任务实时统计 SSE + +**路径**:`GET /api/v1/task/statistics/sse`(与 `apis.task.statistics.sse` 对应) + +**客户端**:使用 `EventSource` 拉流;前端会在 URL 上追加 query(见 `useRealtimeStatisticsSSE`): + +| 参数 | 说明 | +| --- | --- | +| `interval` | 推送间隔(秒),如 `5` | +| `token` | 鉴权,如 `X-User-Token` | +| `timezone` | IANA 时区,与「今日」、当日完成数划界一致 | + +**每条事件的 `data`** + +为 JSON 字符串,解析后为对象(或 `{ "data": { ... } }`,前端会解包)。对象内为**增量或全量**字段均可;前端对 SSE 负载做浅合并。 + +### 看板「手动执行任务」指标(须与实现对齐) + +前端见 `Dashboard/RealtimeSection.js`:「等待操作」用 `pickNonNegativeInt` 读取 `waiting` 相关字段;「当日完成」**仅**读取 **`completedToday.manual`**。未下发时显示 `0`。 + +| 指标 | 含义 | 字段 | +| --- | --- | --- | +| 手动 · 当前为 **waiting**(等待操作)的任务数 | 快照:当前处于 `waiting` 状态且 `runnerType === manual` 的任务数量 | **`waitingByRunnerType.manual`**,或 **`runnerTypeStats.manual.waiting`** | +| 系统 · 同上 | `runnerType === system` | **`waitingByRunnerType.system`**,或 **`runnerTypeStats.system.waiting`** | +| 手动 · **当日完成**任务数 | 完成时间落在「当日」(由后端按 `timezone` 划界)的手动任务数 | **`completedToday.manual`**(顶层字段,与 `waitingByRunnerType` 并列) | +| 系统 · 当日完成 | 同上 | **`completedToday.system`** | + +(不再使用 `todayCompletedByRunnerType`、`runnerTypeStats.*.completedToday`、`todayCompleted`、`executedToday` 等别名。) + +### 其它常用 SSE 字段(节选) + +| 字段 | 说明 | +| --- | --- | +| `date` | 服务端「今日」日期(YYYY-MM-DD,可与 timezone 对齐校验) | +| `totalTasks` | 今日任务量等总览 | +| `byStatus` | 各状态计数(含 `waiting`,与 `pending` 不同) | +| `byType` / `byRunnerType` | 按类型 / 按执行方式聚合 | +| `hourlyTrend` / `hourlyTrendByStatus` / `hourlyTrendByType` | 今日按小时序列 | +| `completedToday` | **当日完成**按 `manual` / `system` 分组(顶层对象,与 `waitingByRunnerType` 并列) | +| `pendingByRunnerType` | **pending(等待执行)** 按 `manual` / `system` 分组;与看板「手动等待操作数」(`waiting`)无关 | +| `runnerTypeStats` | 可按 runner 扩展;看板「手动等待操作数」仅读 **`waiting` / `waitingCount`**;**`executed`** 勿用作「当日完成」(当日完成仅 **`completedToday`**) | +| `todayDuration` | 今日耗时统计(均值等) | diff --git a/src/components/Task/locale/en-US.js b/src/components/Task/locale/en-US.js index a485bfd..15fe5e6 100644 --- a/src/components/Task/locale/en-US.js +++ b/src/components/Task/locale/en-US.js @@ -76,13 +76,18 @@ const locale = { ManualExecutionStats: 'Manual Execution Tasks', ManualExecutionGoMyTaskTitle: 'Click here to open My Tasks', ManualExecutionTasks: 'Manual Task Count', - ManualPendingTasks: 'Manual Pending Count', - ManualExecutedTasks: 'Manual Executed Count', + ManualPendingTasks: 'Awaiting', + ManualExecutedTasks: 'Done today', + ManualPendingMaxWaitLabel: 'Max wait', + ManualCompletedTotalDurationLabel: 'Total time', 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', + DurationByTypeRunnerAll: 'All', + DurationByTypeRunnerManual: 'Manual', + DurationByTypeRunnerSystem: 'Auto', TotalTasks: 'Total Tasks', Range_7d: 'Last 7 Days', Range_1m: 'Last Month', @@ -94,6 +99,9 @@ const locale = { Evening: 'Evening', PeriodCompare: 'Period Compare', TodayHourlyTrend: 'Today Hourly Trend', + HistoricalHourlyTrendTitle: 'Hourly trend', + HourlyTrendModeByType: 'By task type', + HourlyTrendModeByStatus: 'By task status', StatusDistribution: 'Status Distribution', TaskTypeDistribution: 'Task Type Distribution' }; diff --git a/src/components/Task/locale/zh-CN.js b/src/components/Task/locale/zh-CN.js index 3614b0e..c02af7e 100644 --- a/src/components/Task/locale/zh-CN.js +++ b/src/components/Task/locale/zh-CN.js @@ -76,13 +76,18 @@ const locale = { ManualExecutionStats: '手动执行任务', ManualExecutionGoMyTaskTitle: '点击此处查看我的任务', ManualExecutionTasks: '手动执行数量', - ManualPendingTasks: '手动待执行数', - ManualExecutedTasks: '手动已执行数', + ManualPendingTasks: '等待操作', + ManualExecutedTasks: '当日完成', + ManualPendingMaxWaitLabel: '最长等待', + ManualCompletedTotalDurationLabel: '总耗时', ManualAvgExecutionTime: '手动平均执行时间', ManualAvgWaitingTime: '手动平均等待时间', ManualAvgTotalTime: '手动平均总耗时', ExecutionTimeStatistics: '执行时间统计', ExecutionTimeByTaskType: '按任务类型执行时间对比', + DurationByTypeRunnerAll: '全部', + DurationByTypeRunnerManual: '手动', + DurationByTypeRunnerSystem: '自动', TotalTasks: '任务总数', Range_7d: '近7天', Range_1m: '近1个月', @@ -94,6 +99,9 @@ const locale = { Evening: '晚间', PeriodCompare: '时段对比', TodayHourlyTrend: '今日每小时趋势', + HistoricalHourlyTrendTitle: '每小时趋势', + HourlyTrendModeByType: '按任务类型', + HourlyTrendModeByStatus: '按任务状态', StatusDistribution: '任务状态分布', TaskTypeDistribution: '任务类型分布' }; diff --git a/src/mockPreset/index.js b/src/mockPreset/index.js index 50e8132..6926e56 100644 --- a/src/mockPreset/index.js +++ b/src/mockPreset/index.js @@ -49,6 +49,7 @@ const apis = merge({}, getApis(), { const recentTrendByStatus = []; const recentTrendByType = []; const durationTrend = []; + const hourlyCompletionTrend = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); @@ -85,6 +86,22 @@ const apis = merge({}, getApis(), { 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 } } }); + ['import', 'export'].forEach(type => { + [9, 12, 15, 18].forEach(h => { + const ok = Math.floor(Math.random() * 3) + 1; + const bad = Math.random() < 0.15 ? 1 : 0; + hourlyCompletionTrend.push({ + date: dateStr, + hour: h, + type, + runnerType: type === 'import' ? 'system' : 'manual', + totalCompleted: ok + bad, + successCount: ok, + failedCount: bad, + canceledCount: 0 + }); + }); + }); } 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); @@ -103,7 +120,8 @@ const apis = merge({}, getApis(), { recentTrend, recentTrendByStatus, recentTrendByType, - durationTrend + durationTrend, + hourlyCompletionTrend }; } }, @@ -119,8 +137,9 @@ const apis = merge({}, getApis(), { const failed = Math.floor(Math.random() * 3); const running = Math.floor(Math.random() * 2); const pending = Math.floor(Math.random() * 3); + const waiting = Math.floor(Math.random() * 2); const canceled = Math.floor(Math.random() * 2); - const total = success + failed + running + pending + canceled; + const total = success + failed + running + pending + waiting + canceled; const exportCount = Math.floor(total * 0.6); const importCount = total - exportCount; hourlyTrend.push({ hour: h, count: total }); @@ -128,6 +147,7 @@ const apis = merge({}, getApis(), { 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 (waiting > 0) hourlyTrendByStatus.push({ hour: h, status: 'waiting', count: waiting }); 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 }); @@ -137,16 +157,45 @@ const apis = merge({}, getApis(), { 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 totalWaiting = hourlyTrendByStatus.filter(item => item.status === 'waiting').reduce((sum, item) => sum + item.count, 0); const totalCanceled = hourlyTrendByStatus.filter(item => item.status === 'canceled').reduce((sum, item) => sum + item.count, 0); + const manualWaiting = Math.max(0, Math.floor(totalWaiting * 0.5)); + const systemWaiting = Math.max(0, totalWaiting - manualWaiting); + const manualCompletedToday = Math.max(0, Math.floor(totalSuccess * 0.35)); + const systemCompletedToday = Math.max(0, totalSuccess - manualCompletedToday); + // 看板 KPI:waiting 用 waitingByRunnerType;当日完成仅用顶层 completedToday(见 Task doc/api.md) return { date: now.toISOString().split('T')[0], totalTasks, - byStatus: { success: totalSuccess, failed: totalFailed, running: totalRunning, pending: totalPending, canceled: totalCanceled }, + byStatus: { + success: totalSuccess, + failed: totalFailed, + running: totalRunning, + pending: totalPending, + waiting: totalWaiting, + 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, + waitingByRunnerType: { + manual: manualWaiting, + system: systemWaiting + }, + completedToday: { + manual: manualCompletedToday, + system: systemCompletedToday + }, + waitingQueueMaxWaitMsByRunnerType: { + manual: manualWaiting > 0 ? 180000 + Math.floor(Math.random() * 240000) : 0, + system: systemWaiting > 0 ? 120000 + Math.floor(Math.random() * 180000) : 0 + }, + completedTodayTotalDurationMsByRunnerType: { + manual: manualCompletedToday * (90000 + Math.floor(Math.random() * 120000)), + system: systemCompletedToday * 60000 + }, pendingByRunnerType: { manual: Math.max(0, Math.floor(totalPending * 0.45)), system: Math.max(0, totalPending - Math.floor(totalPending * 0.45)) @@ -160,12 +209,14 @@ const apis = merge({}, getApis(), { manual: { total: manualTotal, pending: manualPending, - executed: Math.max(0, manualTotal - manualPending) + executed: Math.max(0, manualTotal - manualPending), + waiting: manualWaiting }, system: { total: systemTotal, pending: systemPending, - executed: Math.max(0, systemTotal - systemPending) + executed: Math.max(0, systemTotal - systemPending), + waiting: systemWaiting } }; })(), @@ -176,7 +227,31 @@ const apis = merge({}, getApis(), { canceledCount: totalCanceled, avgWaitingTime: Math.floor(Math.random() * 3000) + 500, avgExecutionTime: Math.floor(Math.random() * 8000) + 2000, - avgTotalTime: Math.floor(Math.random() * 10000) + 3000 + avgTotalTime: Math.floor(Math.random() * 10000) + 3000, + byType: { + export: { + count: Math.max(1, Math.floor(totalSuccess * 0.35)), + avgWaitingTime: 800, + avgExecutionTime: 2200, + avgTotalTime: 3200 + }, + import: { + count: Math.max(1, Math.floor(totalSuccess * 0.25)), + avgWaitingTime: 1200, + avgExecutionTime: 3100, + avgTotalTime: 4500 + } + }, + byTypeByRunnerType: { + manual: { + export: { count: 2, avgWaitingTime: 900, avgExecutionTime: 2000, avgTotalTime: 3000 }, + import: { count: 1, avgWaitingTime: 1100, avgExecutionTime: 2800, avgTotalTime: 4000 } + }, + system: { + export: { count: 4, avgWaitingTime: 700, avgExecutionTime: 2400, avgTotalTime: 3400 }, + import: { count: 3, avgWaitingTime: 1300, avgExecutionTime: 3300, avgTotalTime: 4800 } + } + } } }; }