看股功能更新

This commit is contained in:
2026-08-16 00:05:26 +08:00
parent 9cce670b74
commit fc86fe0674
28 changed files with 3823 additions and 96 deletions

View File

@@ -2,7 +2,7 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import {
dispose, init, registerIndicator, registerOverlay,
type Chart, type KLineData, type Point,
type Chart, type KLineData, type OverlayCreate, type OverlayTemplate, type Point,
} from 'klinecharts';
// 官方画线扩展preview.klinecharts.com 同款工具集rect/circle 沿用 v10 内置版,不注册扩展的重名模板
import {
@@ -22,6 +22,50 @@ for (const t of [
registerOverlay(t);
}
// ---------- 实盘买卖点标记(交割单导入) ----------
// v10 无 v9 的 simpleMarker须注册自定义模板字母种类/明细经 extendData 传入。
// A股惯例通达信/同花顺同款B 买贴 low 下方、S 卖贴 high 上方、T 当日买+卖做T贴 high 上方;
// 图上只显示单个字母徽章(色底白字,用户指定固定配色,不随涨跌设置),成交明细(数量/均价/费用)
// 悬停字母时由组件浮层展示——onMouseEnter/onMouseLeave 是创建项级回调OverlayCreate 未 Omit
// 事件键),闭包进组件状态即可(模板是模块级的,拿不到组件实例)。
interface TradeRow { label: string; text: string; tone: 'buy' | 'sell' | '' }
interface TradeMarkExt { kind: 'B' | 'S' | 'T'; rows: TradeRow[] }
const TRADE_COLORS: Record<'B' | 'S' | 'T', string> = { B: '#FE354B', S: '#3B7BBF', T: '#F9A504' };
const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
name: 'tradeMarker',
totalStep: 2,
needDefaultPointFigure: false,
needDefaultXAxisFigure: false,
needDefaultYAxisFigure: false,
createPointFigures: ({ overlay, coordinates }) => {
const c = coordinates[0];
const ext = overlay.extendData;
if (!c || !ext) return [];
const ly = ext.kind === 'B' ? c.y + 22 : c.y - 22; // 字母中心与 bar 高低点的像素间距离K线远一点更清爽
return [
{
type: 'text',
attrs: { x: c.x, y: ly, text: ext.kind, align: 'center', baseline: 'middle' },
styles: {
color: '#FFFFFF', backgroundColor: TRADE_COLORS[ext.kind],
size: 12, weight: 'bold', borderRadius: 3,
paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1,
},
ignoreEvent: true,
},
{ // 透明命中区:把字母徽章的悬停判定兜成 r=9 的圆,指上去更容易。
// 必须排在 text 之后:库按数组顺序挂 children、倒序分发 mousemove
// circle 放最后才能最先接管事件——否则首个落点在徽章上时 enter 会被
// text 的 ignoreEvent 拦住、tooltip 出不来circle 全透明,压顶层无视觉影响)
type: 'circle',
attrs: { x: c.x, y: ly, r: 9 },
styles: { style: 'fill', color: 'rgba(0,0,0,0)', borderColor: 'rgba(0,0,0,0)' },
},
];
},
};
registerOverlay(tradeMarkerTemplate);
const props = defineProps<{
ticker: string;
candles: Candle[];
@@ -45,6 +89,17 @@ const props = defineProps<{
timeframe: string;
/** 浮层显示的指标(可选;缺省=目录全开,空数组=仅日期头) */
tooltipFields?: TooltipField[];
/** 日期跳转锚点本地零点时间戳build 完成后把该日 K 线滚动到可视区中央null=停在最新 */
centerTs?: number | null;
/** 实盘买卖点交割单导入按日聚合成标记B=当日只买 贴 low 下方、S=当日只卖 贴 high 上方、
* T=当日买+卖做T贴 high 上方rows 为悬停明细(数量/均价/费用)。
* 只画落在已渲染窗口内的(更早的等左滑翻页后自动补画) */
tradeMarkers?: { key: string; ts: number; kind: 'B' | 'S' | 'T'; rows: TradeRow[] }[];
}>();
const emit = defineEmits<{
/** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */
(e: 'centerMiss', ts: number): void;
}>();
// A股语义色黑底高对比UP/DOWN 跟随设置中的涨跌配色
@@ -246,7 +301,11 @@ function darkStyles() {
horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#333A45' } },
},
separator: { color: '#23252B' },
separator: {
color: '#23252B',
// 悬停/拖拽分隔条时的底色(库默认 8% 蓝在纯黑底上不可见,加重为可感知的拖拽提示)
activeBackgroundColor: 'rgba(37, 99, 235, 0.30)',
},
};
}
@@ -254,6 +313,23 @@ function darkStyles() {
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);
// ---------- 分隔条拖拽调高(库原生 SeparatorWidget→ 持久化 ----------
// build 时记录 key→paneId拖动中库会高频触发 onPaneDrag防抖后读回各副图实际高度写入偏好
let paneIdByKey: Record<string, string> = {};
let subHTimer: ReturnType<typeof setTimeout> | null = null;
function persistSubHeights() {
if (!chart) return;
const next: Record<string, number> = {};
for (const key of props.subPanes) {
const pid = paneIdByKey[key];
const h = pid ? (chart.getPaneOptions(pid) as { height?: number } | null)?.height : undefined;
if (typeof h === 'number') next[key] = Math.max(40, Math.round(h));
}
if (Object.keys(next).length === 0) return;
settings.setChartLayout({ subHeights: { ...props.subHeights, ...next } });
}
// ---------- 鼠标跟随信息框(通达信式,浮层贴鼠标,每行一个指标) ----------
interface TipRow { key: string; label: string; text: string; tone: '' | 'up' | 'down' }
interface HoverInfo {
@@ -426,6 +502,113 @@ function pickTool(key: string) {
function clearOverlays() {
chart?.removeOverlay();
activeTool.value = '';
// removeOverlay() 无参清的是全部 overlay含交易点——交易点不是用户画线重画回来
renderTradeMarkers();
}
// ---------- 日期跳转居中 ----------
/** ts本地零点落在哪根K上取该时刻之前含同日最近一根的下标无则 -1停牌/非交易日自然落到前一根 */
function idxAtOrBefore(list: KLineData[], ts: number): number {
let lo = 0, hi = list.length - 1, ans = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (list[mid].timestamp <= ts) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
/** 把已渲染的第 i 根K线滚动到可视区中央scrollToDataIndex 定位到右缘,补半个可视窗口即居中) */
function centerDataIndex(i: number) {
if (!chart) return;
const v = chart.getVisibleRange();
const vis = Math.max(2, Math.round(v.to - v.from) - 1); // from/to 含半个bar余量
chart.scrollToDataIndex(i + Math.floor(vis / 2) - 1, 350);
}
/** 对外:把某天滚动到可视区中央;目标不在当前已渲染窗口内时返回 false调用方走重拉窗口
* ts 晚于最后一根 10 天以上(未来日期/超出现有数据)同样算失败,避免 floor 搜索落到
* 最后一根、锚点却指向一个不存在交易的日期10 天容忍周末与春节黄金周这类停牌间隙。 */
const FUTURE_TOL_MS = 10 * 86400000;
function centerOn(ts: number): boolean {
if (!chart) return false;
const list = chart.getDataList();
if (list.length === 0) return false;
const i = idxAtOrBefore(list, ts);
if (i < 0) return false;
if (ts > list[list.length - 1].timestamp + FUTURE_TOL_MS) return false;
centerDataIndex(i);
return true;
}
defineExpose({ centerOn });
// ---------- 实盘买卖点标记渲染 ----------
const TRADE_GROUP = 'trades';
/** 交易点允许吸附到「晚于最后一根K时间戳」的窗口按周期放大周/月/年K的 bar 时间戳
* 是周期首日(周一/1日/1月1日当前周期内的成交如月中仍应贴到最后一根上。
* 日K严格为 0行情未同步到成交日时宁可先不画数据同步后重建图表自动补上
* 也不能把周一的成交错标到周五的K线上。 */
const TRADE_AHEAD_MS: Record<string, number> = {
'1d': 0,
'1w': 6 * 86400000,
'1M': 31 * 86400000,
'1y': 366 * 86400000,
};
/** 按 groupId 整组重建买卖点标记(先删后建,幂等)。交易日期按时间戳吸附到所在 bar
* B 贴 bar.low 下方、S/T 贴 bar.high 上方坐标随复权切换自动重算value 取自当前数据)。
* 早于已渲染窗口的交易先跳过——左滑翻页 serveOlder 吐出新数据后会重跑本函数补画。
* 列表为空(关闭显示/清空成交/切到无成交股票)也必须清组,否则旧标记残留。 */
function renderTradeMarkers() {
if (!chart) return;
tradeTip.value = null; // 组重建期间字母已换位,旧明细浮层不能留在原地
chart.removeOverlay({ groupId: TRADE_GROUP });
if (!props.tradeMarkers?.length) return;
const list = chart.getDataList();
if (list.length === 0) return;
const lastTs = list[list.length - 1].timestamp;
const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0;
const creates: OverlayCreate<unknown>[] = [];
for (const m of props.tradeMarkers) {
const i = idxAtOrBefore(list, m.ts);
if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期
const bar = list[i];
creates.push({
id: `trade-${m.key}`,
groupId: TRADE_GROUP,
name: 'tradeMarker',
points: [{ timestamp: bar.timestamp, value: m.kind === 'B' ? bar.low : bar.high }],
extendData: { kind: m.kind, rows: m.rows },
onMouseEnter: (ev) => {
// pageX/pageY 是文档绝对坐标x/y 是相对各 pane 画布的,副图 pane 会带偏移),而
// getBoundingClientRect 是视口坐标——须再减 window.scrollX/Y 对齐基准:浮层是从滚过的
// 列表页打开的body 锁滚仍保留偏移),漏减会把 tip 整体顶出可视区、悬停像失灵
const rect = container.value?.getBoundingClientRect();
const px = (ev.pageX ?? 0) - (rect?.left ?? 0) - window.scrollX;
const py = (ev.pageY ?? 0) - (rect?.top ?? 0) - window.scrollY;
tradeTip.value = { ...placeTradeTip(px, py, m.rows.length), kind: m.kind, date: m.key, rows: m.rows };
},
onMouseLeave: () => { tradeTip.value = null; },
// v10 右键命中 figure 会默认 removeOverlaylock 只拦左键按下),标记被悄悄删掉——显式吞掉
onRightClick: (ev) => { ev.preventDefault?.(); },
lock: true,
});
}
if (creates.length) chart.createOverlay(creates);
}
// ---------- 交易点悬停明细(悬停 B/S/T 字母才显示,离开/滚动即隐) ----------
interface TradeTip { x: number; y: number; kind: 'B' | 'S' | 'T'; date: string; rows: TradeRow[] }
const tradeTip = ref<TradeTip | null>(null);
/** 贴鼠标定位并在右缘/下缘自动翻转(与十字线浮层 placeHover 同款策略,宽度略大) */
function placeTradeTip(px: number, py: number, rowCount: number): { x: number; y: number } {
const w = container.value?.clientWidth ?? 800;
const h = container.value?.clientHeight ?? 500;
const bw = 168, bh = 36 + rowCount * 17, gap = 12;
const x = px + gap + bw > w - 4 ? Math.max(4, px - gap - bw) : px + gap;
const y = py + gap + bh > h - 4 ? Math.max(4, py - gap - bh) : py + gap;
return { x, y };
}
function build() {
@@ -464,12 +647,20 @@ function build() {
const start = allData.length - served - take;
served += take;
callback(allData.slice(start, start + take), { forward: canBack(), backward: false });
renderTradeMarkers(); // 窗口左扩后补画此前跳过的更早交易点
};
const answerEmpty = () => callback([], { forward: false, backward: false });
if (type === 'init') {
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
served = Math.min(INIT_BARS, allData.length);
callback(allData.slice(allData.length - served), { forward: canBack(), backward: false });
// 首屏:最近 INIT_BARS 根;更早历史由左滑触发 'forward' 翻页
// 有跳转锚点时把 serve 左扩到包含锚点(锚点落在窗口前 1/2 处),仍保持
// [n-served, n) 尾连续不变式——这样 serveOlder 的翻页切片不用变;
// BOLL/副图等同数据重建时锚点就不会掉出首屏窗口。
const n = allData.length;
const anchorIdx = props.centerTs != null ? idxAtOrBefore(allData, props.centerTs) : -1;
served = anchorIdx >= 0
? Math.min(n, Math.max(INIT_BARS, n - anchorIdx + (INIT_BARS >> 1)))
: Math.min(INIT_BARS, n);
callback(allData.slice(n - served), { forward: canBack(), backward: false });
maybePrefetch(myEpoch);
} else if (type === 'forward') {
// 左缘:优先吐本地未吐出的(首屏余量或已预取页),本地耗尽再向服务端翻一页更早历史
@@ -505,38 +696,62 @@ function build() {
ch.createIndicator({ name: ensureMaIndicator(props.maPeriods), paneId: 'candle_pane' });
if (props.showBoll) ch.createIndicator({ name: 'pv-boll', paneId: 'candle_pane' });
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
// 副图按用户顺序创建,并设置用户高度;主图吃剩余高度
// minHeight 交给库在分隔条拖拽时强制执行(与 subH 的 40px 下限一致)
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(200, total - subTotal - 24) });
ch.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
paneIdByKey = {};
for (const key of props.subPanes) {
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) });
if (paneId) {
paneIdByKey[key] = paneId;
ch.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}
bindCrosshair(ch);
// 分隔条拖拽调高拖动结束250ms 无新事件)后把各副图实际高度持久化;
// 期间图表已重建epoch 变化)则丢弃,新图表按存档布局
ch.subscribeAction('onPaneDrag', () => {
if (myEpoch !== epoch) return;
if (subHTimer) clearTimeout(subHTimer);
subHTimer = setTimeout(() => { subHTimer = null; persistSubHeights(); }, 250);
});
// 缓冲预取:可视范围接近已加载左缘(<200 根)时提前翻下一页
ch.subscribeAction('onVisibleRangeChange', (payload) => {
if (myEpoch !== epoch) return;
tradeTip.value = null; // 滚动后字母随 bar 移位,悬停明细立即失效
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28);
ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
// 居中失败(锚点早于上市首日/晚于最后一根)必须上报:否则锚点 chip 与统计口径
// 仍停留在「已定位」状态,视口却悄悄回到最新行情。
if (props.centerTs != null && !centerOn(props.centerTs)) emit('centerMiss', props.centerTs);
// init 数据在 setPeriod 时已同步落入图表,可直接画首屏窗口内的交易点
renderTradeMarkers();
}
function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (container.value) dispose(container.value);
chart = null;
hover.value = null;
tradeTip.value = null;
activeTool.value = '';
}
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 买卖点数据变化(导入/清空/开关显示):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => props.tradeMarkers, renderTradeMarkers, { deep: true });
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)
@@ -544,11 +759,11 @@ 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) });
chart.setPaneOptions({ id: 'candle_pane', height: Math.max(200, total - subTotal - 24), minHeight: 200 });
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) });
if (paneId) chart.setPaneOptions({ id: paneId, height: subH(key), minHeight: 40 });
}
}, { deep: true });
</script>
@@ -556,12 +771,13 @@ watch(() => props.subHeights, () => {
<template>
<!-- mousemove captureklinecharts 在内部容器上以冒泡阶段监听并同步触发
onCrosshairChangeplaceHovercapture 先于它更新 mx/my避免用到上一次的坐标 -->
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null">
<div class="relative h-full w-full" @mousemove.capture="onMove" @mouseleave="hover = null; tradeTip = null">
<div ref="container" class="h-full w-full"></div>
<!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定 -->
<!-- 鼠标跟随信息框贴鼠标/下缘自动翻转每行一个指标内容由浮层设置决定
悬停交易字母时让位给明细浮层两框几乎同点位叠加会呈现双层边框的重影 -->
<div
v-if="hover"
v-if="hover && !tradeTip"
class="pointer-events-none absolute z-10 w-40 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="hoverStyle"
>
@@ -578,8 +794,33 @@ watch(() => props.subHeights, () => {
</div>
</div>
<!-- 画图画线工具栏常用一行 + 更多分组面板 -->
<div class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm">
<!-- 交易点悬停明细贴鼠标/下缘自动翻转日期 + 字母 + 当日买卖数量/均价/费用 -->
<div
v-if="tradeTip"
class="pointer-events-none absolute z-20 w-44 rounded border border-[#33353D] bg-black/90 px-2.5 py-1.5 font-mono text-xs leading-4 text-[#E8EAED] shadow-lg"
:style="{ left: `${tradeTip.x}px`, top: `${tradeTip.y}px` }"
>
<div class="flex items-baseline justify-between">
<span class="text-[#9BA3AE]">{{ tradeTip.date }}</span>
<span class="font-bold" :style="{ color: TRADE_COLORS[tradeTip.kind] }">{{ tradeTip.kind }}</span>
</div>
<div class="mt-1 border-t border-[#33353D]/60 pt-1">
<div v-for="(r, i) in tradeTip.rows" :key="i" class="flex items-baseline justify-between">
<span class="text-[#A8AFB8]">{{ r.label }}</span>
<span
:style="r.tone ? { color: r.tone === 'buy' ? TRADE_COLORS.B : TRADE_COLORS.S } : undefined"
:class="r.tone ? '' : 'text-[#E8EAED]'"
>{{ r.text }}</span>
</div>
</div>
</div>
<!-- 画图画线工具栏常用一行 + 更多分组面板
移入工具栏时 canvas 收不到后续 mousemoveonMouseLeave 不会触发须在此清掉交易明细浮层 -->
<div
class="absolute right-2 top-2 z-10 rounded-md border border-[#26272E] bg-[#101014] shadow-sm"
@mouseenter="tradeTip = null"
>
<div class="flex items-center gap-0.5 px-1 py-0.5">
<button
v-for="t in COMMON_TOOLS"