Commit 00d85ce0 by James Yuan

feat: 历史曲线支持任意时间段查询并导出 Excel

查询支持自定义起止时间;导出接口输出所选区间全部采样为 .xls。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 95d68bd2
......@@ -319,21 +319,98 @@ export async function persistSnapshotTimeseries(snap: RealtimeSnapshot) {
export type TsSeriesPoint = { t: number; v: number }
export type TsSeries = { id: string; name?: string; points: TsSeriesPoint[] }
/** 优先读 15min 预聚合;无数据时回退原始点桶聚合 */
/** 最长可查跨度(天),与采集保留策略对齐,默认 90 */
export function maxHistorySpanDays() {
return Math.max(1, Number(process.env.TS_MAX_QUERY_DAYS || config.collectRetentionDays || 90))
}
export type TsTimeRange = {
from: Date
to: Date
/** 相对时长(小时),仅当未显式 from/to 时有意义 */
hours: number
}
/** 解析绝对时间段或相对 hours;非法则抛错 */
export function resolveTsTimeRange(opts: {
hours?: number
from?: string | Date | null
to?: string | Date | null
}): TsTimeRange {
const maxMs = maxHistorySpanDays() * 24 * 3600_000
const now = Date.now()
let fromMs: number
let toMs: number
const rawFrom = opts.from ? new Date(opts.from) : null
const rawTo = opts.to ? new Date(opts.to) : null
const hasFrom = rawFrom && Number.isFinite(rawFrom.getTime())
const hasTo = rawTo && Number.isFinite(rawTo.getTime())
if (hasFrom || hasTo) {
toMs = hasTo ? rawTo!.getTime() : now
fromMs = hasFrom ? rawFrom!.getTime() : toMs - 24 * 3600_000
if (fromMs > toMs) {
const tmp = fromMs
fromMs = toMs
toMs = tmp
}
} else {
const hours = Math.min(maxHistorySpanDays() * 24, Math.max(1, opts.hours ?? 24))
toMs = now
fromMs = toMs - hours * 3600_000
}
if (toMs - fromMs > maxMs) {
throw new Error(`查询跨度不能超过 ${maxHistorySpanDays()} 天`)
}
if (toMs - fromMs < 60_000) {
throw new Error('查询跨度至少 1 分钟')
}
return {
from: new Date(fromMs),
to: new Date(toMs),
hours: Math.max(1, Math.round((toMs - fromMs) / 3600_000)),
}
}
function mapRollupRows(
rollupRows: { device_id: string; bucket: Date; avg_eng: string | number }[],
): TsSeries[] {
const map = new Map<string, TsSeries>()
for (const r of rollupRows) {
let s = map.get(r.device_id)
if (!s) {
s = { id: r.device_id, points: [] }
map.set(r.device_id, s)
}
const v = Number(r.avg_eng)
if (!Number.isFinite(v)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
s.points.push({ t, v })
}
return [...map.values()]
}
/** 优先读预聚合;无数据时回退原始点桶聚合。支持绝对 from/to 或相对 hours。 */
export async function queryTsSeries(opts: {
source: string
metric: string
hours?: number
from?: string | Date | null
to?: string | Date | null
bucketMinutes?: number
deviceIds?: string[]
}): Promise<TsSeries[]> {
if (!isPgEnabled()) return []
const hours = Math.min(168, Math.max(1, opts.hours ?? 24))
const range = resolveTsTimeRange(opts)
const hours = range.hours
// 短窗口优先 1min 桶(更密);长窗口用请求粒度或 15min
const wantBucket = Math.min(60, Math.max(1, opts.bucketMinutes ?? 15))
const bucket = hours <= 24 && wantBucket <= 5 ? 1 : wantBucket === 1 ? 1 : wantBucket
const params: unknown[] = [opts.source, opts.metric, String(hours), bucket]
const params: unknown[] = [opts.source, opts.metric, range.from, range.to, bucket]
let deviceFilter = ''
if (opts.deviceIds?.length) {
params.push(opts.deviceIds)
......@@ -347,8 +424,8 @@ export async function queryTsSeries(opts: {
}>(
`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = $4
AND bucket >= NOW() - ($3::text || ' hours')::interval
WHERE source = $1 AND metric = $2 AND bucket_minutes = $5
AND bucket >= $3 AND bucket <= $4
${deviceFilter}
ORDER BY device_id, bucket`,
params,
......@@ -364,7 +441,7 @@ export async function queryTsSeries(opts: {
`SELECT device_id, bucket, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = 15
AND bucket >= NOW() - ($3::text || ' hours')::interval
AND bucket >= $3 AND bucket <= $4
${deviceFilter}
ORDER BY device_id, bucket`,
params,
......@@ -372,21 +449,7 @@ export async function queryTsSeries(opts: {
rollupRows = fallback.rows
}
if (rollupRows.length) {
const map = new Map<string, TsSeries>()
for (const r of rollupRows) {
let s = map.get(r.device_id)
if (!s) {
s = { id: r.device_id, points: [] }
map.set(r.device_id, s)
}
const v = Number(r.avg_eng)
if (!Number.isFinite(v)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
s.points.push({ t, v })
}
return [...map.values()]
}
if (rollupRows.length) return mapRollupRows(rollupRows)
// 回退:原始点按桶聚合
const { rows } = await query<{
......@@ -396,30 +459,134 @@ export async function queryTsSeries(opts: {
}>(
`SELECT
device_id,
to_timestamp(floor(extract(epoch FROM ts) / ($4 * 60)) * ($4 * 60)) AS bucket,
to_timestamp(floor(extract(epoch FROM ts) / ($5 * 60)) * ($5 * 60)) AS bucket,
AVG(eng) AS avg_eng
FROM ts_samples
WHERE source = $1 AND metric = $2
AND ts >= NOW() - ($3::text || ' hours')::interval
AND ts >= $3 AND ts <= $4
${deviceFilter}
GROUP BY device_id, bucket
ORDER BY device_id, bucket`,
params,
)
const map = new Map<string, TsSeries>()
for (const r of rows) {
let s = map.get(r.device_id)
if (!s) {
s = { id: r.device_id, points: [] }
map.set(r.device_id, s)
return mapRollupRows(rows)
}
export type TsExportRow = {
ts: Date
deviceId: string
source: string
metric: string
eng: number
raw: number | null
/** raw=原始采样;rollup=预聚合桶 */
kind: 'raw' | 'rollup'
}
const EXPORT_ROW_LIMIT = Math.max(10_000, Number(process.env.TS_EXPORT_MAX_ROWS || 500_000))
/** 导出所选区间全部数据:优先原始采样,不足则回退预聚合 */
export async function queryTsExportRows(opts: {
source: string
metric: string
hours?: number
from?: string | Date | null
to?: string | Date | null
deviceIds?: string[]
}): Promise<{ range: TsTimeRange; rows: TsExportRow[]; truncated: boolean }> {
if (!isPgEnabled()) {
return { range: resolveTsTimeRange(opts), rows: [], truncated: false }
}
const range = resolveTsTimeRange(opts)
const params: unknown[] = [opts.source, opts.metric, range.from, range.to, EXPORT_ROW_LIMIT + 1]
let deviceFilter = ''
if (opts.deviceIds?.length) {
params.push(opts.deviceIds)
deviceFilter = ` AND device_id = ANY($${params.length}::text[])`
}
const { rows: raw } = await query<{
ts: Date
device_id: string
source: string
metric: string
eng: string | number
raw: string | number | null
}>(
`SELECT ts, device_id, source, metric, eng, raw
FROM ts_samples
WHERE source = $1 AND metric = $2
AND ts >= $3 AND ts <= $4
${deviceFilter}
ORDER BY device_id, ts
LIMIT $5`,
params,
)
if (raw.length) {
const truncated = raw.length > EXPORT_ROW_LIMIT
const slice = truncated ? raw.slice(0, EXPORT_ROW_LIMIT) : raw
return {
range,
truncated,
rows: slice.map((r) => ({
ts: r.ts instanceof Date ? r.ts : new Date(r.ts),
deviceId: r.device_id,
source: r.source,
metric: r.metric,
eng: Number(r.eng),
raw: r.raw == null ? null : Number(r.raw),
kind: 'raw' as const,
})),
}
const v = Number(r.avg_eng)
if (!Number.isFinite(v)) continue
const t = r.bucket instanceof Date ? r.bucket.getTime() : new Date(r.bucket).getTime()
s.points.push({ t, v })
}
return [...map.values()]
// 原始点已清理:导出最密预聚合(1min → 15min)
for (const bm of [1, 15]) {
const rollParams: unknown[] = [opts.source, opts.metric, range.from, range.to, bm]
let rollDeviceFilter = ''
if (opts.deviceIds?.length) {
rollParams.push(opts.deviceIds)
rollDeviceFilter = ` AND device_id = ANY($${rollParams.length}::text[])`
}
rollParams.push(EXPORT_ROW_LIMIT + 1)
const limitIdx = rollParams.length
const { rows: roll } = await query<{
bucket: Date
device_id: string
source: string
metric: string
avg_eng: string | number
}>(
`SELECT bucket, device_id, source, metric, avg_eng
FROM ts_rollups
WHERE source = $1 AND metric = $2 AND bucket_minutes = $5
AND bucket >= $3 AND bucket <= $4
${rollDeviceFilter}
ORDER BY device_id, bucket
LIMIT $${limitIdx}`,
rollParams,
)
if (!roll.length) continue
const truncated = roll.length > EXPORT_ROW_LIMIT
const slice = truncated ? roll.slice(0, EXPORT_ROW_LIMIT) : roll
return {
range,
truncated,
rows: slice.map((r) => ({
ts: r.bucket instanceof Date ? r.bucket : new Date(r.bucket),
deviceId: r.device_id,
source: r.source,
metric: r.metric,
eng: Number(r.avg_eng),
raw: null,
kind: 'rollup' as const,
})),
}
}
return { range, rows: [], truncated: false }
}
export async function pruneTimeseriesData() {
......
/** 生成 Excel 可打开的 SpreadsheetML(.xls),零依赖 */
function xmlEscape(s: string) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export type ExcelCell = string | number | null | undefined
function cellXml(v: ExcelCell): string {
if (v == null || v === '') return '<Cell></Cell>'
if (typeof v === 'number' && Number.isFinite(v)) {
return `<Cell><Data ss:Type="Number">${v}</Data></Cell>`
}
return `<Cell><Data ss:Type="String">${xmlEscape(String(v))}</Data></Cell>`
}
/** 单表导出为 .xls(SpreadsheetML)Buffer */
export function buildExcelXml(opts: {
sheetName?: string
headers: string[]
rows: ExcelCell[][]
}): Buffer {
const sheet = xmlEscape((opts.sheetName || 'Sheet1').slice(0, 31))
const headerRow = `<Row>${opts.headers.map((h) => cellXml(h)).join('')}</Row>`
const body = opts.rows.map((r) => `<Row>${r.map((c) => cellXml(c)).join('')}</Row>`).join('')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<Worksheet ss:Name="${sheet}">
<Table>
${headerRow}
${body}
</Table>
</Worksheet>
</Workbook>`
return Buffer.from(xml, 'utf8')
}
export function formatExportTs(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
import { listDevices } from '../devices/store.js'
import {
listTsCatalog,
maxHistorySpanDays,
queryTsExportRows,
queryTsSeries,
resolveTsTimeRange,
type TsCatalogItem,
type TsSeries,
} from '../db/timeseries.js'
import { buildExcelXml, formatExportTs } from './excelXml.js'
const METRIC_LABEL: Record<string, string> = {
temp: '温度',
......@@ -68,6 +72,8 @@ export async function getHistorySeries(opts: {
source: string
metric: string
hours?: number
from?: string | null
to?: string | null
bucketMinutes?: number
deviceId?: string
}): Promise<{
......@@ -76,15 +82,19 @@ export async function getHistorySeries(opts: {
sourceLabel: string
metricLabel: string
hours: number
from: string
to: string
bucketMinutes: number
maxSpanDays: number
series: TsSeries[]
}> {
const hours = opts.hours ?? 24
const range = resolveTsTimeRange({ hours: opts.hours, from: opts.from, to: opts.to })
const bucketMinutes = opts.bucketMinutes ?? 15
const raw = await queryTsSeries({
source: opts.source,
metric: opts.metric,
hours,
from: range.from,
to: range.to,
bucketMinutes,
deviceIds: opts.deviceId ? [opts.deviceId] : undefined,
})
......@@ -94,8 +104,64 @@ export async function getHistorySeries(opts: {
metric: opts.metric,
sourceLabel: sourceLabel(opts.source),
metricLabel: metricLabel(opts.metric),
hours,
hours: range.hours,
from: range.from.toISOString(),
to: range.to.toISOString(),
bucketMinutes,
maxSpanDays: maxHistorySpanDays(),
series,
}
}
/** 导出区间内全部采样(优先原始点)为 Excel .xls Buffer */
export async function exportHistoryExcel(opts: {
source: string
metric: string
hours?: number
from?: string | null
to?: string | null
deviceId?: string
}): Promise<{
filename: string
buffer: Buffer
rowCount: number
truncated: boolean
kind: 'raw' | 'rollup' | 'empty'
}> {
const { range, rows, truncated } = await queryTsExportRows({
source: opts.source,
metric: opts.metric,
hours: opts.hours,
from: opts.from,
to: opts.to,
deviceIds: opts.deviceId ? [opts.deviceId] : undefined,
})
const names = await deviceNameMap()
const srcLabel = sourceLabel(opts.source)
const metLabel = metricLabel(opts.metric)
const excelRows = rows.map((r) => [
formatExportTs(r.ts instanceof Date ? r.ts : new Date(r.ts)),
r.deviceId,
names.get(r.deviceId) || r.deviceId,
srcLabel,
metLabel,
Number.isFinite(r.eng) ? r.eng : '',
r.raw == null || !Number.isFinite(r.raw) ? '' : r.raw,
r.kind === 'raw' ? '原始采样' : '预聚合',
])
const buffer = buildExcelXml({
sheetName: metLabel.slice(0, 31) || 'history',
headers: ['时间', '设备ID', '设备名称', '类别', '指标', '工程值', '原始值', '数据类型'],
rows: excelRows,
})
const fromTag = formatExportTs(range.from).replace(/[: ]/g, '')
const toTag = formatExportTs(range.to).replace(/[: ]/g, '')
const filename = `history_${opts.source}_${opts.metric}_${fromTag}_${toTag}.xls`
return {
filename,
buffer,
rowCount: rows.length,
truncated,
kind: rows.length ? rows[0].kind : 'empty',
}
}
import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { queryFridgeTempSeries } from '../db/collect.js'
import { getHistoryCatalog, getHistorySeries } from '../realtime/history.js'
import { exportHistoryExcel, getHistoryCatalog, getHistorySeries } from '../realtime/history.js'
import { buildScreenDashboard } from '../realtime/screen.js'
import { applyWriteLog, getSnapshot } from '../realtime/store.js'
......@@ -34,7 +34,7 @@ realtimeRouter.get('/history/catalog', async (_req, res) => {
}
})
/** 通用历史曲线:source + metric(可选 deviceId) */
/** 通用历史曲线:source + metric(可选 deviceId);支持 hours 或 from/to */
realtimeRouter.get('/history', async (req, res) => {
const source = String(req.query.source || '')
const metric = String(req.query.metric || '')
......@@ -42,15 +42,56 @@ realtimeRouter.get('/history', async (req, res) => {
res.status(400).json({ message: '缺少 source / metric' })
return
}
const hours = Number(req.query.hours) || 24
const hours = req.query.hours != null && req.query.hours !== '' ? Number(req.query.hours) : undefined
const from = req.query.from ? String(req.query.from) : undefined
const to = req.query.to ? String(req.query.to) : undefined
const bucketMinutes = Number(req.query.bucketMinutes) || 15
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined
try {
const data = await getHistorySeries({ source, metric, hours, bucketMinutes, deviceId })
const data = await getHistorySeries({ source, metric, hours, from, to, bucketMinutes, deviceId })
res.json(data)
} catch (e) {
const msg = e instanceof Error ? e.message : '历史曲线查询失败'
console.error('[realtime] history query failed', e)
res.status(500).json({ message: '历史曲线查询失败', series: [] })
res.status(400).json({ message: msg, series: [] })
}
})
/** 导出所选区间全部数据为 Excel(.xls,SpreadsheetML) */
realtimeRouter.get('/history/export', async (req, res) => {
const source = String(req.query.source || '')
const metric = String(req.query.metric || '')
if (!source || !metric) {
res.status(400).json({ message: '缺少 source / metric' })
return
}
const hours = req.query.hours != null && req.query.hours !== '' ? Number(req.query.hours) : undefined
const from = req.query.from ? String(req.query.from) : undefined
const to = req.query.to ? String(req.query.to) : undefined
const deviceId = req.query.deviceId ? String(req.query.deviceId) : undefined
try {
const { filename, buffer, rowCount, truncated, kind } = await exportHistoryExcel({
source,
metric,
hours,
from,
to,
deviceId,
})
if (!rowCount) {
res.status(404).json({ message: '所选区间无数据可导出' })
return
}
res.setHeader('Content-Type', 'application/vnd.ms-excel; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
res.setHeader('X-Export-Rows', String(rowCount))
res.setHeader('X-Export-Truncated', truncated ? '1' : '0')
res.setHeader('X-Export-Kind', kind)
res.send(buffer)
} catch (e) {
const msg = e instanceof Error ? e.message : '历史数据导出失败'
console.error('[realtime] history export failed', e)
res.status(400).json({ message: msg })
}
})
......
......@@ -215,16 +215,24 @@ export default {
historyTitle: 'History & charts',
historyBadge: 'Pre-aggregated · {summary}',
historySummary: '{n} series · {pts} points · last {hours}h',
historySummaryRange: '{n} series · {pts} points · {from} ~ {to}',
historyCat: 'Category',
historyMetric: 'Metric',
historyDevice: 'Device',
historyAllDevices: 'All devices',
historyRangeMode: 'Time range',
historyRangePreset: 'Quick duration',
historyRangeCustom: 'Custom range',
historyFrom: 'From',
historyTo: 'To',
historyRangeRequired: 'Please select start and end time',
historyDuration: 'Duration',
historyBucket: 'Bucket',
historyH6: 'Last 6 hours',
historyH24: 'Last 24 hours',
historyH72: 'Last 3 days',
historyH168: 'Last 7 days',
historyH720: 'Last 30 days',
historyM5: '5 minutes',
historyM15: '15 minutes',
historyM60: '1 hour',
......@@ -237,6 +245,10 @@ export default {
historyNoData: 'No data',
historyCatalogFail: 'Failed to load catalog',
historySeriesFail: 'Failed to load series',
historyExport: 'Export Excel',
historyExporting: 'Exporting…',
historyExportFail: 'Export failed',
historyExportTruncated: 'Too many rows; export truncated to the limit',
},
video: {
......
......@@ -215,16 +215,24 @@ export default {
historyTitle: 'Histórico e gráficos',
historyBadge: 'Pré-agregado · {summary}',
historySummary: '{n} séries · {pts} pontos · últimas {hours}h',
historySummaryRange: '{n} séries · {pts} pontos · {from} ~ {to}',
historyCat: 'Categoria',
historyMetric: 'Métrica',
historyDevice: 'Equipamento',
historyAllDevices: 'Todos os equipamentos',
historyRangeMode: 'Intervalo',
historyRangePreset: 'Duração rápida',
historyRangeCustom: 'Intervalo personalizado',
historyFrom: 'Início',
historyTo: 'Fim',
historyRangeRequired: 'Selecione data/hora de início e fim',
historyDuration: 'Duração',
historyBucket: 'Granularidade',
historyH6: 'Últimas 6 horas',
historyH24: 'Últimas 24 horas',
historyH72: 'Últimos 3 dias',
historyH168: 'Últimos 7 dias',
historyH720: 'Últimos 30 dias',
historyM5: '5 minutos',
historyM15: '15 minutos',
historyM60: '1 hora',
......@@ -237,6 +245,10 @@ export default {
historyNoData: 'Sem dados',
historyCatalogFail: 'Falha ao carregar o catálogo',
historySeriesFail: 'Falha ao carregar as séries',
historyExport: 'Exportar Excel',
historyExporting: 'A exportar…',
historyExportFail: 'Falha na exportação',
historyExportTruncated: 'Demasiadas linhas; exportação truncada no limite',
},
video: {
......
......@@ -215,16 +215,24 @@ export default {
historyTitle: '历史数据与曲线',
historyBadge: '时序预聚合 · {summary}',
historySummary: '{n} 条序列 · {pts} 个聚合点 · 近 {hours}h',
historySummaryRange: '{n} 条序列 · {pts} 个聚合点 · {from} ~ {to}',
historyCat: '类别',
historyMetric: '指标',
historyDevice: '设备',
historyAllDevices: '全部设备',
historyRangeMode: '时间范围',
historyRangePreset: '快捷时长',
historyRangeCustom: '自定义区间',
historyFrom: '开始时间',
historyTo: '结束时间',
historyRangeRequired: '请选择开始与结束时间',
historyDuration: '时长',
historyBucket: '粒度',
historyH6: '近 6 小时',
historyH24: '近 24 小时',
historyH72: '近 3 天',
historyH168: '近 7 天',
historyH720: '近 30 天',
historyM5: '5 分钟',
historyM15: '15 分钟',
historyM60: '1 小时',
......@@ -237,6 +245,10 @@ export default {
historyNoData: '无数据',
historyCatalogFail: '目录加载失败',
historySeriesFail: '曲线加载失败',
historyExport: '导出 Excel',
historyExporting: '导出中…',
historyExportFail: '导出失败',
historyExportTruncated: '数据量过大,已截断至上限行数导出',
},
video: {
......
......@@ -215,16 +215,24 @@ export default {
historyTitle: '歷史資料與曲線',
historyBadge: '時序預聚合 · {summary}',
historySummary: '{n} 條序列 · {pts} 個聚合點 · 近 {hours}h',
historySummaryRange: '{n} 條序列 · {pts} 個聚合點 · {from} ~ {to}',
historyCat: '類別',
historyMetric: '指標',
historyDevice: '設備',
historyAllDevices: '全部設備',
historyRangeMode: '時間範圍',
historyRangePreset: '快捷時長',
historyRangeCustom: '自訂區間',
historyFrom: '開始時間',
historyTo: '結束時間',
historyRangeRequired: '請選擇開始與結束時間',
historyDuration: '時長',
historyBucket: '粒度',
historyH6: '近 6 小時',
historyH24: '近 24 小時',
historyH72: '近 3 天',
historyH168: '近 7 天',
historyH720: '近 30 天',
historyM5: '5 分鐘',
historyM15: '15 分鐘',
historyM60: '1 小時',
......@@ -237,6 +245,10 @@ export default {
historyNoData: '無資料',
historyCatalogFail: '目錄載入失敗',
historySeriesFail: '曲線載入失敗',
historyExport: '匯出 Excel',
historyExporting: '匯出中…',
historyExportFail: '匯出失敗',
historyExportTruncated: '資料量過大,已截斷至上限列數匯出',
},
video: {
......
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api/http'
import { api, ApiError } from '@/api/http'
import EChart from '@/components/charts/EChart.vue'
import { multiLineOption } from '@/utils/historyChart'
......@@ -23,13 +23,52 @@ type HistSeries = { id: string; name?: string; points: { t: number; v: number }[
const catalog = ref<CatalogItem[]>([])
const loading = ref(false)
const exporting = ref(false)
const error = ref('')
const hours = ref(24)
/** preset=近 N 小时;custom=任意起止时间 */
const rangeMode = ref<'preset' | 'custom'>('preset')
const rangeFrom = ref('')
const rangeTo = ref('')
const bucketMinutes = ref(15)
const source = ref('')
const metric = ref('')
const deviceId = ref('')
const series = ref<HistSeries[]>([])
const queryFrom = ref('')
const queryTo = ref('')
function pad2(n: number) {
return String(n).padStart(2, '0')
}
/** datetime-local 本地字符串 */
function toLocalInput(d: Date) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function initCustomRange(h = hours.value) {
const to = new Date()
const from = new Date(to.getTime() - Math.max(1, h) * 3600_000)
rangeFrom.value = toLocalInput(from)
rangeTo.value = toLocalInput(to)
}
function buildQuery(): URLSearchParams {
const q = new URLSearchParams({
source: source.value,
metric: metric.value,
bucketMinutes: String(bucketMinutes.value),
})
if (deviceId.value) q.set('deviceId', deviceId.value)
if (rangeMode.value === 'custom' && rangeFrom.value && rangeTo.value) {
q.set('from', new Date(rangeFrom.value).toISOString())
q.set('to', new Date(rangeTo.value).toISOString())
} else {
q.set('hours', String(hours.value))
}
return q
}
const sources = computed(() => {
const map = new Map<string, string>()
......@@ -62,6 +101,18 @@ const chartOption = computed(() =>
const summary = computed(() => {
const n = series.value.length
const pts = series.value.reduce((s, x) => s + x.points.length, 0)
if (queryFrom.value && queryTo.value) {
const from = new Date(queryFrom.value)
const to = new Date(queryTo.value)
const fmt = (d: Date) =>
`${d.getMonth() + 1}/${d.getDate()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
return t('pages.historySummaryRange', {
n,
pts,
from: fmt(from),
to: fmt(to),
})
}
return t('pages.historySummary', { n, pts, hours: hours.value })
})
......@@ -87,18 +138,24 @@ async function loadSeries() {
series.value = []
return
}
if (rangeMode.value === 'custom' && (!rangeFrom.value || !rangeTo.value)) {
error.value = t('pages.historyRangeRequired')
series.value = []
return
}
loading.value = true
error.value = ''
try {
const q = new URLSearchParams({
source: source.value,
metric: metric.value,
hours: String(hours.value),
bucketMinutes: String(bucketMinutes.value),
})
if (deviceId.value) q.set('deviceId', deviceId.value)
const res = await api<{ series: HistSeries[] }>(`/api/realtime/history?${q}`)
const res = await api<{
series: HistSeries[]
from?: string
to?: string
hours?: number
}>(`/api/realtime/history?${buildQuery()}`)
series.value = res.series || []
queryFrom.value = res.from || ''
queryTo.value = res.to || ''
if (res.hours) hours.value = res.hours
} catch (e) {
error.value = e instanceof Error ? e.message : t('pages.historySeriesFail')
series.value = []
......@@ -107,15 +164,53 @@ async function loadSeries() {
}
}
async function exportExcel() {
if (!source.value || !metric.value) return
if (rangeMode.value === 'custom' && (!rangeFrom.value || !rangeTo.value)) {
error.value = t('pages.historyRangeRequired')
return
}
exporting.value = true
error.value = ''
try {
const res = await fetch(`/api/realtime/history/export?${buildQuery()}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new ApiError(res.status, data.message || t('pages.historyExportFail'))
}
const blob = await res.blob()
const cd = res.headers.get('Content-Disposition') || ''
const m = /filename="?([^";]+)"?/.exec(cd)
const filename = m?.[1] ? decodeURIComponent(m[1]) : `history_${source.value}_${metric.value}.xls`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
const truncated = res.headers.get('X-Export-Truncated') === '1'
if (truncated) error.value = t('pages.historyExportTruncated')
} catch (e) {
error.value = e instanceof Error ? e.message : t('pages.historyExportFail')
} finally {
exporting.value = false
}
}
watch(rangeMode, (mode) => {
if (mode === 'custom' && (!rangeFrom.value || !rangeTo.value)) initCustomRange()
})
watch([source, metric], () => {
deviceId.value = ''
})
watch([source, metric, deviceId, hours, bucketMinutes], () => {
watch([source, metric, deviceId, hours, bucketMinutes, rangeMode, rangeFrom, rangeTo], () => {
void loadSeries()
})
onMounted(async () => {
initCustomRange(24)
await loadCatalog()
await loadSeries()
})
......@@ -152,14 +247,32 @@ onMounted(async () => {
</select>
</label>
<label>
{{ t('pages.historyRangeMode') }}
<select v-model="rangeMode">
<option value="preset">{{ t('pages.historyRangePreset') }}</option>
<option value="custom">{{ t('pages.historyRangeCustom') }}</option>
</select>
</label>
<label v-if="rangeMode === 'preset'">
{{ t('pages.historyDuration') }}
<select v-model.number="hours">
<option :value="6">{{ t('pages.historyH6') }}</option>
<option :value="24">{{ t('pages.historyH24') }}</option>
<option :value="72">{{ t('pages.historyH72') }}</option>
<option :value="168">{{ t('pages.historyH168') }}</option>
<option :value="720">{{ t('pages.historyH720') }}</option>
</select>
</label>
<template v-else>
<label>
{{ t('pages.historyFrom') }}
<input v-model="rangeFrom" type="datetime-local" step="60" />
</label>
<label>
{{ t('pages.historyTo') }}
<input v-model="rangeTo" type="datetime-local" step="60" />
</label>
</template>
<label>
{{ t('pages.historyBucket') }}
<select v-model.number="bucketMinutes">
......@@ -171,6 +284,14 @@ onMounted(async () => {
<button type="button" class="btn" :disabled="loading" @click="loadSeries">
{{ t('common.refresh') }}
</button>
<button
type="button"
class="btn btn-export"
:disabled="loading || exporting || !source || !metric"
@click="exportExcel"
>
{{ exporting ? t('pages.historyExporting') : t('pages.historyExport') }}
</button>
</div>
<div v-if="error" class="err">{{ error }}</div>
......@@ -250,7 +371,8 @@ onMounted(async () => {
font-size: 11px;
color: var(--label, #8eb0c4);
}
.filters select {
.filters select,
.filters input[type='datetime-local'] {
min-width: 120px;
height: 30px;
border-radius: 4px;
......@@ -258,6 +380,10 @@ onMounted(async () => {
background: rgba(4, 20, 40, 0.85);
color: #e8f6ff;
padding: 0 8px;
color-scheme: dark;
}
.filters input[type='datetime-local'] {
min-width: 190px;
}
.btn {
height: 30px;
......@@ -268,6 +394,10 @@ onMounted(async () => {
color: #dff8ff;
cursor: pointer;
}
.btn-export {
border-color: rgba(80, 220, 160, 0.45);
background: rgba(20, 120, 80, 0.4);
}
.btn:disabled {
opacity: 0.5;
cursor: default;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment