This commit is contained in:
2026-09-04 10:00:35 +08:00
parent 2735ff1fd9
commit 76b422320b
14 changed files with 611 additions and 218 deletions

View File

@@ -11,7 +11,7 @@ import {
threeWaves, triangle, xabcd,
} from '@klinecharts/extension';
import { useSettingsStore, TOOLTIP_FIELDS, DEFAULT_TOOLTIP_FIELDS } from '@/stores/settings';
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, type ChipsExt } from '@/indicators';
import { registerCustomIndicators, computeZhixingSeries, DKX_PERIODS, ZHIXING_NAME, CHIPS_NAME, CHIPS_COL_PX, type ChipsExt } from '@/indicators';
import type { Candle, TooltipField } from '@/api/types';
// 通达信导入的自定义主图指标注册表;知行序列走 PV 管道按时间戳取预计算值。
@@ -671,11 +671,62 @@ function syncChipsIndicator() {
if (want) {
if (exists) chart.removeIndicator({ name: CHIPS_NAME });
chart.createIndicator({ name: CHIPS_NAME, paneId: 'candle_pane', extendData: props.chips ?? undefined });
syncRightOffset(); // 开关即扩留白(不等数据),数据到达时列直接落在已预留的空白里
} else if (exists) {
chart.removeIndicator({ name: CHIPS_NAME });
restoreRightOffset();
} else {
restoreRightOffset(); // 数据未到即关:同样要还原已扩出去的留白
}
}
// ---------- 筹码峰竖列的右侧留白管理 ----------
// klinecharts 滚动状态唯一变量是 diff最后一根K距绘图区右缘的 bar 数),
// setOffsetRightDistance(px) 会把 diff 直接重置为 px/barSpace 并 relayout——等价于
// “视口拽回实时位并留 px 空白”。因此只在「右区」末根K在视野内getVisibleRange().to
// 达到 data 长度)时调用;用户在看历史时绝不调,滚回右区后由 onScroll 防抖补一次吸附
// (同花顺松手吸附同款)。绝不调 setMaxOffsetRightDistance会把滚动限制切到
// 'distance' 模式,默认钳到 50px 且改变滚动边缘行为。
const BASE_RIGHT_PX = 28;
let syncingRightOffset = false; // 重入守卫set 会同步派发 onVisibleRangeChange
let scrollReanchorTimer: ReturnType<typeof setTimeout> | null = null;
let managedRightPx = 0; // 筹码开启期间我们 set 过的目标值(关筹码时判断“是否还归我们管”)
/** 目标右侧留白:开筹码 = 列宽 + 一根K线 + 余量bar 最宽 50px → 峰值 ~182px */
function targetRightPx(): number {
if (!props.showChips) return BASE_RIGHT_PX;
const bar = chart?.getBarSpace().bar || 10;
return Math.round(CHIPS_COL_PX + bar + 12);
}
/** 开筹码期间保证留白盖住竖列:只扩不缩(用户自己拖出的更大留白不动) */
function syncRightOffset(): void {
if (!chart || syncingRightOffset || !props.showChips) return;
const list = chart.getDataList();
if (!list.length || chart.getVisibleRange().to < list.length) return; // 右区守卫
const target = targetRightPx();
if (chart.getOffsetRightDistance() >= target - 2) return; // 阈值防循环 + 只扩不缩
syncingRightOffset = true;
try {
chart.setOffsetRightDistance(target);
managedRightPx = target;
} finally {
syncingRightOffset = false;
}
}
/** 关筹码时一次性还原 28px仅当留白仍在我们管理范围内用户没手动拖大过 */
function restoreRightOffset(): void {
if (!chart || managedRightPx === 0) return;
const list = chart.getDataList();
const cur = chart.getOffsetRightDistance();
if (list.length && chart.getVisibleRange().to >= list.length
&& cur > BASE_RIGHT_PX + 2 && cur <= managedRightPx + 2) {
chart.setOffsetRightDistance(BASE_RIGHT_PX);
}
managedRightPx = 0;
}
function build() {
if (!container.value || props.candles.length === 0) return;
UP = settings.upHex;
@@ -801,7 +852,23 @@ function build() {
const from = (payload as { data?: { from?: unknown } }).data?.from;
if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch);
});
ch.setOffsetRightDistance(28);
// 右侧留白开筹码时按竖列宽度预留scrollToRealTime 以它为锚点,先后顺序不能换)
ch.setOffsetRightDistance(props.showChips ? targetRightPx() : BASE_RIGHT_PX);
// 筹码竖列的留白跟随:缩放后 barSpace 变化onZoom 触发时新值已生效)立即补扩;
// 拖拽期间 onScroll 逐帧触发、且拖拽每帧从手势起点快照重算 diff逐帧同步会与拖拽
// “互搏”(从实时位拖向历史会被逐帧拽回),故防抖 150ms 只在手势结束后补一次
ch.subscribeAction('onZoom', () => {
if (myEpoch !== epoch) return;
syncRightOffset();
});
ch.subscribeAction('onScroll', () => {
if (myEpoch !== epoch) return;
if (scrollReanchorTimer) clearTimeout(scrollReanchorTimer);
scrollReanchorTimer = setTimeout(() => {
scrollReanchorTimer = null;
if (myEpoch === epoch) syncRightOffset();
}, 150);
});
ch.scrollToRealTime();
// 日期跳转build 尾部的 scrollToRealTime 会把视口重置到最新一根,居中必须放在它之后
//init 数据在 setPeriod 时已同步落入图表,这里可直接定位)。
@@ -814,6 +881,8 @@ function build() {
function teardown() {
if (subHTimer) { clearTimeout(subHTimer); subHTimer = null; }
if (scrollReanchorTimer) { clearTimeout(scrollReanchorTimer); scrollReanchorTimer = null; }
managedRightPx = 0;
if (container.value) dispose(container.value);
chart = null;
hover.value = null;

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import { deleteScreenerQuery, getScreenerQueries } from '@/api/client';
import type { ScreenConditions, ScreenerQueryItem } from '@/api/types';
import type { ScreenerQueryItem } from '@/api/types';
const props = defineProps<{ loading: boolean }>();
const emit = defineEmits<{
(e: 'run', text: string, conditions?: ScreenConditions | null): void;
(e: 'run', text: string): void;
(e: 'ran'): void;
(e: 'stop'): void;
}>();
const text = ref('');
@@ -18,13 +19,14 @@ const examples = [
];
function run() {
if (props.loading) return; // 执行中 Ctrl+Enter 不重复触发(要停止点「停止」)
if (text.value.trim()) {
emit('run', text.value.trim());
emit('ran');
}
}
// ---------- 提问历史(入库,可一键重跑 / 删除) ----------
// ---------- 提问历史(点击只填入输入框,不触发查询 / 删除) ----------
const history = ref<ScreenerQueryItem[]>([]);
const historyOpen = ref(false);
@@ -38,12 +40,9 @@ async function refreshHistory() {
} catch { history.value = []; }
}
function rerun(q: ScreenerQueryItem) {
function fillQuery(q: ScreenerQueryItem) {
text.value = q.text;
historyOpen.value = false;
// 存过 conditions 的记录直传条件,跳过 LLM 重新解析
emit('run', q.text, q.conditions ?? null);
emit('ran');
}
async function removeQuery(id: number) {
@@ -87,8 +86,8 @@ defineExpose({ refreshHistory });
<button
type="button"
class="min-w-0 flex-1 text-left"
:title="q.conditions ? '点击直传条件重跑(不重新解析)' : '点击填入并重跑'"
@click="rerun(q)"
title="点击填入输入框(不自动执行)"
@click="fillQuery(q)"
>
<span class="block truncate text-sm text-[#E8EAED]">{{ q.text }}</span>
<span class="mt-0.5 block text-xs text-[#9BA3AE]">
@@ -134,10 +133,19 @@ defineExpose({ refreshHistory });
支持 KDJ / RSI / MACD / 布林 / 均线指标条件市值 / 市盈率 / 换手率等快照条件以及连续 N N 天任一天时间窗口
<kbd class="rounded border border-[#26272E] bg-black px-1">Ctrl</kbd>+<kbd class="rounded border border-[#26272E] bg-black px-1">Enter</kbd> 快速筛选
</p>
<button type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="loading || !text.trim()" @click="run">
<svg v-if="loading" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
{{ loading ? '筛选中…' : '开始筛选' }}
<!-- 执行中变为停止中止 LLM 解析与逐股过滤服务端随连接断开自动取消 -->
<button
v-if="loading"
type="button"
class="flex shrink-0 items-center gap-1.5 rounded-lg border border-red-500/50 bg-red-500/15 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/25"
@click="emit('stop')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="6" y="6" width="12" height="12" rx="1.5" /></svg>
停止
</button>
<button v-else type="button" class="btn-primary shrink-0 disabled:opacity-50" :disabled="!text.trim()" @click="run">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
开始筛选
</button>
</div>
</div>