Files
Aether/frontend/src/components/charts/ScatterChart.vue
fawney19 33265b4b13 refactor(global-model): migrate model metadata to flexible config structure
将模型配置从多个固定字段(description, official_url, icon_url, default_supports_* 等)
统一为灵活的 config JSON 字段,提高扩展性。同时优化前端模型创建表单,支持从 models-dev
列表直接选择模型快速填充。

主要变更:
- 后端:模型表迁移,支持 config JSON 存储模型能力和元信息
- 前端:GlobalModelFormDialog 支持两种创建方式(列表选择/手动填写)
- API 类型更新,对齐新的数据结构
2025-12-16 12:21:21 +08:00

594 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="w-full h-full relative">
<canvas ref="chartRef" />
<div
v-if="crosshairStats"
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
>
<div class="font-medium text-yellow-400">
Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟
</div>
<!-- 单个 dataset 时显示简单统计 -->
<div
v-if="crosshairStats.datasets.length === 1"
class="mt-1"
>
<span class="text-green-400">{{ crosshairStats.datasets[0].belowCount }}</span> / {{ crosshairStats.datasets[0].totalCount }} 点在横线以下
<span class="ml-2 text-blue-400">({{ crosshairStats.datasets[0].belowPercent.toFixed(1) }}%)</span>
</div>
<!-- 多个 dataset 时按模型分别显示 -->
<div
v-else
class="mt-1 space-y-0.5"
>
<div
v-for="ds in crosshairStats.datasets"
:key="ds.label"
class="flex items-center gap-2"
>
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: ds.color }"
/>
<span class="text-gray-300 truncate max-w-[80px]">{{ ds.label }}:</span>
<span class="text-green-400">{{ ds.belowCount }}</span>/<span class="text-gray-400">{{ ds.totalCount }}</span>
<span class="text-blue-400">({{ ds.belowPercent.toFixed(0) }}%)</span>
</div>
<!-- 总计 -->
<div class="flex items-center gap-2 pt-1 border-t border-gray-600 mt-1">
<span class="text-gray-300">总计:</span>
<span class="text-green-400">{{ crosshairStats.totalBelowCount }}</span>/<span class="text-gray-400">{{ crosshairStats.totalCount }}</span>
<span class="text-blue-400">({{ crosshairStats.totalBelowPercent.toFixed(1) }}%)</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import {
Chart as ChartJS,
LinearScale,
PointElement,
ScatterController,
TimeScale,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions,
type Plugin,
type Scale
} from 'chart.js'
import 'chartjs-adapter-date-fns'
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined,
compressGaps: false,
gapThreshold: 60, // 默认 60 分钟以上的间隙会被压缩
compressedGapSize: 5 // 压缩后的间隙显示为 5 分钟
})
ChartJS.register(
LinearScale,
PointElement,
ScatterController,
TimeScale,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'scatter'>
options?: ChartOptions<'scatter'>
height?: number
compressGaps?: boolean
gapThreshold?: number // 间隙阈值(分钟)
compressedGapSize?: number // 压缩后显示大小(分钟)
}
interface DatasetStats {
label: string
color: string
belowCount: number
totalCount: number
belowPercent: number
}
interface CrosshairStats {
yValue: number
datasets: DatasetStats[]
totalBelowCount: number
totalCount: number
totalBelowPercent: number
}
interface GapInfo {
startX: number // 压缩后的 X 位置
originalStart: Date
originalEnd: Date
duration: number // 间隙时长(毫秒)
}
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'scatter'> | null = null
const crosshairY = ref<number | null>(null)
const gapInfoList = ref<GapInfo[]>([])
const crosshairStats = computed<CrosshairStats | null>(() => {
if (crosshairY.value === null || !props.data.datasets) return null
const datasetStats: DatasetStats[] = []
let totalBelowCount = 0
let totalCount = 0
for (const dataset of props.data.datasets) {
if (!dataset.data) continue
let belowCount = 0
let dsTotal = 0
for (const point of dataset.data) {
const p = point as { x: string; y: number }
if (typeof p.y === 'number') {
dsTotal++
totalCount++
if (p.y <= crosshairY.value) {
belowCount++
totalBelowCount++
}
}
}
if (dsTotal > 0) {
datasetStats.push({
label: dataset.label || 'Unknown',
color: (dataset.backgroundColor as string) || 'rgba(59, 130, 246, 0.7)',
belowCount,
totalCount: dsTotal,
belowPercent: (belowCount / dsTotal) * 100
})
}
}
if (totalCount === 0) return null
return {
yValue: crosshairY.value,
datasets: datasetStats,
totalBelowCount,
totalCount,
totalBelowPercent: (totalBelowCount / totalCount) * 100
}
})
// 自定义非线性 Y 轴转换函数
// 0-10 分钟占据 70% 的空间10-120 分钟占据 30% 的空间
const BREAKPOINT = 10 // 分界点10 分钟
const LOWER_RATIO = 0.7 // 0-10 分钟占 70% 空间
// 将实际值转换为显示值(用于绘图)
function toDisplayValue(realValue: number): number {
if (realValue <= BREAKPOINT) {
// 0-10 分钟线性映射到 0-70
return realValue * (LOWER_RATIO * 100 / BREAKPOINT)
} else {
// 10-120 分钟映射到 70-100
const upperRange = 120 - BREAKPOINT
const displayUpperRange = (1 - LOWER_RATIO) * 100
return LOWER_RATIO * 100 + ((realValue - BREAKPOINT) / upperRange) * displayUpperRange
}
}
// 将显示值转换回实际值(用于读取鼠标位置)
function toRealValue(displayValue: number): number {
const breakpointDisplay = LOWER_RATIO * 100
if (displayValue <= breakpointDisplay) {
return displayValue / (LOWER_RATIO * 100 / BREAKPOINT)
} else {
const upperRange = 120 - BREAKPOINT
const displayUpperRange = (1 - LOWER_RATIO) * 100
return BREAKPOINT + ((displayValue - breakpointDisplay) / displayUpperRange) * upperRange
}
}
// 压缩时间间隙的数据转换
function compressTimeGaps(data: ChartData<'scatter'>): {
data: ChartData<'scatter'>
gaps: GapInfo[]
timeMapping: Map<number, number> // 原始时间 -> 压缩后时间
} {
const gapThresholdMs = props.gapThreshold * 60 * 1000
const compressedGapSizeMs = props.compressedGapSize * 60 * 1000
// 收集所有数据点的时间戳并排序
const allTimestamps: number[] = []
for (const dataset of data.datasets) {
for (const point of dataset.data as Array<{ x: string; y: number }>) {
allTimestamps.push(new Date(point.x).getTime())
}
}
allTimestamps.sort((a, b) => a - b)
if (allTimestamps.length < 2) {
return { data, gaps: [], timeMapping: new Map() }
}
// 找出所有大间隙
const gaps: GapInfo[] = []
const timeMapping = new Map<number, number>()
let totalCompression = 0
for (let i = 1; i < allTimestamps.length; i++) {
const gap = allTimestamps[i] - allTimestamps[i - 1]
if (gap > gapThresholdMs) {
const compression = gap - compressedGapSizeMs
gaps.push({
startX: allTimestamps[i - 1] - totalCompression + compressedGapSizeMs / 2,
originalStart: new Date(allTimestamps[i - 1]),
originalEnd: new Date(allTimestamps[i]),
duration: gap
})
totalCompression += compression
}
}
// 构建时间映射
let currentCompression = 0
let gapIndex = 0
for (const ts of allTimestamps) {
// 检查是否跨过了某个间隙
while (gapIndex < gaps.length && ts >= gaps[gapIndex].originalEnd.getTime()) {
const gapDuration = gaps[gapIndex].originalEnd.getTime() - gaps[gapIndex].originalStart.getTime()
currentCompression += gapDuration - compressedGapSizeMs
gapIndex++
}
timeMapping.set(ts, ts - currentCompression)
}
// 更新间隙的 startX 为压缩后的坐标
gapIndex = 0
currentCompression = 0
for (const gap of gaps) {
gap.startX = gap.originalStart.getTime() - currentCompression + compressedGapSizeMs / 2
const gapDuration = gap.originalEnd.getTime() - gap.originalStart.getTime()
currentCompression += gapDuration - compressedGapSizeMs
}
// 转换数据
const compressedData: ChartData<'scatter'> = {
...data,
datasets: data.datasets.map(dataset => ({
...dataset,
data: (dataset.data as Array<{ x: string; y: number }>).map(point => {
const originalTs = new Date(point.x).getTime()
const compressedTs = timeMapping.get(originalTs) ?? originalTs
return {
...point,
x: new Date(compressedTs).toISOString(),
_originalX: point.x // 保存原始时间
}
})
}))
}
return { data: compressedData, gaps, timeMapping }
}
// 转换数据点的 Y 值
function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
return {
...data,
datasets: data.datasets.map(dataset => ({
...dataset,
data: (dataset.data as Array<{ x: string; y: number; _originalX?: string }>).map(point => ({
...point,
y: toDisplayValue(point.y),
_originalY: point.y // 保存原始值用于 tooltip
}))
}))
}
}
// 格式化时长
function formatDuration(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60))
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
return `${hours}h${minutes > 0 ? `${minutes}m` : ''}`
}
return `${minutes}m`
}
const defaultOptions: ChartOptions<'scatter'> = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest',
intersect: true
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
hour: 'HH:mm'
},
tooltipFormat: 'HH:mm'
},
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10
}
},
y: {
type: 'linear',
min: 0,
max: 100, // 显示值范围 0-100
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)',
// 自定义刻度值:在实际值 0, 2, 5, 10, 30, 60, 120 处显示
callback(this: Scale, tickValue: string | number) {
const displayVal = Number(tickValue)
// 只在特定的显示位置显示刻度
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
for (const target of targetTicks) {
const targetDisplay = toDisplayValue(target)
if (Math.abs(displayVal - targetDisplay) < 1) {
return `${target}`
}
}
return ''
},
stepSize: 5, // 显示值的步长
autoSkip: false
},
title: {
display: true,
text: '间隔 (分钟)',
color: 'rgb(107, 114, 128)'
},
afterBuildTicks(scale: Scale) {
// 在特定实际值处设置刻度
const targetTicks = [0, 2, 5, 10, 30, 60, 120]
scale.ticks = targetTicks.map(val => ({
value: toDisplayValue(val),
label: `${val}`
}))
}
}
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
titleColor: 'rgb(243, 244, 246)',
bodyColor: 'rgb(243, 244, 246)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1,
callbacks: {
title: (contexts) => {
if (contexts.length === 0) return ''
const point = contexts[0].raw as { x: string; _originalX?: string }
const timeStr = point._originalX || point.x
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
},
label: (context) => {
const point = context.raw as { x: string; y: number; _originalY?: number }
const realY = point._originalY ?? toRealValue(point.y)
return `间隔: ${realY.toFixed(1)} 分钟`
}
}
}
},
onHover: (event, _elements, chartInstance) => {
const canvas = chartInstance.canvas
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const mouseY = (event.native as MouseEvent)?.clientY
if (mouseY === undefined) {
crosshairY.value = null
return
}
const { chartArea, scales } = chartInstance
const yScale = scales.y
if (!chartArea || !yScale) return
const relativeY = mouseY - rect.top
if (relativeY < chartArea.top || relativeY > chartArea.bottom) {
crosshairY.value = null
} else {
const displayValue = yScale.getValueForPixel(relativeY)
// 转换回实际值
crosshairY.value = displayValue !== undefined ? toRealValue(displayValue) : null
}
chartInstance.draw()
}
}
// 修改 crosshairPlugin 使用显示值
const crosshairPluginWithTransform: Plugin<'scatter'> = {
id: 'crosshairLine',
afterDraw: (chartInstance) => {
if (crosshairY.value === null) return
const { ctx, chartArea, scales } = chartInstance
const yScale = scales.y
if (!yScale || !chartArea) return
// 将实际值转换为显示值再获取像素位置
const displayValue = toDisplayValue(crosshairY.value)
const yPixel = yScale.getPixelForValue(displayValue)
if (yPixel < chartArea.top || yPixel > chartArea.bottom) return
ctx.save()
ctx.beginPath()
ctx.moveTo(chartArea.left, yPixel)
ctx.lineTo(chartArea.right, yPixel)
ctx.strokeStyle = 'rgba(250, 204, 21, 0.8)'
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.stroke()
ctx.restore()
}
}
// 绘制间隙标记的插件
const gapMarkerPlugin: Plugin<'scatter'> = {
id: 'gapMarker',
afterDraw: (chartInstance) => {
if (gapInfoList.value.length === 0) return
const { ctx, chartArea, scales } = chartInstance
const xScale = scales.x
if (!xScale || !chartArea) return
ctx.save()
for (const gap of gapInfoList.value) {
const xPixel = xScale.getPixelForValue(gap.startX)
if (xPixel < chartArea.left || xPixel > chartArea.right) continue
// 绘制波浪线断点标记
const waveHeight = 6
const waveWidth = 8
const y1 = chartArea.top
const y2 = chartArea.bottom
ctx.beginPath()
ctx.strokeStyle = 'rgba(156, 163, 175, 0.5)'
ctx.lineWidth = 1.5
ctx.setLineDash([])
// 绘制波浪线
for (let y = y1; y < y2; y += waveHeight * 2) {
ctx.moveTo(xPixel - waveWidth / 2, y)
ctx.quadraticCurveTo(xPixel + waveWidth / 2, y + waveHeight, xPixel - waveWidth / 2, y + waveHeight * 2)
}
ctx.stroke()
// 绘制间隙时长标签
ctx.fillStyle = 'rgba(107, 114, 128, 0.8)'
ctx.font = '10px sans-serif'
ctx.textAlign = 'center'
const label = formatDuration(gap.duration)
ctx.fillText(label, xPixel, chartArea.top - 4)
}
ctx.restore()
}
}
function handleMouseLeave() {
crosshairY.value = null
if (chart) {
chart.draw()
}
}
function createChart() {
if (!chartRef.value) return
let dataToUse = props.data
gapInfoList.value = []
// 如果启用间隙压缩
if (props.compressGaps) {
const { data: compressedData, gaps } = compressTimeGaps(props.data)
dataToUse = compressedData
gapInfoList.value = gaps
}
// 转换数据
const transformedData = transformData(dataToUse)
chart = new ChartJS(chartRef.value, {
type: 'scatter',
data: transformedData,
options: {
...defaultOptions,
...props.options
},
plugins: [crosshairPluginWithTransform, gapMarkerPlugin]
})
chartRef.value.addEventListener('mouseleave', handleMouseLeave)
}
function updateChart() {
if (chart) {
let dataToUse = props.data
gapInfoList.value = []
if (props.compressGaps) {
const { data: compressedData, gaps } = compressTimeGaps(props.data)
dataToUse = compressedData
gapInfoList.value = gaps
}
chart.data = transformData(dataToUse)
chart.update('none')
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chartRef.value) {
chartRef.value.removeEventListener('mouseleave', handleMouseLeave)
}
if (chart) {
chart.destroy()
chart = null
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.compressGaps, () => {
if (chart) {
chart.destroy()
chart = null
}
createChart()
})
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
}
}, { deep: true })
</script>