This commit is contained in:
2026-09-09 11:35:02 +08:00
parent bc1c72d558
commit d656c05b3d
35 changed files with 711 additions and 3194 deletions

View File

@@ -790,9 +790,11 @@ function teardown() {
onMounted(build);
onBeforeUnmount(teardown);
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }, { deep: true });
// 浅 watch 即可:父组件对 data 是整体替换新数组引用props 引用变化必触发;
// deep 反而每次深遍历几百根 K 线的嵌套数组(父组件从无原地改写)
watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); });
// 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线)
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers, { deep: true });
watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers);
// 涨跌配色切换:重建图表以应用新颜色
watch(() => settings.priceTone, () => { teardown(); build(); });
// 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态)

View File

@@ -1,133 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { dispose, init, type Chart, type KLineData } from 'klinecharts';
import { getIndexCandles } from '@/api/client';
import type { Candle, Timeframe } from '@/api/types';
import { useSettingsStore } from '@/stores/settings';
import { darkStyles } from '@/chartStyles';
// 首页上证指数 K 线图:轻量版(无翻页/画线/副图配置)。
// v10 无 applyNewData数据只进 dataLoader——每次到新数据整图重建切周期/换配色同款,
// 与详情页 teardown+build 模式一致;全量 ≤9000 根init 开销毫秒级)。
// 周期随用户偏好持久化chartLayout.indexTimeframe
const settings = useSettingsStore();
const PERIODS: { key: Timeframe; label: string }[] = [
{ key: '1d', label: '日K' },
{ key: '1w', label: '周K' },
{ key: '1M', label: '月K' },
{ key: '1y', label: '年K' },
];
const timeframe = ref<Timeframe>(settings.chartLayout.indexTimeframe ?? '1d');
function setTimeframe(tf: Timeframe) {
if (tf === timeframe.value) return;
timeframe.value = tf;
settings.setChartLayout({ indexTimeframe: tf });
void load();
}
const container = ref<HTMLDivElement | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const lastDate = ref(''); // 数据末根交易日(收盘口径)
let chart: Chart | null = null;
let loadToken = 0;
let lastCandles: Candle[] | null = null; // 配色切换重建图表时免重拉
function rebuild(candles: Candle[]) {
if (!container.value) return;
if (chart) { dispose(container.value); chart = null; }
const ch = init(container.value, { styles: darkStyles(settings.upHex, settings.downHex) });
if (!ch) return;
chart = ch;
const data: KLineData[] = candles.map((c) => ({
timestamp: new Date(c.ts).getTime(),
open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume,
}));
// 全量已在手init 一次给足forward更早历史/backward更新端都无更多
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'init') callback(data, { forward: false, backward: false });
else callback([], { forward: false, backward: false });
},
});
// v10 要求 symbol+period+dataLoader 三者齐备才触发 'init' 加载
ch.setSymbol({ ticker: '000001.SH' });
ch.setPeriod({ type: 'day', span: 1 });
// 主图 MA周期与详情页默认一致+ VOL 副图;右侧留白与详情页同款
ch.createIndicator({ name: 'MA', paneId: 'candle_pane', calcParams: [5, 10, 20, 60] });
ch.createIndicator('VOL');
const volPane = ch.getIndicators().find((i) => i.name === 'VOL')?.paneId;
ch.setPaneOptions({ id: 'candle_pane', height: 252, minHeight: 160 });
if (volPane) ch.setPaneOptions({ id: volPane, height: 76, minHeight: 56 });
ch.setOffsetRightDistance(28);
}
async function load() {
const token = ++loadToken;
loading.value = true;
error.value = null;
try {
const candles = await getIndexCandles(timeframe.value);
if (token !== loadToken) return; // 期间已切换周期,旧响应丢弃
lastCandles = candles;
rebuild(candles);
lastDate.value = candles.length ? candles[candles.length - 1].ts.slice(0, 10) : '';
} catch (e) {
if (token === loadToken) error.value = e instanceof Error ? e.message : '获取指数K线失败';
} finally {
if (token === loadToken) loading.value = false;
}
}
onMounted(load);
onBeforeUnmount(() => {
if (container.value) dispose(container.value);
chart = null;
});
// 涨跌配色切换:重建图表应用新颜色,数据用已拉到的直接重放
watch(() => settings.priceTone, () => {
if (lastCandles) rebuild(lastCandles);
});
</script>
<template>
<div class="rounded-lg border border-[#26272E] bg-[#101014] p-3">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium text-[#E5E7EB]">上证指数</span>
<span v-if="lastDate" class="font-mono text-xs tabular-nums text-[#6B7280]">收盘口径 · {{ lastDate }}</span>
<span v-else class="text-xs text-[#6B7280]">收盘口径</span>
</div>
<div class="flex items-center gap-1">
<button
v-for="p in PERIODS"
:key="p.key"
type="button"
class="rounded-md border px-2.5 py-1 text-[13px] transition-colors"
:class="timeframe === p.key
? 'border-blue-600 bg-blue-600 text-white'
: 'border-[#26272E] bg-[#101014] text-[#9BA3AE] hover:text-[#E5E7EB]'"
@click="setTimeframe(p.key)"
>{{ p.label }}</button>
</div>
</div>
<div class="relative h-[340px]">
<div ref="container" class="h-full w-full" />
<div
v-if="loading"
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/70 text-sm text-[#9BA3AE]"
>
<svg class="mb-2 h-6 w-6 animate-spin text-blue-500" 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>
指数K线加载中
</div>
<div v-else-if="error" class="flex h-full items-center justify-center text-sm text-[#A8AFB8]">
{{ error }}
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="load">重试</button>
</div>
</div>
</div>
</template>

View File

@@ -420,6 +420,9 @@ const filteredItems = computed(() => {
(it) => it.ts_code.toLowerCase().includes(q) || it.name.toLowerCase().includes(q),
);
});
// 渲染截断:概念板块宽基可到 5555 只成分,全进 DOM 会卡;搜索可收敛,键盘 ↑/↓ 不受影响
const ITEM_RENDER_CAP = 300;
const shownItems = computed(() => filteredItems.value.slice(0, ITEM_RENDER_CAP));
const activeItem = computed(
() => props.items.find((it) => it.ts_code === active.value) ?? null,
@@ -628,7 +631,7 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<button
v-for="it in filteredItems"
v-for="it in shownItems"
:key="it.ts_code"
type="button"
class="flex w-full items-center gap-2 border-b border-[#1E2026] px-3 py-2 text-left transition-colors"
@@ -647,6 +650,9 @@ const fmtListDate = (s?: string | null) => (s && s.length === 8 ? `${s.slice(0,
</span>
</button>
<div v-if="filteredItems.length === 0" class="px-3 py-8 text-center text-[13px] text-[#9BA3AE]">无匹配</div>
<div v-else-if="filteredItems.length > ITEM_RENDER_CAP" class="px-3 py-2 text-center text-[11px] text-[#6B7280]">
{{ filteredItems.length.toLocaleString() }} 仅显示前 {{ ITEM_RENDER_CAP }} · 用上方搜索收敛
</div>
</div>
<div class="border-t border-[#1E2026] px-3 py-2 text-xs text-[#9BA3AE]"> {{ filteredItems.length }} </div>
</aside>