Files
stock/serve/frontend/src/components/stock-analysis/AnalysisKChart.tsx
T
2026-07-04 16:59:25 +08:00

548 lines
24 KiB
TypeScript
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.
import { useEffect, useRef, useMemo, useState } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { KlineRow, LevelSeries } from '@/lib/api'
/**
* 个股分析专用日 K 图表。
*
* 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
* - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
* - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
* 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
*
* 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
* - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
* - ranges: 区间高亮(事件区间 → markArea)
* - onDateClick: 点击日期回调(后续接消息面时间轴)
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
*/
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
const THEME = {
bull: '#C74040',
bear: '#2D9B65',
text: '#A1A1AA',
grid: 'rgba(255,255,255,0.04)',
volUp: 'rgba(240,68,56,0.5)',
volDown: 'rgba(18,183,106,0.5)',
}
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
export type LevelType = 'sr' | 'pivot' | 'extreme' | 'boll' | 'keltner_s' | 'keltner_m' | 'keltner_l' | 'atr_stop' | 'gap' | 'fib' | 'round'
export interface PriceLevel {
value: number
label: string
type: LevelType
side: 'resistance' | 'support' | 'neutral'
strength?: 'strong' | 'medium' | 'weak'
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
rank?: number
}
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙(成交密集区,价量驱动)
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
{ key: 'boll', label: '布林带', color: '#F97316' }, // 橙(MA20±2σ 曲线)
{ key: 'keltner_s',label: 'Keltner短期', color: '#06B6D4' }, // 青(MA20±2ATR 曲线)
{ key: 'keltner_m',label: 'Keltner中期', color: '#22D3EE' }, // 浅青(MA60±2.5ATR 曲线)
{ key: 'keltner_l',label: 'Keltner长期', color: '#67E8F9' }, // 更浅青(MA120±3ATR 曲线)
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
]
// 通道曲线元数据(单一数据源):供 buildOption 画线 + 右侧面板取最新值共用。
// alignedKey: alignedSeries 中的 key(由 series.boll/keltner/atr 对齐而来)
// group: 属于哪个价位开关组(开关该组即开关这条曲线)
// endLabel: 右侧端点标签(显示最新值的文字)
const CURVE_DEFS: { alignedKey: string; group: LevelType; endLabel: string; color: string; dashed?: boolean }[] = [
{ alignedKey: 'boll_upper', group: 'boll', endLabel: '布林上轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_lower', group: 'boll', endLabel: '布林下轨', color: '#F97316', dashed: true },
{ alignedKey: 'boll_mid', group: 'boll', endLabel: '布林中轨', color: '#FB923C', dashed: false },
{ alignedKey: 'keltner_s_upper',group: 'keltner_s', endLabel: 'Keltner短上', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_s_lower',group: 'keltner_s', endLabel: 'Keltner短下', color: '#06B6D4', dashed: true },
{ alignedKey: 'keltner_m_upper',group: 'keltner_m', endLabel: 'Keltner中上', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_m_lower',group: 'keltner_m', endLabel: 'Keltner中下', color: '#22D3EE', dashed: true },
{ alignedKey: 'keltner_l_upper',group: 'keltner_l', endLabel: 'Keltner长上', color: '#67E8F9', dashed: true },
{ alignedKey: 'keltner_l_lower',group: 'keltner_l', endLabel: 'Keltner长下', color: '#67E8F9', dashed: true },
{ alignedKey: 'atr_stop', group: 'atr_stop', endLabel: 'ATR止损', color: '#EF4444', dashed: true },
{ alignedKey: 'atr_tp', group: 'atr_stop', endLabel: 'ATR止盈', color: '#F87171', dashed: true },
]
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
export interface ChartMarker {
date: string
label?: string
color?: string
above?: boolean
}
export interface ChartRange {
start: string
end: string
label?: string
color?: string
}
interface Props {
rows: KlineRow[]
levels?: Record<LevelType, PriceLevel[]>
/** 带状曲线指标(布林带/Keltner/ATR)的每日序列 —— 画成跟随时间漂移的曲线 */
series?: LevelSeries
/** series 数据对应的日期数组(与 series 各数组对齐) */
seriesDates?: string[]
/** 默认开启的价位组 */
defaultLevelTypes?: LevelType[]
/** 预留:新闻/暴雷/利好日期标记 */
markers?: ChartMarker[]
/** 预留:事件区间高亮 */
ranges?: ChartRange[]
/** 预留:点击某根 K 线 */
onDateClick?: (date: string) => void
height?: number
className?: string
}
const VOL_PANE_H = 90
export function AnalysisKChart({
rows,
levels,
series,
seriesDates,
defaultLevelTypes = ['sr', 'pivot', 'keltner_s'],
markers,
ranges,
onDateClick,
height = 460,
className,
}: Props) {
const chartRef = useRef<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
// 数据预处理 + 带状曲线序列对齐(后端 series 的日期范围可能与 rows 不同,需映射)
const { dates, candle, vols, dateIndex, zoomStart, alignedSeries } = useMemo(() => {
const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
const candle = rows.map(r => [r.open, r.close, r.low, r.high])
const vols = rows.map(r => ({
value: r.volume ?? 0,
itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
}))
const dateIndex = new Map(dates.map((d, i) => [d, i]))
// 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
const showBars = 120
const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
// 把后端 series(按 seriesDates 对齐)映射到前端 rows 的 dates 顺序
const alignedSeries: Record<string, (number | null)[]> = {}
if (series && seriesDates && seriesDates.length > 0) {
// 构建 seriesDates 索引
const sIdx = new Map(seriesDates.map((d, i) => [d, i]))
// 通用对齐:给定 series 里某条数组,返回与 rows dates 对齐的版本
const align = (arr: (number | null)[] | undefined): (number | null)[] => {
if (!arr) return dates.map(() => null)
return dates.map(d => {
const i = sIdx.get(d)
return i != null ? arr[i] : null
})
}
if (series.boll) {
alignedSeries['boll_upper'] = align(series.boll.upper)
alignedSeries['boll_lower'] = align(series.boll.lower)
if (series.boll.mid) alignedSeries['boll_mid'] = align(series.boll.mid)
}
if (series.keltner_s) {
alignedSeries['keltner_s_upper'] = align(series.keltner_s.upper)
alignedSeries['keltner_s_lower'] = align(series.keltner_s.lower)
}
if (series.keltner_m) {
alignedSeries['keltner_m_upper'] = align(series.keltner_m.upper)
alignedSeries['keltner_m_lower'] = align(series.keltner_m.lower)
}
if (series.keltner_l) {
alignedSeries['keltner_l_upper'] = align(series.keltner_l.upper)
alignedSeries['keltner_l_lower'] = align(series.keltner_l.lower)
}
if (series.atr) {
alignedSeries['atr_stop'] = align(series.atr.stop_loss)
alignedSeries['atr_tp'] = align(series.atr.take_profit)
}
}
return { dates, candle, vols, dateIndex, zoomStart, alignedSeries }
}, [rows, series, seriesDates])
// 构建 option
const buildOption = (): EChartsOption => {
const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
// 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
// [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
const SLIDER_H = 22
const PAD_TOP = 16
const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
const PAD_BOTTOM = 8
const volH = VOL_PANE_H
const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
const sliderBottom = PAD_BOTTOM
// 预留:markPoint(新闻标记)
const markPointData: any[] = (markers ?? [])
.filter(m => dateIndex.has(m.date))
.map(m => ({
coord: [m.date, rows[dateIndex.get(m.date)!].high],
symbol: 'pin', symbolSize: 32,
itemStyle: { color: m.color ?? '#EAB308' },
label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
}))
// 预留:markArea(事件区间)
const markAreaData: any[] = (ranges ?? [])
.filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
.map(r => [{
xAxis: r.start, name: r.label ?? '',
itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
}, { xAxis: r.end }])
const series: any[] = [
{
name: 'K', type: 'candlestick', data: candle, animation: false,
itemStyle: {
color: THEME.bull, color0: THEME.bear,
borderColor: THEME.bull, borderColor0: THEME.bear,
},
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
},
{
name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
data: vols, animation: false,
},
]
// 价位水平线 —— 用 line series(恒定值)画水平线,endLabel 显示标签文字;
// 与通道曲线一致,标签落在右侧 grid.right 预留带(外侧),不压蜡烛。
for (const p of priceLines) {
series.push({
name: p.label, type: 'line', silent: true, animation: false,
symbol: 'none',
data: dates.map(() => p.value),
lineStyle: { width: 1, color: p.color, type: 'dashed', opacity: 0.7 },
itemStyle: { color: p.color },
endLabel: {
show: true,
formatter: () => `${p.label} ${p.value.toFixed(2)}`,
color: p.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
},
})
}
// 带状曲线指标(布林带 / Keltner通道 / ATR止损) —— 跟随行情漂移的曲线
// 单一数据源 CURVE_DEFS 驱动:每条曲线带 endLabel(右侧端点标签),显示最新数值
for (const def of CURVE_DEFS) {
if (!activeTypes.has(def.group)) continue
const data = alignedSeries[def.alignedKey]
if (!data || !data.some(v => v != null)) continue
// 取最后一个有效值作为右侧端点显示文字
let lastVal: number | null = null
for (let i = data.length - 1; i >= 0; i--) {
if (data[i] != null) { lastVal = data[i]; break }
}
series.push({
name: def.endLabel, type: 'line', data: data.map(v => v ?? '-'),
smooth: true, symbol: 'none', silent: true, animation: false,
lineStyle: { width: 1, color: def.color, type: def.dashed === false ? 'solid' : 'dashed', opacity: 0.8 },
itemStyle: { color: def.color },
// 右侧端点标签:显示该通道的最新数值,距绘图区右缘留 6px 间距
endLabel: lastVal != null ? {
show: true,
formatter: () => `${lastVal!.toFixed(2)}`,
color: def.color, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
backgroundColor: 'rgba(15,23,42,0.85)', padding: [1, 4], borderRadius: 2,
distance: 6,
} : undefined,
})
}
return {
animation: false,
backgroundColor: 'transparent',
// grid.right 留出足够宽度给价位标签文字区:蜡烛只占左侧主区域,
// 价位线右端的标签文字显示在这条预留带里,不压在蜡烛上。
// 预留 ~144px:最长标签(如「成交密集区(POC) 12.34」)约 13 字符,fontSize 9 等宽。
grid: [
{ left: 56, right: 144, top: 16, height: mainH },
{ left: 56, right: 144, top: volTop, height: volH },
],
xAxis: [
{
type: 'category', data: dates, boundaryGap: true,
axisLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10 },
splitLine: { show: false },
axisPointer: { show: true, label: { show: false } },
},
{
type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
},
],
yAxis: [
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
{ scale: true, gridIndex: 1, splitNumber: 2,
// 成交量区不画背景横线
splitLine: { show: false },
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
formatter: (v: number) => fmtVol(v) } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
],
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
tooltip: { show: false },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
series,
}
}
// 初始化 + 数据更新
useEffect(() => {
if (!chartRef.current) return
if (!chartInstRef.current) {
chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
chartInstRef.current.on('click', (params: any) => {
// 预留:点击 K 线(非 markPoint/markLine)回调
if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
onDateClick(dates[params.dataIndex])
}
})
}
chartInstRef.current.setOption(buildOption(), true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, levels, series, seriesDates, activeTypes, pivotRank, markers, ranges, height])
// resize
useEffect(() => {
const inst = chartInstRef.current
if (!inst) return
const onResize = () => inst.resize()
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
}, [])
const toggleType = (t: LevelType) => {
setActiveTypes(prev => {
const next = new Set(prev)
if (next.has(t)) next.delete(t)
else next.add(t)
return next
})
}
return (
<div className={className}>
{/* 价位开关按钮组 */}
{levels && (
<div className="flex flex-wrap items-center gap-1.5 mb-2">
<span className="text-[10px] text-muted mr-1">关键价位</span>
{LEVEL_GROUPS.map(g => {
const active = activeTypes.has(g.key)
// 枢轴点数量按当前档位过滤显示;其他组显示原始数量
const raw = levels[g.key] ?? []
const count = g.key === 'pivot'
? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
: raw.length
return (
<button
key={g.key}
onClick={() => toggleType(g.key)}
disabled={raw.length === 0}
title={`${g.label} (${count} 个)`}
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
active
? 'text-foreground'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
{g.label}
<span className="opacity-50">{count}</span>
</button>
)
})}
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
<span className="text-[10px] text-muted mr-1">档位</span>
{([1, 2, 3] as const).map(r => (
<button
key={r}
onClick={() => setPivotRank(r)}
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
pivotRank === r
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
>
{r}
</button>
))}
</div>
)}
</div>
)}
{/* 图表:右侧预留带(grid.right 预留)显示价位标签文字,不压蜡烛 */}
<div ref={chartRef} style={{ width: '100%', height }} />
{/* 价位统计面板:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{levels && (
<LevelOverview
levels={levels}
activeTypes={activeTypes}
pivotRank={pivotRank}
close={rows.length ? rows[rows.length - 1].close : undefined}
/>
)}
</div>
)
}
// ===== 价位统计面板(图表下方,结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
}: {
levels: Record<LevelType, PriceLevel[]>
activeTypes: Set<LevelType>
pivotRank: 1 | 2 | 3
close?: number
}) {
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
const visible: PriceLevel[] = []
for (const g of LEVEL_GROUPS) {
if (!activeTypes.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
visible.push(p)
}
}
if (visible.length === 0) return null
// 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
const cur = close ?? visible[0].value
const resistances = visible
.filter(p => p.side === 'resistance')
.sort((a, b) => a.value - b.value) // 由近及远(低→高)
const supports = visible
.filter(p => p.side === 'support')
.sort((a, b) => b.value - a.value) // 由近及远(高→低)
const neutrals = visible.filter(p => p.side === 'neutral')
const fmtPct = (v: number) => {
if (!cur) return ''
const pct = ((v - cur) / cur) * 100
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(1)}%`
}
const Row = ({ p }: { p: PriceLevel }) => {
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
return (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
</div>
)
}
return (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
{/* 当前价 */}
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
<span className="text-[10px] text-muted">当前价</span>
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
</div>
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
{resistances.length > 0 && (
<div>
<div className="text-[10px] font-medium text-bear mb-0.5">压力位 </div>
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
</div>
)}
{/* 支撑位 + 中性(枢轴位 P) */}
<div>
{supports.length > 0 && (
<>
<div className="text-[10px] font-medium text-bull mb-0.5">支撑位 </div>
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
</>
)}
{neutrals.length > 0 && (
<div className={supports.length > 0 ? 'mt-2' : ''}>
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5">枢轴位</div>}
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
</div>
)}
</div>
</div>
)
}
// ===== 工具:收集要画的水平价位线(按开启的组 + 档位 + 强度配色) =====
// 注意:带状指标(布林带/Keltner/ATR)改用曲线渲染,不在此画水平线,避免重复。
function collectPriceLines(
levels: Record<LevelType, PriceLevel[]> | undefined,
active: Set<LevelType>,
pivotRank: 1 | 2 | 3,
): { value: number; label: string; color: string }[] {
if (!levels) return []
const out: { value: number; label: string; color: string }[] = []
for (const g of LEVEL_GROUPS) {
if (!active.has(g.key)) continue
for (const p of levels[g.key] ?? []) {
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
// 波动通道类(boll / keltner三档 / atr_stop)整组走曲线渲染,不画水平线;
// sr 组现为成交密集区水平点,直接画线即可,无需特判。
if (p.type === 'boll' || p.type === 'keltner_s' || p.type === 'keltner_m'
|| p.type === 'keltner_l' || p.type === 'atr_stop') continue
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
}
}
return out
}
function strengthColor(strength: string | undefined, base: string): string {
// strong 用实色,medium 用 0.85,weak 用 0.55 透明
if (strength === 'weak') return base + '8C'
if (strength === 'medium') return base + 'D9'
return base
}
function fmtVol(v: number): string {
if (!v) return '0'
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
return v.toFixed(0)
}