275 lines
12 KiB
Vue
275 lines
12 KiB
Vue
<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>
|
||
<!-- 迷你走势:默认高度 36px,hover 出十字点与数值 -->
|
||
<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>
|