Files
stock/frontend/src/components/MarketOverview.vue
2026-09-05 00:12:19 +08:00

275 lines
12 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 { computed, onMounted, reactive, ref } from 'vue';
import { getMarketOverview } from '@/api/client';
import type { IndexQuote, MarketOverview as Overview } from '@/api/types';
import AmountHistoryChart from '@/components/AmountHistoryChart.vue';
import IndexKLine from '@/components/IndexKLine.vue';
// 主页大盘总览A 股 + 港美指数最近收盘(收盘口径,标注交易日),沪深两市市值/成交统计。
// 数据为 EOD 口径,进页面拉一次 + 手动刷新即可,不做轮询。
const overview = ref<Overview | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
overview.value = await getMarketOverview();
} catch (e) {
error.value = e instanceof Error ? e.message : '获取大盘行情失败';
} finally {
loading.value = false;
}
}
onMounted(load);
const groups = computed(() => {
const idx = overview.value?.indexes ?? [];
return [
{ label: '沪深主要指数', items: idx.filter((i) => i.region === 'cn') },
{ label: '港美市场', items: idx.filter((i) => i.region !== 'cn') },
].filter((g) => g.items.length > 0);
});
const updatedAt = computed(() => {
const s = overview.value?.updated_at;
return s ? new Date(s).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
});
// ---------- 数字与颜色 ----------
function dirClass(v: number | null | undefined): string {
if (v == null) return 'text-[#A8AFB8]';
return v > 0 ? 'text-up' : v < 0 ? 'text-down' : 'text-[#A8AFB8]';
}
function fmtClose(v: number | null | undefined): string {
if (v == null) return '--';
return v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtSigned(v: number | null | undefined): string {
if (v == null) return '--';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
}
function fmtPct(v: number | null | undefined): string {
if (v == null) return '--';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '';
return v.replaceAll('-', '').slice(4, 8).replace(/^(\d{2})(\d{2})$/, '$1-$2');
}
/** 亿元 -> 万亿/亿 自适应(两市市值/成交用) */
function fmtYi(v: number | null | undefined): string {
if (v == null) return '--';
if (Math.abs(v) >= 10000) return `${(v / 10000).toFixed(2)} 万亿`;
return `${v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} 亿`;
}
// ---------- 迷你走势归一化折线SVG 拉伸 + 描边不缩放;端点/悬停点用 HTML 圆点保证正圆) ----------
const SPARK_PAD = 0.08; // 上下留白,避免贴边
function sparkGeom(it: IndexQuote) {
const vals = it.spark;
const n = vals.length;
if (n < 2) return null;
const lo = Math.min(...vals);
const hi = Math.max(...vals);
const span = hi - lo || Math.abs(hi) || 1;
const pts = vals.map((v, i) => ({
x: i / (n - 1),
y: 1 - ((v - lo) / span) * (1 - 2 * SPARK_PAD) - SPARK_PAD,
}));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(4)},${p.y.toFixed(4)}`).join(' ');
const area = `${line} L1,1 L0,1 Z`;
return { pts, line, area, last: pts[n - 1] };
}
const sparkColor = (it: IndexQuote): string => {
if (it.pct_chg == null || it.pct_chg === 0) return '#A8AFB8';
return it.pct_chg > 0 ? 'var(--color-up)' : 'var(--color-down)';
};
// 悬停per-code 记录索引 + 相对坐标tooltip 跟随
const hover = reactive<Record<string, { i: number; x: number; y: number }>>({});
function onSparkMove(it: IndexQuote, e: MouseEvent) {
const g = sparkGeom(it);
if (!g) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const t = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const i = Math.round(t * (it.spark.length - 1));
hover[it.code] = { i, x: g.pts[i].x, y: g.pts[i].y };
}
function hoverText(it: IndexQuote): string {
const h = hover[it.code];
if (!h) return '';
const d = it.spark_dates[h.i] ?? '';
const iso = d ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '';
return `${iso} ${fmtClose(it.spark[h.i])}`;
}
</script>
<template>
<section class="mb-12" aria-label="大盘行情总览">
<div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-medium text-[#A8AFB8]">大盘行情
<span class="ml-2 text-xs font-normal text-[#6B7280]">实时行情 · 收盘走势</span>
</h2>
<div class="flex items-center gap-3 text-xs text-[#6B7280]">
<span v-if="updatedAt">更新于 {{ updatedAt }}</span>
<button
type="button"
class="rounded p-1 transition-colors hover:bg-[#26272E] hover:text-[#A8AFB8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:disabled="loading"
aria-label="刷新大盘行情"
@click="load"
>
<svg class="h-4 w-4" :class="loading && 'animate-spin'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />
</svg>
</button>
</div>
</div>
<!-- 加载骨架首载与卡片同构的占位 -->
<div v-if="!overview && loading" class="space-y-6">
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<div v-for="i in 4" :key="i" class="h-[104px] animate-pulse rounded-lg border border-[#26272E] bg-[#101014]" />
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-5">
<div v-for="i in 5" :key="i" class="h-[104px] animate-pulse rounded-lg border border-[#26272E] bg-[#101014]" />
</div>
</div>
<!-- 失败不阻塞主页一行提示即可 -->
<div v-else-if="error" class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm text-[#A8AFB8]">
大盘行情暂不可用{{ error }}
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="load">重试</button>
</div>
<template v-else-if="overview">
<div v-for="g in groups" :key="g.label" class="mb-4 last:mb-0">
<div class="mb-2 text-xs text-[#6B7280]">{{ g.label }}</div>
<div
class="grid grid-cols-2 gap-3"
:class="g.items.length === 4 ? 'md:grid-cols-4' : 'md:grid-cols-3 lg:grid-cols-5'"
>
<div
v-for="it in g.items"
:key="it.code"
class="relative overflow-visible rounded-lg border border-[#26272E] bg-[#101014] px-4 pb-3 pt-3"
>
<div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-[#E5E7EB]">{{ it.name }}</span>
<!-- 实时蓝色徽标收盘口径交易日 MM-DD -->
<span v-if="it.realtime" class="flex items-center gap-1 text-[10px] text-blue-400">
<span class="h-1 w-1 animate-pulse rounded-full bg-blue-400" />实时
</span>
<span v-else class="font-mono text-[10px] tabular-nums text-[#6B7280]">{{ fmtDate(it.trade_date) }}</span>
</div>
<div class="mt-1.5 flex items-baseline gap-2">
<span class="text-xl font-semibold text-white">{{ fmtClose(it.close) }}</span>
<span class="font-mono text-xs tabular-nums" :class="dirClass(it.pct_chg)">
{{ it.pct_chg != null && it.pct_chg > 0 ? '▲' : it.pct_chg != null && it.pct_chg < 0 ? '▼' : '' }}{{ fmtPct(it.pct_chg) }}
</span>
</div>
<!-- 迷你走势默认高度 36pxhover 出十字点与数值 -->
<div
class="relative mt-2 h-9"
@mousemove="onSparkMove(it, $event)"
@mouseleave="delete hover[it.code]"
>
<svg
v-if="sparkGeom(it)"
class="h-full w-full"
viewBox="0 0 1 1"
preserveAspectRatio="none"
aria-hidden="true"
>
<path :d="sparkGeom(it)!.area" :fill="sparkColor(it)" fill-opacity="0.1" />
<path
:d="sparkGeom(it)!.line"
fill="none"
:stroke="sparkColor(it)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- 端点 2px 表面环与悬停点HTML 圆点避免非等比 viewBox 把圆拉成椭圆 -->
<span
v-if="sparkGeom(it)"
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
:style="{ left: `${sparkGeom(it)!.last.x * 100}%`, top: `${sparkGeom(it)!.last.y * 100}%`, backgroundColor: sparkColor(it) }"
/>
<template v-if="hover[it.code]">
<span
class="pointer-events-none absolute h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[#101014]"
:style="{ left: `${hover[it.code].x * 100}%`, top: `${hover[it.code].y * 100}%`, backgroundColor: sparkColor(it) }"
/>
<span
class="pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded border border-[#3A3D46] bg-[#1A1B21] px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-[#E5E7EB]"
:style="{ left: `${Math.min(82, Math.max(18, hover[it.code].x * 100))}%` }"
>{{ hoverText(it) }}</span>
</template>
</div>
</div>
</div>
</div>
<!-- 上证指数 K 线///周期随用户偏好持久化 -->
<IndexKLine class="mt-4" />
<!-- 两市统计daily_info SH_MARKET(主板A+科创+B) + SZ_MARKET 汇总 -->
<div
v-if="overview.stats"
class="mt-4 flex flex-wrap items-center gap-x-8 gap-y-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3 text-sm"
title="口径沪市主板A+科创板+B股+ 深市全部股票,不含基金/北交所"
>
<span class="text-xs text-[#6B7280]">沪深两市
<span class="font-mono tabular-nums">{{ fmtDate(overview.stats.trade_date) }}</span>
</span>
<span class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">成交额</span>
<span class="font-semibold text-[#E5E7EB]">{{ fmtYi(overview.stats.amount_today ?? overview.stats.amount) }}</span>
<span v-if="overview.stats.amount_today != null" class="text-[10px] text-blue-300">今日</span>
</span>
<span class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">总市值</span>
<span class="font-semibold text-[#E5E7EB]">{{ fmtYi(overview.stats.total_mv) }}</span>
</span>
<span class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">流通市值</span>
<span class="font-semibold text-[#E5E7EB]">{{ fmtYi(overview.stats.float_mv) }}</span>
</span>
<span v-if="overview.stats.turnover != null" class="flex items-baseline gap-2">
<span class="text-xs text-[#6B7280]">换手率<span class="text-[10px]"></span></span>
<span class="font-semibold text-[#E5E7EB]">{{ overview.stats.turnover.toFixed(2) }}%</span>
</span>
</div>
<!-- 两市成交额历史数值轴柱状图今日盘中柱调淡标注 -->
<AmountHistoryChart
v-if="overview.amount_history?.length"
:bars="overview.amount_history"
class="mt-3"
/>
<!-- 部分来源失败透明但不打扰 -->
<div v-if="overview.errors.length" class="mt-2 text-[10px] text-[#6B7280]" :title="overview.errors.join('')">
{{ overview.errors.length }} 项数据获取失败已跳过
</div>
</template>
</section>
</template>