v0.3.7: 恢复前端Vue源码 + 修复AdminDashboard 401根源
This commit is contained in:
382
source_clean/frontend-src/src/composables/useTrendChart.ts
Normal file
382
source_clean/frontend-src/src/composables/useTrendChart.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* useTrendChart
|
||||
*
|
||||
* Composable that manages a combined bar+line ECharts trend chart.
|
||||
* Features:
|
||||
* - Tree-shakeable ECharts import (core + only used chart types/components)
|
||||
* - Auto resize via ResizeObserver
|
||||
* - DataZoom slider for 30+ day ranges
|
||||
* - Day-over-day delta in tooltips
|
||||
* - Average markLine
|
||||
* - Period summary callback
|
||||
* - Proper cleanup on unmount
|
||||
*/
|
||||
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
|
||||
import { init, use, graphic } from 'echarts/core'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import {
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
DataZoomSliderComponent,
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
// Register only the features we actually use
|
||||
use([
|
||||
BarChart, LineChart,
|
||||
TooltipComponent, GridComponent, LegendComponent,
|
||||
DataZoomSliderComponent, MarkLineComponent,
|
||||
CanvasRenderer,
|
||||
])
|
||||
|
||||
const { LinearGradient } = graphic
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string
|
||||
searches: number
|
||||
saves: number
|
||||
searchDelta: number
|
||||
saveDelta: number
|
||||
}
|
||||
|
||||
export interface TrendSummary {
|
||||
totalSearches: number
|
||||
totalSaves: number
|
||||
avgSearches: number
|
||||
avgSaves: number
|
||||
peakDay: string
|
||||
peakSearches: number
|
||||
peakSaves: number
|
||||
dayCount: number
|
||||
}
|
||||
|
||||
export function useTrendChart(
|
||||
trendData: Ref<TrendDataPoint[]>,
|
||||
onSummary?: Ref<TrendSummary | null> | ((s: TrendSummary) => void),
|
||||
) {
|
||||
const chartRef = ref<HTMLDivElement | null>(null)
|
||||
let chartInstance: ECharts | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const BAR_WIDTH_PCT = '35%'
|
||||
const BAR_GAP_PCT = '30%'
|
||||
|
||||
function computeSummary(data: TrendDataPoint[]): TrendSummary {
|
||||
const totalSearches = data.reduce((s, d) => s + d.searches, 0)
|
||||
const totalSaves = data.reduce((s, d) => s + d.saves, 0)
|
||||
const n = data.length || 1
|
||||
let peakIdx = 0
|
||||
let peakVal = 0
|
||||
data.forEach((d, i) => {
|
||||
const v = d.searches + d.saves
|
||||
if (v > peakVal) { peakVal = v; peakIdx = i }
|
||||
})
|
||||
return {
|
||||
totalSearches,
|
||||
totalSaves,
|
||||
avgSearches: Math.round(totalSearches / n),
|
||||
avgSaves: Math.round(totalSaves / n),
|
||||
peakDay: data[peakIdx]?.date?.slice(5) || '—',
|
||||
peakSearches: data[peakIdx]?.searches || 0,
|
||||
peakSaves: data[peakIdx]?.saves || 0,
|
||||
dayCount: n,
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = chartRef.value
|
||||
const data = trendData.value
|
||||
if (!el) return
|
||||
|
||||
// Compute summary and emit
|
||||
const summary = computeSummary(data)
|
||||
if (typeof onSummary === 'function') {
|
||||
onSummary(summary)
|
||||
} else if (onSummary) {
|
||||
onSummary.value = summary
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!data.length) {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#909399;font-size:13px;">暂无使用数据</div>'
|
||||
return
|
||||
}
|
||||
|
||||
// Handle DOM element recreation (v-if remounts chart div when navigating away & back)
|
||||
if (!chartInstance || chartInstance.getDom() !== el) {
|
||||
if (chartInstance) chartInstance.dispose()
|
||||
chartInstance = init(el)
|
||||
initResize()
|
||||
}
|
||||
|
||||
const dates = data.map(d => d.date.slice(5)) // MM-DD
|
||||
const n = data.length
|
||||
const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1)
|
||||
const yMax = Math.ceil(maxCount * 1.35) || 1
|
||||
const avgSearches = Math.round(summary.totalSearches / n)
|
||||
const showDataZoom = n >= 30
|
||||
|
||||
const option: EChartsCoreOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
backgroundColor: 'rgba(255,255,255,0.97)',
|
||||
borderColor: '#e8e8e8',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: [10, 14],
|
||||
textStyle: { fontSize: 12, color: '#303133' },
|
||||
formatter: (params: any) => {
|
||||
const idx = params[0]?.dataIndex ?? 0
|
||||
const dateLabel = data[idx]?.date || ''
|
||||
const d = data[idx]
|
||||
const seen = new Set<string>()
|
||||
const items = params.filter((p: any) => {
|
||||
const key = p.seriesName
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
let html = `<div style="font-weight:700;font-size:13px;margin-bottom:8px;color:#1a1a2e">${dateLabel}</div>`
|
||||
items.forEach((p: any) => {
|
||||
const rawVal = p.value
|
||||
const val = Array.isArray(rawVal) ? rawVal[1] : rawVal
|
||||
const isBar = p.seriesType === 'bar'
|
||||
const isSearch = p.seriesName === '搜索'
|
||||
const delta = isSearch ? d?.searchDelta : d?.saveDelta
|
||||
const deltaStr = delta !== undefined && delta !== 0
|
||||
? `<span style="margin-left:6px;font-size:11px;color:${delta > 0 ? '#f56c6c' : '#67c23a'}">${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}</span>`
|
||||
: (delta === 0 ? '<span style="margin-left:6px;font-size:11px;color:#909399">→0</span>' : '')
|
||||
const icon = isBar
|
||||
? `<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color};vertical-align:middle"></span>`
|
||||
: `<span style="display:inline-block;width:14px;height:2px;background:${p.color};vertical-align:middle"></span>`
|
||||
html += `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">${icon}<span>${p.seriesName}:<b>${val}</b> 次${deltaStr}</span></div>`
|
||||
})
|
||||
return html
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['搜索', '保存'],
|
||||
bottom: showDataZoom ? 30 : 0,
|
||||
left: 'center',
|
||||
itemWidth: 14,
|
||||
itemHeight: 10,
|
||||
textStyle: { fontSize: 11, color: '#666' },
|
||||
},
|
||||
grid: {
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 28,
|
||||
bottom: showDataZoom ? 70 : 42,
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
fontSize: 11,
|
||||
color: '#909399',
|
||||
rotate: n > 15 ? 45 : 0,
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '次',
|
||||
nameTextStyle: { fontSize: 10, color: '#909399' },
|
||||
min: 0,
|
||||
max: yMax,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
fontSize: 10, color: '#909399' },
|
||||
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
|
||||
},
|
||||
series: [
|
||||
// Bar — 搜索
|
||||
{
|
||||
name: '搜索',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.searches),
|
||||
barWidth: BAR_WIDTH_PCT,
|
||||
barGap: BAR_GAP_PCT,
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#6366f1' },
|
||||
{ offset: 1, color: '#a5b4fc' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#4f46e5' },
|
||||
{ offset: 1, color: '#818cf8' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
},
|
||||
// Bar — 保存
|
||||
{
|
||||
name: '保存',
|
||||
type: 'bar',
|
||||
data: data.map(d => d.saves),
|
||||
barWidth: BAR_WIDTH_PCT,
|
||||
barGap: BAR_GAP_PCT,
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#10b981' },
|
||||
{ offset: 1, color: '#6ee7b7' },
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#059669' },
|
||||
{ offset: 1, color: '#34d399' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
},
|
||||
// Line — 搜索
|
||||
{
|
||||
name: '搜索',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: data.map(d => d.searches),
|
||||
lineStyle: { width: 2.5, color: '#4f46e5' },
|
||||
itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 },
|
||||
areaStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
|
||||
{ offset: 1, color: 'rgba(99,102,241,0.01)' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
connectNulls: true,
|
||||
animationDuration: 700,
|
||||
animationEasing: 'cubicOut',
|
||||
z: 3,
|
||||
markLine: avgSearches > 0 ? {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 },
|
||||
label: {
|
||||
formatter: `均 ${avgSearches}`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#6366f1',
|
||||
},
|
||||
data: [{ yAxis: avgSearches, name: '日均搜索' }],
|
||||
} : undefined,
|
||||
},
|
||||
// Line — 保存
|
||||
{
|
||||
name: '保存',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
data: data.map(d => d.saves),
|
||||
lineStyle: { width: 2.5, color: '#059669' },
|
||||
itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 },
|
||||
areaStyle: {
|
||||
color: new LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(16,185,129,0.12)' },
|
||||
{ offset: 1, color: 'rgba(16,185,129,0.01)' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
},
|
||||
connectNulls: true,
|
||||
animationDuration: 700,
|
||||
animationEasing: 'cubicOut',
|
||||
z: 3,
|
||||
markLine: avgSearches > 0 ? {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 },
|
||||
label: {
|
||||
formatter: `均 ${Math.round(summary.totalSaves / n)}`,
|
||||
position: 'insideEndTop',
|
||||
fontSize: 10,
|
||||
color: '#10b981',
|
||||
},
|
||||
data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }],
|
||||
} : undefined,
|
||||
},
|
||||
],
|
||||
// DataZoom slider for 30+ day views
|
||||
...(showDataZoom ? {
|
||||
dataZoom: [{
|
||||
type: 'slider',
|
||||
bottom: 6,
|
||||
height: 20,
|
||||
start: 0,
|
||||
end: 100,
|
||||
borderColor: '#e8e8e8',
|
||||
fillerColor: 'rgba(99,102,241,0.08)',
|
||||
handleStyle: { color: '#6366f1', borderColor: '#6366f1' },
|
||||
textStyle: { fontSize: 10, color: '#909399' },
|
||||
}],
|
||||
} : {}),
|
||||
}
|
||||
|
||||
chartInstance.setOption(option, true)
|
||||
}
|
||||
|
||||
// Auto-render when data changes
|
||||
watch(
|
||||
trendData,
|
||||
() => {
|
||||
nextTick(() => render())
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// Setup ResizeObserver for responsive chart
|
||||
function initResize() {
|
||||
if (!chartRef.value) return
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
chartInstance?.resize()
|
||||
})
|
||||
resizeObserver.observe(chartRef.value)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
chartInstance?.dispose()
|
||||
chartInstance = null
|
||||
})
|
||||
|
||||
return {
|
||||
chartRef,
|
||||
render,
|
||||
initResize,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user