功能更新

This commit is contained in:
2026-08-15 08:57:15 +08:00
parent 50fd032b45
commit c1c43d2ff7
30 changed files with 1908 additions and 888 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, registerIndicator, type Chart, type KLineData } from 'klinecharts';
import { useSettingsStore } from '@/stores/settings';
import type { Candle } from '@/api/types';
const props = defineProps<{
@@ -9,41 +10,55 @@ const props = defineProps<{
indicators: Record<string, Record<string, (number | null)[]>>;
/** 副图指标及顺序('vol' 用内置;其余为后端序列) */
subPanes: string[];
/** 主图 MA 周期(可配置,随用户偏好持久化) */
maPeriods: number[];
/** 各副图高度 px可配置随用户偏好持久化 */
subHeights: Record<string, number>;
/** 主图是否叠加布林带 */
showBoll: boolean;
/** K线周期标签仅用于 MA 指标名缓存 key */
timeframe: string;
}>();
// A股语义色浅色
const UP = '#dc2626';
const DOWN = '#16a34a';
const C1 = '#2563eb'; // 蓝
const C2 = '#f59e0b'; // 橙
const C3 = '#a855f7'; // 紫
const C4 = '#10b981'; // 绿青
// A股语义色浅色UP/DOWN 跟随设置中的涨跌配色
const settings = useSettingsStore();
let UP = '#dc2626';
let DOWN = '#16a34a';
const MA_COLORS = ['#2563eb', '#f59e0b', '#a855f7', '#10b981', '#ec4899', '#0ea5e9', '#84cc16', '#f97316'];
// ---------- 后端序列注入(单一事实源,按索引对齐) ----------
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) })),
});
// 动态 MA按周期组合注册一次figures 的 key 必须静态,故按签名建缓存)
const _maReg = new Set<string>();
function ensureMaIndicator(periods: number[]) {
const sig = [...periods].sort((a, b) => a - b).join('_');
if (_maReg.has(sig)) return `pv-ma-${sig}`;
registerIndicator({
name: `pv-ma-${sig}`,
shortName: 'MA',
figures: periods.map((p, i) => ({
key: `ma${p}`, title: `MA${p}`, type: 'line',
styles: () => ({ color: MA_COLORS[i % MA_COLORS.length] }),
})),
calc: (d: KLineData[]) => d.map((_, i) => {
const row: Record<string, number | undefined> = {};
for (const p of periods) row[`ma${p}`] = g(`ma${p}`)(i);
return row;
}),
});
_maReg.add(sig);
return `pv-ma-${sig}`;
}
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 }) },
{ key: 'upper', title: 'UP', type: 'line', styles: () => ({ color: '#a855f7' }) },
{ key: 'mid', title: 'MB', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'lower', title: 'DN', type: 'line', styles: () => ({ color: '#a855f7' }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ upper: g('upper')(i), mid: g('mid')(i), lower: g('lower')(i) })),
});
@@ -52,8 +67,8 @@ 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: 'dif', title: 'DIF', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'dea', title: 'DEA', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{
key: 'hist', title: 'HIST', type: 'bar', baseValue: 0, // 零轴柱,缺省会从面板底部画起
styles: (p) => {
@@ -69,9 +84,9 @@ 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 }) },
{ key: 'k', title: 'K', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'd', title: 'D', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'j', title: 'J', type: 'line', styles: () => ({ color: '#dc2626' }) },
],
calc: (d: KLineData[]) => d.map((_, i) => ({ k: g('k')(i), d: g('d')(i), j: g('j')(i) })),
});
@@ -80,63 +95,145 @@ 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 }) },
{ key: 'rsi6', title: 'RSI6', type: 'line', styles: () => ({ color: '#2563eb' }) },
{ key: 'rsi12', title: 'RSI12', type: 'line', styles: () => ({ color: '#f59e0b' }) },
{ key: 'rsi24', title: 'RSI24', type: 'line', styles: () => ({ color: '#a855f7' }) },
],
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;
let allData: KLineData[] = [];
let served = 0; // 已交给图表的 bar 数从尾部计backward 分页用
const LIGHT_STYLES = {
grid: { horizontal: { color: '#eef2f7' }, vertical: { color: '#eef2f7' } },
candle: {
bar: {
upColor: UP, downColor: DOWN,
upBorderColor: UP, downBorderColor: DOWN,
upWickColor: UP, downWickColor: DOWN,
const INIT_BARS = 240; // 初始展示根数(约一年日线)
const PAGE_BARS = 500; // 每次向左滚动追加的历史根数
function lightStyles() {
return {
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 },
},
},
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' } },
},
},
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' },
};
separator: { color: '#e2e8f0' },
};
}
// 副图默认高度
const SUB_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const SUB_DEFAULT_HEIGHT: Record<string, number> = { vol: 64, macd: 100, kdj: 96, rsi: 84 };
const subH = (k: string) => Math.max(40, props.subHeights[k] ?? SUB_DEFAULT_HEIGHT[k] ?? 90);
// ---------- 鼠标跟随信息框(通达信式) ----------
interface HoverInfo {
date: string; open: number; high: number; low: number; close: number;
chg: number | null; amp: number | null; vol: string; amount: string | null;
mas: { label: string; value: number | null; color: string }[];
}
const hover = ref<HoverInfo | null>(null);
function fmtVol(v: number): string {
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (v >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(Math.round(v));
}
function bindCrosshair(ch: Chart) {
ch.subscribeAction('onCrosshairChange', (payload) => {
const k = (payload as { data?: { kLineData?: KLineData } }).data?.kLineData;
if (!k || !allData.length) { hover.value = null; return; }
// 二分定位索引(全量数组与指标序列按索引对齐)
let lo = 0, hi = allData.length - 1, idx = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (allData[mid].timestamp === k.timestamp) { idx = mid; break; }
if (allData[mid].timestamp < k.timestamp) lo = mid + 1; else hi = mid - 1;
}
if (idx < 0) { hover.value = null; return; }
const prev = idx > 0 ? allData[idx - 1] : null;
const chg = prev ? ((k.close - prev.close) / prev.close) * 100 : null;
const amp = prev ? ((k.high - k.low) / prev.close) * 100 : null;
hover.value = {
date: new Date(k.timestamp).toLocaleDateString('zh-CN'),
open: k.open, high: k.high, low: k.low, close: k.close,
chg, amp, vol: fmtVol(k.volume ?? 0), amount: null,
mas: props.maPeriods.map((p, i) => ({
label: `MA${p}`,
value: PV[`ma${p}`]?.[idx] ?? null,
color: MA_COLORS[i % MA_COLORS.length],
})),
};
});
}
// ---------- 画图画线(通达信式工具栏) ----------
const TOOLS: { key: string; label: string; title: string }[] = [
{ key: '', label: '光标', title: '光标模式Esc 取消画线)' },
{ key: 'segment', label: '', title: '线段' },
{ key: 'ray', label: '→', title: '射线' },
{ key: 'horizontalLine', label: '─', title: '水平线' },
{ key: 'rect', label: '▭', title: '矩形' },
{ key: 'priceChannelLine', label: '∥', title: '价格通道' },
{ key: 'fibLine', label: 'fib', title: '斐波那契回撤' },
];
const activeTool = ref('');
function pickTool(key: string) {
activeTool.value = key;
if (chart && key) chart.createOverlay({ name: key });
}
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
}
function build() {
if (!container.value || props.candles.length === 0) return;
UP = settings.upHex;
DOWN = settings.downHex;
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 });
const ch = init(container.value, { styles: lightStyles() });
if (!ch) return;
chart = ch;
const data: KLineData[] = props.candles.map((k) => ({
allData = 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,
}));
served = 0;
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'update') {
const last = data[data.length - 1];
callback(last ? [last] : [], { backward: false, forward: false });
const last = allData[allData.length - 1];
callback(last ? [last] : [], { backward: served < allData.length, forward: false });
} else if (type === 'init') {
callback(data, { backward: false, forward: false });
// 全量已拉到本地:先给最近 INIT_BARS 根,向左滚动时按页吐更早历史
served = Math.min(INIT_BARS, allData.length);
callback(allData.slice(allData.length - served), { backward: served < allData.length, forward: false });
} else if (type === 'backward') {
const remain = allData.length - served;
const take = Math.min(PAGE_BARS, remain);
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { backward: served < allData.length, forward: false });
} else {
callback([], { backward: false, forward: false });
}
@@ -146,21 +243,22 @@ function build() {
ch.setSymbol({ ticker: props.ticker });
ch.setPeriod({ type: 'day', span: 1 });
// 主图MA 恒开BOLL 可选
ch.createIndicator({ name: 'pv-ma', paneId: 'candle_pane' });
// 主图MA(周期可配置)恒开BOLL 可选
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), 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 subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value.clientHeight || 560;
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(220, total - subHeights - 24) });
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 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 });
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
ch.createIndicator(name);
const paneId = ch.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) ch.setPaneOptions({ id: paneId, height: subH(key) });
}
bindCrosshair(ch);
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
}
@@ -168,13 +266,71 @@ function build() {
function teardown() {
if (container.value) dispose(container.value);
chart = null;
hover.value = null;
activeTool.value = '';
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll], () => { teardown(); build(); }, { deep: true });
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
watch(() => props.subHeights, () => {
if (!chart) return;
const subTotal = props.subPanes.reduce((s, k) => s + subH(k), 0);
const total = container.value?.clientHeight || 560;
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24) });
for (const key of props.subPanes) {
const name = key === 'vol' ? 'VOL' : `pv-${key}`;
const paneId = chart.getIndicators().find((i) => i.name === name)?.paneId;
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key) });
}
}, { deep: true });
</script>
<template>
<div ref="container" class="h-full w-full"></div>
<div class="relative h-full w-full">
<div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框通达信式小方块 -->
<div
v-if="hover"
class="pointer-events-none absolute left-2 top-2 z-10 rounded border border-slate-700 bg-slate-900/90 px-2.5 py-1.5 font-mono text-[11px] leading-4 text-slate-200 shadow-lg"
>
<div class="text-slate-400">{{ hover.date }}</div>
<div> <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.open.toFixed(2) }}</span>
<span class="text-red-400">{{ hover.high.toFixed(2) }}</span>
<span class="text-emerald-400">{{ hover.low.toFixed(2) }}</span>
<span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.close.toFixed(2) }}</span></div>
<div> <span :class="hover.chg != null && hover.chg >= 0 ? 'text-red-400' : 'text-emerald-400'">{{ hover.chg == null ? '—' : (hover.chg > 0 ? '+' : '') + hover.chg.toFixed(2) + '%' }}</span>
<span class="text-slate-100">{{ hover.amp == null ? '—' : hover.amp.toFixed(2) + '%' }}</span>
<span class="text-slate-100">{{ hover.vol }}</span></div>
<div v-if="hover.mas.length" class="mt-0.5">
<span v-for="(m, i) in hover.mas" :key="m.label" class="mr-2" :style="{ color: m.color }">
{{ m.label }} {{ m.value == null ? '' : m.value.toFixed(2) }}<span v-if="i < hover.mas.length - 1" class="invisible">,</span>
</span>
</div>
</div>
<!-- 画图画线工具栏 -->
<div class="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md border border-slate-200 bg-white/95 px-1 py-0.5 shadow-sm">
<button
v-for="t in TOOLS"
:key="t.key || 'cursor'"
type="button"
class="min-w-6 rounded px-1 py-0.5 text-[11px] transition-colors"
:class="activeTool === t.key ? 'bg-blue-600 text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900'"
:title="t.title"
@click="pickTool(t.key)"
>{{ t.label }}</button>
<span class="mx-0.5 h-3 w-px bg-slate-200"></span>
<button
type="button"
class="rounded px-1 py-0.5 text-[11px] text-red-500 transition-colors hover:bg-red-50"
title="清除全部画线"
@click="clearOverlays"
>清除</button>
</div>
</div>
</template>