134 lines
5.4 KiB
Vue
134 lines
5.4 KiB
Vue
<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>
|