看股功能更新

This commit is contained in:
2026-08-16 00:47:19 +08:00
parent 7c794cb342
commit 63f8ed8b69
3 changed files with 450 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
<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';
// 主页大盘总览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>
<span 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>
<!-- 两市统计daily_info SH_A + 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">
<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) }}</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>
<!-- 部分来源失败透明但不打扰 -->
<div v-if="overview.errors.length" class="mt-2 text-[10px] text-[#6B7280]" :title="overview.errors.join('')">
{{ overview.errors.length }} 项数据获取失败已跳过
</div>
</template>
</section>
</template>