v0.3.7: 恢复前端Vue源码 + 修复AdminDashboard 401根源

This commit is contained in:
2026-05-17 13:26:36 +08:00
parent 09be4c307e
commit 8cd4dabb60
178 changed files with 20570 additions and 5 deletions

View 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,
}
}