Files
stock/frontend/src/components/IndexKLine.vue
2026-09-07 18:07:31 +08:00

134 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>