feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权
This commit is contained in:
180
frontend/src/components/DetailKLine.vue
Normal file
180
frontend/src/components/DetailKLine.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
|
||||
import type { Candle } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
ticker: string;
|
||||
candles: Candle[];
|
||||
indicators: Record<string, Record<string, (number | null)[]>>;
|
||||
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
|
||||
subPanes: string[];
|
||||
/** 主图是否叠加布林带 */
|
||||
showBoll: boolean;
|
||||
}>();
|
||||
|
||||
// A股语义色(浅色)
|
||||
const UP = '#dc2626';
|
||||
const DOWN = '#16a34a';
|
||||
const C1 = '#2563eb'; // 蓝
|
||||
const C2 = '#f59e0b'; // 橙
|
||||
const C3 = '#a855f7'; // 紫
|
||||
const C4 = '#10b981'; // 绿青
|
||||
|
||||
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
|
||||
let PV: Record<string, (number | null)[]> = {};
|
||||
const g = (k: string) => (i: number) => PV[k]?.[i] ?? undefined;
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-ma',
|
||||
shortName: 'MA',
|
||||
figures: [
|
||||
{ key: 'ma5', title: 'MA5', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'ma10', title: 'MA10', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'ma20', title: 'MA20', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'ma60', title: 'MA60', type: 'line', styles: () => ({ color: C4 }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ ma5: g('ma5')(i), ma10: g('ma10')(i), ma20: g('ma20')(i), ma60: g('ma60')(i) })),
|
||||
});
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-boll',
|
||||
shortName: 'BOLL',
|
||||
figures: [
|
||||
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: C3 }) },
|
||||
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: C3 }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
|
||||
});
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-macd',
|
||||
shortName: 'MACD',
|
||||
figures: [
|
||||
{ key: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{
|
||||
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
|
||||
styles: (p) => {
|
||||
const v = (p.data.current as { hist?: number } | null)?.hist ?? 0;
|
||||
return { color: v >= 0 ? UP : DOWN };
|
||||
},
|
||||
},
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ dif: g('dif')(i), dea: g('dea')(i), hist: g('hist')(i) })),
|
||||
});
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-kdj',
|
||||
shortName: 'KDJ',
|
||||
figures: [
|
||||
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: UP }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
|
||||
});
|
||||
|
||||
registerIndicator({
|
||||
name: 'pv-rsi',
|
||||
shortName: 'RSI',
|
||||
figures: [
|
||||
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: C1 }) },
|
||||
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: C2 }) },
|
||||
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: C3 }) },
|
||||
],
|
||||
calc: (d: KLineData[]) => d.map((_, i) => ({ rsi6: g('rsi6')(i), rsi12: g('rsi12')(i), rsi24: g('rsi24')(i) })),
|
||||
});
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
|
||||
const LIGHT_STYLES = {
|
||||
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
|
||||
candle: {
|
||||
bar: {
|
||||
upColor: UP, downColor: DOWN,
|
||||
upBorderColor: UP, downBorderColor: DOWN,
|
||||
upWickColor: UP, downWickColor: DOWN,
|
||||
},
|
||||
priceMark: {
|
||||
high: { color: '#94a3b8' }, low: { color: '#94a3b8' },
|
||||
last: { upColor: UP, downColor: DOWN },
|
||||
},
|
||||
},
|
||||
xAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
yAxis: { axisLine: { color: '#e2e8f0' }, tickText: { color: '#64748b' }, tickLine: { color: '#e2e8f0' } },
|
||||
crosshair: {
|
||||
horizontal: { text: { backgroundColor: '#1e293b' } },
|
||||
vertical: { text: { backgroundColor: '#1e293b' } },
|
||||
},
|
||||
separator: { color: '#e2e8f0' },
|
||||
};
|
||||
|
||||
// 副图默认高度
|
||||
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
|
||||
|
||||
function build() {
|
||||
if (!container.value || props.candles.length === 0) return;
|
||||
PV = {};
|
||||
for (const [group, series] of Object.entries(props.indicators)) {
|
||||
for (const [key, arr] of Object.entries(series)) PV[key] = arr;
|
||||
}
|
||||
|
||||
const ch = init(container.value, { styles: LIGHT_STYLES });
|
||||
if (!ch) return;
|
||||
chart = ch;
|
||||
|
||||
const data: KLineData[] = props.candles.map((k) => ({
|
||||
timestamp: new Date(k.ts).getTime(),
|
||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||
}));
|
||||
ch.setDataLoader({
|
||||
getBars: ({ type, callback }) => {
|
||||
if (type === 'update') {
|
||||
const last = data[data.length - 1];
|
||||
callback(last ? [last] : [], { backward: false, forward: false });
|
||||
} else if (type === 'init') {
|
||||
callback(data, { backward: false, forward: false });
|
||||
} else {
|
||||
callback([], { backward: false, forward: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载,缺一则 getBars 永不调用、图表空白
|
||||
ch.setSymbol({ ticker: props.ticker });
|
||||
ch.setPeriod({ type: 'day', span: 1 });
|
||||
|
||||
// 主图:MA 恒开,BOLL 可选
|
||||
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
|
||||
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
|
||||
|
||||
// 副图按用户顺序创建,并压矮;主图吃剩余高度
|
||||
const subHeights = props.subPanes.reduce((s, k) => s + (SUB_HEIGHT[k] ?? 90), 0);
|
||||
const total = container.value.clientHeight || 560;
|
||||
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
|
||||
for (const key of props.subPanes) {
|
||||
if (key === 'vol') ch.createIndicator('VOL');
|
||||
else ch.createIndicator(`pv-${key}`);
|
||||
const paneId = ch.getIndicators().find((i) => i.name === (key === 'vol' ? 'VOL' : `pv-${key}`))?.paneId;
|
||||
if (paneId) ch.setPaneOptions({ id: paneId, height: SUB_HEIGHT[key] ?? 90 });
|
||||
}
|
||||
|
||||
ch.setOffsetRightDistance(28);
|
||||
ch.scrollToRealTime();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (container.value) dispose(container.value);
|
||||
chart = null;
|
||||
}
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="h-full w-full"></div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user