看股功能更新
This commit is contained in:
161
backend/app/data/market_overview.py
Normal file
161
backend/app/data/market_overview.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""大盘行情总览(主页展示)。
|
||||
|
||||
- A 股指数走 pro.index_daily,全球指数走 pro.index_global(均为收盘口径,晚间更新;
|
||||
本 token 无 rt_idx_k 实时权限,故展示「最近交易日收盘」并标注日期)。
|
||||
- 两市统计走 pro.daily_info:沪市取 SH_A、深市取 SZ_MARKET 汇总出
|
||||
总市值 / 流通市值 / 成交额(单位亿元,接口原生口径)。
|
||||
- 整包结果写 Redis 缓存(TTL 可配);单个指数拉取失败只是跳过(errors 里注明),
|
||||
全部失败才抛 MarketOverviewError —— 主页行情是锦上添花,不拖垮整页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .. import cache
|
||||
from ..config import settings
|
||||
|
||||
# (代码, 名称, 地区) —— 展示顺序即列表顺序
|
||||
MARKET_INDEXES: list[tuple[str, str, str]] = [
|
||||
("000001.SH", "上证指数", "cn"),
|
||||
("399001.SZ", "深证成指", "cn"),
|
||||
("399006.SZ", "创业板指", "cn"),
|
||||
("000688.SH", "科创50", "cn"),
|
||||
("HSI", "恒生指数", "hk"),
|
||||
("HKTECH", "恒生科技", "hk"),
|
||||
("DJI", "道琼斯", "us"),
|
||||
("IXIC", "纳斯达克", "us"),
|
||||
("SPX", "标普500", "us"),
|
||||
]
|
||||
|
||||
_SPARK_DAYS = 45 # 迷你走势取最近 45 个交易日收盘
|
||||
_HISTORY_DAYS = 150 # 日历日窗口(约 100 个交易日,够取 spark)
|
||||
_CALL_INTERVAL = 0.12 # 顺序调用间隔(秒),对 tushare 控频
|
||||
_CACHE_KEY = "market_overview:v1"
|
||||
|
||||
|
||||
class MarketOverviewError(RuntimeError):
|
||||
"""所有指数都拉不到(token/网络故障)——接口层转 503。"""
|
||||
|
||||
|
||||
def _f(v) -> float | None:
|
||||
"""pandas 值 -> float;NaN/None -> None(否则 JSON 里会出现 NaN)。"""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if math.isnan(f) else f
|
||||
|
||||
|
||||
def _d(v) -> date | None:
|
||||
return datetime.strptime(str(v), "%Y%m%d").date() if v else None
|
||||
|
||||
|
||||
def _get_pro():
|
||||
if not settings.tushare_token:
|
||||
raise MarketOverviewError("未配置 TUSHARE_TOKEN,无法获取大盘行情(backend/.env)")
|
||||
import tushare as ts
|
||||
|
||||
ts.set_token(settings.tushare_token)
|
||||
return ts.pro_api()
|
||||
|
||||
|
||||
def _fetch_index_sync(pro, ts_code: str) -> pd.DataFrame:
|
||||
start = (datetime.now() - timedelta(days=_HISTORY_DAYS)).strftime("%Y%m%d")
|
||||
if "." in ts_code: # A 股指数(000001.SH 形式)
|
||||
return pro.index_daily(ts_code=ts_code, start_date=start)
|
||||
return pro.index_global(ts_code=ts_code, start_date=start)
|
||||
|
||||
|
||||
def _quote_from_df(df: pd.DataFrame) -> dict | None:
|
||||
"""DataFrame -> {close, change, pct_chg, trade_date, spark, spark_dates}(旧 -> 新)。"""
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
df = df.sort_values("trade_date")
|
||||
tail = df.tail(_SPARK_DAYS)
|
||||
last = df.iloc[-1]
|
||||
return {
|
||||
"close": _f(last["close"]),
|
||||
"change": _f(last.get("change")),
|
||||
"pct_chg": _f(last.get("pct_chg")),
|
||||
"trade_date": _d(last["trade_date"]),
|
||||
"spark": [round(float(c), 4) for c in tail["close"]],
|
||||
"spark_dates": [str(d) for d in tail["trade_date"]],
|
||||
}
|
||||
|
||||
|
||||
def _fetch_stats_sync(pro) -> dict | None:
|
||||
"""两市市值/成交统计:沪 SH_A + 深 SZ_MARKET(同一天口径相加,亿元)。"""
|
||||
start = (datetime.now() - timedelta(days=14)).strftime("%Y%m%d")
|
||||
sh = pro.daily_info(exchange="SH", start_date=start)
|
||||
sz = pro.daily_info(exchange="SZ", start_date=start)
|
||||
if sh is None or sh.empty or sz is None or sz.empty:
|
||||
return None
|
||||
|
||||
def _board(df: pd.DataFrame, code: str):
|
||||
sub = df[df["ts_code"] == code].sort_values("trade_date") # 接口不保证有序
|
||||
return sub.iloc[-1] if not sub.empty else None
|
||||
|
||||
sh_a, sz_m = _board(sh, "SH_A"), _board(sz, "SZ_MARKET")
|
||||
if sh_a is None or sz_m is None:
|
||||
return None
|
||||
# 两边各自取最新,日期不一致时以较旧一天为准凑齐口径(罕见,通常同日)
|
||||
d = min(_d(sh_a["trade_date"]), _d(sz_m["trade_date"]))
|
||||
|
||||
def _sum(col: str) -> float | None:
|
||||
a, b = _f(sh_a.get(col)), _f(sz_m.get(col))
|
||||
return None if a is None or b is None else round(a + b, 2)
|
||||
|
||||
return {
|
||||
"trade_date": d,
|
||||
"total_mv": _sum("total_mv"),
|
||||
"float_mv": _sum("float_mv"),
|
||||
"amount": _sum("amount"),
|
||||
"turnover": _f(sh_a.get("tr")), # 换手率仅沪市有,展示口径注明沪市
|
||||
}
|
||||
|
||||
|
||||
async def fetch_overview() -> dict:
|
||||
"""聚合全部指数 + 两市统计(Redis 缓存整包,TTL 内直接回)。"""
|
||||
cached = await cache.cache_get(_CACHE_KEY)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
pro = await asyncio.to_thread(_get_pro)
|
||||
|
||||
indexes: list[dict] = []
|
||||
errors: list[str] = []
|
||||
for ts_code, name, region in MARKET_INDEXES:
|
||||
try:
|
||||
df = await asyncio.to_thread(_fetch_index_sync, pro, ts_code)
|
||||
q = _quote_from_df(df)
|
||||
if q is None:
|
||||
raise MarketOverviewError("无数据")
|
||||
indexes.append({"code": ts_code, "name": name, "region": region, **q})
|
||||
except Exception as e: # noqa: BLE001 —— 单个指数失败不拖垮整包
|
||||
errors.append(f"{name}: {str(e)[:60]}")
|
||||
await asyncio.sleep(_CALL_INTERVAL)
|
||||
|
||||
if not indexes:
|
||||
raise MarketOverviewError("大盘行情全部拉取失败: " + "; ".join(errors)[:200])
|
||||
|
||||
stats: dict | None = None
|
||||
try:
|
||||
await asyncio.sleep(_CALL_INTERVAL)
|
||||
stats = await asyncio.to_thread(_fetch_stats_sync, pro)
|
||||
except Exception as e: # noqa: BLE001 —— 统计缺失时指数照常展示
|
||||
errors.append(f"两市统计: {str(e)[:60]}")
|
||||
|
||||
payload = {
|
||||
"updated_at": datetime.now(),
|
||||
"indexes": indexes,
|
||||
"stats": stats,
|
||||
"errors": errors,
|
||||
}
|
||||
await cache.cache_set(_CACHE_KEY, payload, ttl=settings.market_overview_ttl)
|
||||
return payload
|
||||
@@ -694,3 +694,40 @@ INFO: 127.0.0.1:52465 - "GET /api/screener/preview/000001.SZ?limit=800&adjus
|
||||
WARNING: WatchFiles detected changes in 'app\data\market_overview.py'. Reloading...
|
||||
INFO: 127.0.0.1:53348 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53466 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53593 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53600 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53614 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53616 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:53772 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:54143 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:54156 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:54155 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:54160 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:54166 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:54952 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:55237 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55254 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:55252 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55259 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:55269 - "GET /api/screener/sync/status HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55280 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:55282 - "GET /api/stocks/facets HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55281 - "GET /api/stocks?sort=symbol&order=asc&limit=100&offset=0 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55286 - "GET /api/stocks?sort=circ_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55294 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=0 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55318 - "GET /api/trades?ts_code=603259.SH HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55320 - "GET /api/watchlist HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55319 - "GET /api/screener/preview/603259.SH?limit=500&adjust=qfq&timeframe=1d HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55330 - "GET /api/screener/preview/603259.SH?limit=800&adjust=qfq&timeframe=1d&end=2024-07-24 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55349 - "GET /api/screener/preview/603259.SH?limit=800&adjust=qfq&timeframe=1d&end=2021-04-07 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:55433 - "GET /api/stocks?sort=total_mv&order=desc&limit=100&offset=100 HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56175 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:56189 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56208 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:56204 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56279 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:56281 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:56621 - "GET /api/auth/me HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56638 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
INFO: 127.0.0.1:56637 - "GET /api/preferences HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:56644 - "GET /api/market/overview HTTP/1.1" 404 Not Found
|
||||
|
||||
252
frontend/src/components/MarketOverview.vue
Normal file
252
frontend/src/components/MarketOverview.vue
Normal 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>
|
||||
<!-- 迷你走势:默认高度 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>
|
||||
|
||||
<!-- 两市统计: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>
|
||||
Reference in New Issue
Block a user