This commit is contained in:
2026-09-07 18:07:31 +08:00
parent 359f9ae2e4
commit bc1c72d558
27 changed files with 4532 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
// klinecharts v10 暗色主题(详情页与首页指数图共用)。
// UP/DOWN 由调用方传入跟随设置的涨跌配色bars[0] 与库默认项深合并,
// 只覆盖涨跌色等少量键——init 时 merge 到 getDefaultStyles。
export function darkStyles(up: string, down: string) {
return {
grid: { horizontal: { color: '#1C1E24' }, vertical: { color: '#1C1E24' } },
// 内建 VOL 等指标柱的涨跌色缺省是库默认绿涨红跌,与全站语义相反
indicator: { bars: [{ upColor: up, downColor: down, noChangeColor: '#7A818C' }] },
candle: {
bar: {
upColor: up, downColor: down,
upBorderColor: up, downBorderColor: down,
upWickColor: up, downWickColor: down,
},
priceMark: {
high: { color: '#9AA0AA' }, low: { color: '#9AA0AA' },
last: { upColor: up, downColor: down },
},
},
xAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
yAxis: { axisLine: { color: '#2A2D34' }, tickText: { color: '#9AA0AA', size: 12 }, tickLine: { color: '#2A2D34' } },
crosshair: {
horizontal: { text: { backgroundColor: '#333A45' } },
vertical: { text: { backgroundColor: '#333A45' } },
},
separator: {
color: '#23252B',
// 悬停/拖拽分隔条时的底色(库默认 8% 蓝在纯黑底上不可见,加重为可感知的拖拽提示)
activeBackgroundColor: 'rgba(37, 99, 235, 0.30)',
},
};
}

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { AmountBar } from '@/api/types';
// 两市成交额历史柱状图(近 120 交易日):左侧交易额数值轴 + 网格线,
// 单色柱(今日盘中柱调淡 + 脉冲圆点标注hover 高亮并提示日期/金额。
const props = defineProps<{ bars: AmountBar[] }>();
const BAR_COLOR = '#3B82F6';
const AXIS_W = 46; // 左侧轴标签列宽px
interface BarGeom {
i: number;
x: number; y: number; w: number; h: number; // viewBox 0..1 坐标y 向下)
}
/** 轴刻度文案0 / 千亿 / 万亿(亿元 -> 中文量级) */
function fmtAxis(v: number): string {
if (v === 0) return '0';
if (v >= 10000) return `${(v / 10000) % 1 === 0 ? (v / 10000).toFixed(0) : (v / 10000).toFixed(1)}万亿`;
if (v >= 1000) return `${Math.round(v / 1000)}千亿`;
return `${Math.round(v)}亿`;
}
const model = computed(() => {
const bars = props.bars;
const n = bars.length;
if (n < 5) return null;
// 选「刻度数 <= 6」的最小步长向上取整成整数刻度上限柱高按 ceiling 归一)
const rawMax = Math.max(...bars.map((b) => b.amount));
const steps = [500, 1000, 2000, 2500, 5000, 10000, 20000, 25000, 50000];
const step = steps.find((s) => Math.ceil(rawMax / s) <= 6) ?? 100000;
const ceiling = Math.ceil(rawMax / step) * step;
const ticks = Array.from({ length: Math.ceil(ceiling / step) + 1 }, (_, k) => k * step);
const geoms: BarGeom[] = bars.map((b, i) => {
const h = (b.amount / ceiling) * 0.96; // 顶部留 4% 余量
return { i, x: (i + 0.14) / n, w: 0.72 / n, y: 1 - h, h };
});
return { n, geoms, ticks, lastBar: bars[n - 1], lastGeom: geoms[n - 1] };
});
// ---------- hover最近柱高亮 + tooltip ----------
const hoverIdx = ref<number | null>(null);
function onMove(e: MouseEvent) {
const m = model.value;
if (!m) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const t = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
hoverIdx.value = Math.min(m.n - 1, Math.max(0, Math.floor(t * m.n)));
}
const hoverTip = computed(() => {
const m = model.value;
if (!m || hoverIdx.value == null) return null;
const g = m.geoms[hoverIdx.value];
const b = props.bars[hoverIdx.value];
return {
x: g.x + g.w / 2,
text: `${b.date} · ${Math.round(b.amount).toLocaleString('zh-CN')}亿`,
intraday: !!b.intraday,
};
});
function fmtAmount(v: number | null | undefined): string {
if (v == null) return '--';
return Math.round(v).toLocaleString('zh-CN');
}
</script>
<template>
<div v-if="model" class="rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3" role="img" aria-label="沪深两市近120个交易日成交额柱状图">
<!-- 头部最新值 + 窗口说明 -->
<div class="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[#6B7280]">
<span>两市成交额
<span class="ml-1 font-mono tabular-nums text-[#E5E7EB]">{{ fmtAmount(model.lastBar.amount) }}亿</span>
<span v-if="model.lastBar.intraday" class="ml-1 text-[10px] text-blue-300">今日盘中</span>
</span>
<span class="text-[10px]"> {{ model.n }} 个交易日</span>
</div>
<div class="flex h-28">
<!-- 交易额数值轴刻度按数据分数定位 -->
<div class="relative shrink-0" :style="{ width: AXIS_W + 'px' }">
<span
v-for="t in model.ticks"
:key="t"
class="absolute right-1 -translate-y-1/2 font-mono text-[10px] tabular-nums text-[#6B7280]"
:style="{ top: `${(1 - t / model.ticks[model.ticks.length - 1]) * 100}%` }"
>{{ fmtAxis(t) }}</span>
</div>
<!-- 绘图区网格线 + viewBox 0..1 非等比拉伸rect 无描边不受影响 -->
<div class="relative flex-1" @mousemove="onMove" @mouseleave="hoverIdx = null">
<svg class="h-full w-full" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<line
v-for="t in model.ticks"
:key="'g' + t"
x1="0" :y1="1 - t / model.ticks[model.ticks.length - 1]"
x2="1" :y2="1 - t / model.ticks[model.ticks.length - 1]"
stroke="#26272E" stroke-width="1" vector-effect="non-scaling-stroke"
/>
<rect
v-for="g in model.geoms"
:key="g.i"
:x="g.x" :y="g.y" :width="g.w" :height="g.h"
:fill="BAR_COLOR"
:fill-opacity="props.bars[g.i].intraday ? 0.45 : 0.75"
:stroke="hoverIdx === g.i ? '#E5E7EB' : 'none'"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- 盘中 bar 顶部脉冲圆点HTML 圆点保证正圆 sparkline 端点同款 -->
<span
v-if="model.lastBar.intraday"
class="pointer-events-none absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-full"
:style="{ left: `${(model.lastGeom.x + model.lastGeom.w / 2) * 100}%`, top: `${model.lastGeom.y * 100}%`, backgroundColor: BAR_COLOR }"
/>
<!-- tooltip sparkline 同款样式 -->
<span
v-if="hoverTip"
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, hoverTip.x * 100))}%` }"
>{{ hoverTip.text }}<span v-if="hoverTip.intraday" class="ml-1 text-blue-300">盘中</span></span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ApiError, getStockCompany } from '@/api/client';
import type { StockCompanyInfo } from '@/api/types';
const props = defineProps<{ tsCode: string }>();
// ETF 无公司简介(沪 51/56/58、深 159 开头),本地短路免打无谓请求(与后端 is_etf_symbol 同口径)
const isEtf = /^(51|56|58|159)/.test(props.tsCode.split('.')[0]);
const expanded = ref(false);
const info = ref<StockCompanyInfo | null>(null);
const loading = ref(false);
const miss = ref(false); // 404确认无数据ETF 已前置,此处为 tushare 无此股),整节隐藏
const loadErr = ref<string | null>(null);
/** 详情打开即拉(与 K 线并行);组件经 :key 随切股重挂,无乱序回填问题。 */
async function load() {
if (loading.value) return;
loading.value = true;
loadErr.value = null;
try {
info.value = await getStockCompany(props.tsCode);
} catch (e) {
if (e instanceof ApiError && e.status === 404) miss.value = true;
else loadErr.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false; // 组件卸载后写 ref 无害Vue3 no-op
}
}
onMounted(() => {
if (!isEtf) void load();
});
// '19871222' -> '1987-12-22'(长度 8 才转,否则原样)
function fmtSetup(v: string): string {
return v.length === 8 ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : v;
}
// tushare 原始单位万元过亿换算展示1940591.82 万元 -> 194.06 亿元)
function fmtCapital(v: number): string {
return v >= 1e4 ? `${(v / 1e4).toFixed(2)} 亿元` : `${v.toFixed(2)} 万元`;
}
function fmtEmployees(v: number): string {
return v.toLocaleString('zh-CN');
}
// 短字段网格(空值行整体隐藏)
const rows = computed<[string, string][]>(() => {
const c = info.value;
if (!c) return [];
const region = [c.province, c.city].filter(Boolean).join(' · ');
return (
[
['法人代表', c.chairman],
['总经理', c.manager],
['董秘', c.secretary],
['注册资本', c.reg_capital != null ? fmtCapital(c.reg_capital) : null],
['注册时间', c.setup_date ? fmtSetup(c.setup_date) : null],
['所在地', region || null],
['员工人数', c.employees != null ? fmtEmployees(c.employees) : null],
] as [string, string | null | undefined][]
).filter((r): r is [string, string] => r[1] != null && r[1] !== '');
});
// 长文本块(公司介绍 / 主要业务及产品 / 经营范围)
const texts = computed<[string, string][]>(() => {
const c = info.value;
if (!c) return [];
return (
[
['公司介绍', c.introduction],
['主要业务及产品', c.main_business],
['经营范围', c.business_scope],
] as [string, string | null | undefined][]
).filter((r): r is [string, string] => !!r[1]);
});
// tushare 返回的主机名多不带协议bank.pingan.com补 https:// 才能当外链点
const websiteHref = computed<string | null>(() => {
const w = info.value?.website;
if (!w) return null;
return /^https?:\/\//i.test(w) ? w : `https://${w}`;
});
</script>
<template>
<div v-if="!isEtf && !miss" class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<button
class="flex w-full items-center justify-between text-[13px] text-[#9BA3AE] transition-colors hover:text-[#E8EAED]"
@click="expanded = !expanded"
>
<span>公司简介</span>
<svg
class="h-3.5 w-3.5 transition-transform" :class="expanded ? 'rotate-90' : ''"
viewBox="0 0 16 16" fill="none"
>
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<template v-if="expanded">
<div v-if="loading" class="mt-2 text-[13px] text-[#9BA3AE]">加载中</div>
<div v-else-if="loadErr" class="mt-2 text-[13px] text-[#9BA3AE]">
{{ loadErr }}
<button class="ml-1 text-blue-400 hover:underline" @click="load">重试</button>
</div>
<template v-else-if="info">
<div v-if="info.com_name" class="mt-2 truncate text-[13px] text-[#C3C9D2]" :title="info.com_name">
{{ info.com_name }}
</div>
<div v-if="rows.length" class="mt-2 grid grid-cols-2 gap-y-2">
<template v-for="(row, i) in rows" :key="i">
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="truncate text-right text-[#E8EAED]" :title="row[1]">{{ row[1] }}</span>
</template>
</div>
<a
v-if="websiteHref"
:href="websiteHref" target="_blank" rel="noopener noreferrer"
class="mt-2 block truncate text-[13px] text-blue-400 hover:underline"
:title="info.website ?? undefined"
>{{ info.website }}</a>
<div v-for="(t, i) in texts" :key="i" class="mt-3">
<div class="mb-1 text-xs text-[#7A818C]">{{ t[0] }}</div>
<p class="whitespace-pre-line break-words text-[13px] leading-relaxed text-[#C3C9D2]">{{ t[1] }}</p>
</div>
</template>
</template>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useEtfSyncStore } from '@/stores/etfSync';
const store = useEtfSyncStore();
onMounted(async () => {
await store.fetchStatus();
store.pollIfRunning();
});
onBeforeUnmount(() => store.stopPolling());
/** "2026-09-01T00:00:00" / "2026-09-01" -> "2026年09月01日";无数据显示 — */
function fmtDate(s?: string | null): string {
if (!s) return '—';
const d = s.slice(0, 10);
const [y, m, day] = d.split('-');
if (!y || !m || !day) return d;
return `${y}${m}${day}`;
}
const latestDate = computed(() => store.syncStatus?.last_trade_date ?? null);
const running = computed(() => !!store.syncStatus?.running);
const etfCount = computed(() => store.syncStatus?.stats?.etfs ?? 0);
const errText = computed(() => store.error || store.syncStatus?.error || null);
const progressPct = computed(() => {
const s = store.syncStatus;
if (!s?.total) return 0;
return Math.min(100, ((s.done ?? 0) / s.total) * 100);
});
</script>
<template>
<div class="mb-4 flex flex-wrap items-center gap-x-5 gap-y-3 rounded-xl border border-[#26272E] bg-[#101014] px-5 py-3.5">
<!-- 最新更新日期 -->
<div class="flex items-center gap-3">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</span>
<div>
<div class="text-xs text-[#6B7280]">ETF 日线数据更新至<template v-if="etfCount"> · {{ etfCount.toLocaleString() }} </template></div>
<div class="text-sm font-semibold tabular-nums text-[#E5E7EB]">{{ fmtDate(latestDate) }}</div>
</div>
</div>
<span class="hidden flex-1 sm:block"></span>
<!-- 同步中进度条 -->
<div v-if="running" class="flex min-w-[220px] flex-1 items-center gap-2">
<svg class="h-4 w-4 shrink-0 animate-spin text-violet-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>
<span class="whitespace-nowrap text-[13px] text-[#A8AFB8]">
{{ store.syncStatus?.step || '同步中…' }}<template v-if="store.syncStatus?.total">{{ store.syncStatus?.done }}/{{ store.syncStatus?.total }}</template>
</span>
<span v-if="store.syncStatus?.total" class="h-1.5 flex-1 overflow-hidden rounded-full bg-[#26272E]">
<span class="block h-full rounded-full bg-violet-500 transition-all" :style="{ width: progressPct + '%' }" />
</span>
</div>
<!-- 空闲手动同步按钮 -->
<button v-else type="button" class="shrink-0 rounded-md border border-violet-500/40 bg-violet-500/15 px-3.5 py-2 text-sm font-medium text-violet-300 transition hover:bg-violet-500/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black" @click="store.startSync()">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-2.6-6.4M21 3v6h-6" /></svg>
同步ETF数据
</button>
<!-- 错误提示 -->
<div v-if="errText" class="w-full text-sm text-amber-400">
<svg class="mr-1 inline h-4 w-4 align-[-3px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ errText }}
</div>
</div>
</template>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ApiError, getStockFinance } from '@/api/client';
import type { StockFinanceRecord } from '@/api/types';
const props = defineProps<{ tsCode: string }>();
const emit = defineEmits<{
/** 加载成功上报近五年记录(报告期倒序),父组件用于分红率等跨源指标 */
(e: 'loaded', records: StockFinanceRecord[]): void;
}>();
// ETF 无财务数据(沪 51/56/58、深 159 开头),本地短路免打无谓请求(与后端 is_etf_symbol 同口径)
const isEtf = /^(51|56|58|159)/.test(props.tsCode.split('.')[0]);
const expanded = ref(true); // 财务是详情页核心信息,默认展开(公司简介默认折叠)
const records = ref<StockFinanceRecord[]>([]);
const loading = ref(false);
const miss = ref(false); // 404确认无数据新股/退市老股),整节隐藏
const loadErr = ref<string | null>(null);
const selEnd = ref(''); // 当前展示的报告期(默认最新)
/** 详情打开即拉(与 K 线并行);组件经 :key 随切股重挂,无乱序回填问题。 */
async function load() {
if (loading.value) return;
loading.value = true;
loadErr.value = null;
try {
const res = await getStockFinance(props.tsCode);
records.value = res.records;
selEnd.value = res.records[0]?.end_date ?? '';
if (res.records.length) emit('loaded', res.records);
} catch (e) {
if (e instanceof ApiError && e.status === 404) miss.value = true;
else loadErr.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false; // 组件卸载后写 ref 无害Vue3 no-op
}
}
onMounted(() => {
if (!isEtf) void load();
});
// ---------- 格式化 ----------
const fmtNum = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
const fmtPct = (v: number | null | undefined) => (v == null ? '—' : v.toFixed(2) + '%');
const pctClass = (v: number | null | undefined) => (v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '');
/** 元 -> 亿(表头已注明单位;亿元以下用万,避免一串 0.00 */
const fmtYi = (v: number | null | undefined) => {
if (v == null) return '—';
const a = Math.abs(v);
if (a >= 1e8) return (v / 1e8).toFixed(2);
if (a >= 1e4) return (v / 1e4).toFixed(0) + '万';
return v.toFixed(0);
};
/** 报告期标签:'20260630' -> '2026-06-30 中报' */
const PERIOD_NAMES: Record<string, string> = { '0331': '一季报', '0630': '中报', '0930': '三季报', '1231': '年报' };
function periodLabel(end: string): string {
if (end.length !== 8) return end;
return `${end.slice(0, 4)}-${end.slice(4, 6)}-${end.slice(6, 8)} ${PERIOD_NAMES[end.slice(4)] ?? ''}`.trim();
}
// 当前选中的报告期记录
const sel = computed(() => records.value.find((r) => r.end_date === selEnd.value) ?? records.value[0] ?? null);
// 最新报告期关键指标(一行两列;同比项红涨绿跌)
const rows = computed<[string, string, string][]>(() => {
const r = sel.value;
if (!r) return [];
return (
[
['每股收益(元)', fmtNum(r.eps)],
['每股净资产(元)', fmtNum(r.bps)],
['每股经营现金流', fmtNum(r.ocfps)],
['ROE', fmtPct(r.roe)],
['扣非ROE', fmtPct(r.roe_dt)],
['毛利率', fmtPct(r.grossprofit_margin)],
['净利率', fmtPct(r.netprofit_margin)],
['资产负债率', fmtPct(r.debt_to_assets)],
['营业收入(亿)', fmtYi(r.total_revenue)],
['归母净利润(亿)', fmtYi(r.n_income_attr_p)],
['扣非净利润(亿)', fmtYi(r.profit_dedt)],
['经营现金流(亿)', fmtYi(r.n_cashflow_act)],
['总资产(亿)', fmtYi(r.total_assets)],
['归母净资产(亿)', fmtYi(r.total_hldr_eqy)],
['研发投入(亿)', fmtYi(r.rd_exp)],
['营收同比', fmtPct(r.or_yoy), pctClass(r.or_yoy)],
['归母净利同比', fmtPct(r.netprofit_yoy), pctClass(r.netprofit_yoy)],
['扣非净利同比', fmtPct(r.dt_netprofit_yoy), pctClass(r.dt_netprofit_yoy)],
] as [string, string, string | undefined][]
).map((r2) => [r2[0], r2[1], r2[2] ?? ''] as [string, string, string]);
});
// 近五年年报records 按报告期倒序,取 1231 结尾的前 6 个年度)
const annuals = computed(() => records.value.filter((r) => r.end_date.endsWith('1231')).slice(0, 6));
</script>
<template>
<div v-if="!isEtf && !miss" class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<button
class="flex w-full items-center justify-between text-[13px] text-[#9BA3AE] transition-colors hover:text-[#E8EAED]"
@click="expanded = !expanded"
>
<span>财务指标</span>
<svg
class="h-3.5 w-3.5 transition-transform" :class="expanded ? 'rotate-90' : ''"
viewBox="0 0 16 16" fill="none"
>
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<template v-if="expanded">
<div v-if="loading" class="mt-2 text-[13px] text-[#9BA3AE]">
加载中<span v-if="!records.length">近五年财务数据首次拉取约需数秒</span>
</div>
<div v-else-if="loadErr" class="mt-2 text-[13px] text-[#9BA3AE]">
{{ loadErr }}
<button class="ml-1 text-blue-400 hover:underline" @click="load">重试</button>
</div>
<template v-else-if="records.length">
<!-- 报告期切换默认最新可翻近五年的任一季报/年报 -->
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-[13px] text-[#C3C9D2]">{{ periodLabel(sel?.end_date ?? '') }}</span>
<select
v-model="selEnd"
class="max-w-36 rounded border border-[#33353D] bg-[#16181D] px-1 py-0.5 font-mono text-xs text-[#C3C9D2] outline-none"
title="切换报告期"
>
<option v-for="r in records" :key="r.end_date" :value="r.end_date">{{ periodLabel(r.end_date) }}</option>
</select>
</div>
<div v-if="rows.length" class="mt-2 grid grid-cols-2 gap-y-2">
<template v-for="(row, i) in rows" :key="i">
<span class="text-[#9BA3AE]">{{ row[0] }}</span>
<span class="truncate text-right text-[#E8EAED]" :class="row[2]" :title="row[1]">{{ row[1] }}</span>
</template>
</div>
<!-- 近五年年报趋势营收/净利按当年同比着色红涨绿跌 -->
<div v-if="annuals.length" class="mt-3">
<div class="mb-1 text-xs text-[#7A818C]">近五年年报</div>
<table class="w-full font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="py-0.5 text-left font-normal">年度</th>
<th class="text-right font-normal">营收亿</th>
<th class="text-right font-normal">净利亿</th>
<th class="text-right font-normal">EPS</th>
<th class="text-right font-normal">ROE</th>
</tr>
</thead>
<tbody>
<tr v-for="a in annuals" :key="a.end_date" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ a.end_date.slice(0, 4) }}</td>
<td class="text-right" :class="pctClass(a.or_yoy)">{{ fmtYi(a.total_revenue) }}</td>
<td class="text-right" :class="pctClass(a.netprofit_yoy)">{{ fmtYi(a.n_income_attr_p) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(a.eps) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtPct(a.roe) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<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>

View File

@@ -0,0 +1,467 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ApiError, getStockReference } from '@/api/client';
import { REFERENCE_KINDS, type StockReferenceRecord } from '@/api/types';
const props = defineProps<{ tsCode: string }>();
// ETF 无参考数据(沪 51/56/58、深 159 开头),本地短路免打无谓请求(与后端 is_etf_symbol 同口径)
const isEtf = /^(51|56|58|159)/.test(props.tsCode.split('.')[0]);
// 默认折叠:右栏已有行情/分红/财务/简介,参考数据属低频深挖信息
const expanded = ref(false);
const activeKind = ref('top10_holders');
const activeLabel = computed(() => REFERENCE_KINDS.find((k) => k.key === activeKind.value)?.label ?? '');
// 分类缓存(切股经 :key 重挂组件自动清空);失败不缓存,保留重试机会。
// 必须 reactiverecords computed 依赖 cache.get普通 Map 的 set 不触发重算——
// 首次加载完成后面板会停在 fallback要再切一次分类才能看到数据
const cache = reactive(new Map<string, StockReferenceRecord[]>());
const loading = ref(false);
const loadErr = ref<string | null>(null);
let seq = 0;
async function ensure(kind: string, force = false) {
if (!force && cache.has(kind)) return;
const my = ++seq;
loading.value = true;
loadErr.value = null;
try {
const res = await getStockReference(props.tsCode, kind);
cache.set(kind, res.records);
if (my === seq) loadErr.value = null;
} catch (e) {
if (my === seq && kind === activeKind.value) {
loadErr.value = e instanceof Error ? e.message : String(e);
}
} finally {
if (my === seq) loading.value = false;
}
}
watch([expanded, activeKind], ([open, kind]) => {
if (open) void ensure(kind);
});
const records = computed<StockReferenceRecord[] | undefined>(() => cache.get(activeKind.value));
// ---------- 超长列表展开/收起(切换分类时重置) ----------
const PAGE_LIMIT = 20;
const showAll = ref(false);
watch(activeKind, () => { showAll.value = false; });
function paged<T>(rows: T[]): T[] {
return showAll.value ? rows : rows.slice(0, PAGE_LIMIT);
}
// ---------- 宽松行取值(后端 records 字段随 kind 而异JSON 数值/字符串按类型收窄) ----------
const N = (r: StockReferenceRecord, k: string): number | null => (typeof r[k] === 'number' ? (r[k] as number) : null);
const S = (r: StockReferenceRecord, k: string): string | null => (typeof r[k] === 'string' ? (r[k] as string) : null);
// ---------- 格式化 ----------
const fmtNum = (v: number | null | undefined, d = 2) => (v == null ? '—' : v.toFixed(d));
const fmtInt = (v: number | null | undefined) => (v == null ? '—' : Math.round(v).toLocaleString('zh-CN'));
const fmtYmd8 = (s: string | null) => (s && s.length === 8 ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s ?? '—');
/** 股数亿股2 位)/ 万股 */
const fmtShares = (v: number | null | undefined) => {
if (v == null) return '—';
const a = Math.abs(v);
if (a >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (a >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(Math.round(v));
};
/** 元 -> 亿/万(回购金额);万元原样万/亿(大宗金额) */
const fmtYuan = (v: number | null | undefined) => {
if (v == null) return '—';
const a = Math.abs(v);
if (a >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (a >= 1e4) return (v / 1e4).toFixed(0) + '万';
return v.toFixed(0);
};
const fmtWan = (v: number | null | undefined) => {
if (v == null) return '—';
return Math.abs(v) >= 1e4 ? (v / 1e4).toFixed(2) + '亿' : v.toFixed(0) + '万';
};
const pctClass = (v: number | null | undefined) => (v == null ? '' : v > 0 ? 'text-up' : v < 0 ? 'text-down' : '');
const pctText = (v: number | null | undefined) => (v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(2) + '%');
// ---------- top10股东/流通股东共用渲染:报告期下拉 + 期内持股表) ----------
const isTop10 = computed(() => activeKind.value === 'top10_holders' || activeKind.value === 'top10_floatholders');
const top10Periods = computed(() => {
if (!isTop10.value || !records.value) return [];
return [...new Set(records.value.map((r) => S(r, 'end_date')).filter((d): d is string => !!d))];
});
const selPeriod = ref('');
watch(top10Periods, (ps) => { selPeriod.value = ps[0] ?? ''; }, { immediate: true });
const top10Rows = computed(() => (records.value ?? []).filter((r) => S(r, 'end_date') === selPeriod.value));
const isFloatHolders = computed(() => activeKind.value === 'top10_floatholders');
// ---------- 股东人数环比records 按截止日倒序,环比对上一行) ----------
function holderNumDelta(i: number): number | null {
const rs = records.value ?? [];
const cur = N(rs[i], 'holder_num');
const prev = i + 1 < rs.length ? N(rs[i + 1], 'holder_num') : null;
if (cur == null || prev == null || prev === 0) return null;
return ((cur - prev) / prev) * 100;
}
// ---------- 增减持方向 ----------
const inDeClass = (r: StockReferenceRecord) => (S(r, 'in_de') === 'IN' ? 'text-up' : S(r, 'in_de') === 'DE' ? 'text-down' : '');
// ---------- 解禁未来高亮 ----------
const today8 = new Date().toISOString().slice(0, 10).replaceAll('-', '');
const isFutureFloat = (r: StockReferenceRecord) => (S(r, 'float_date') ?? '') > today8;
</script>
<template>
<div v-if="!isEtf" class="mt-4 border-t border-[#1E2026] pt-3 text-sm">
<button
class="flex w-full items-center justify-between text-[13px] text-[#9BA3AE] transition-colors hover:text-[#E8EAED]"
@click="expanded = !expanded"
>
<span>参考数据</span>
<svg class="h-3.5 w-3.5 transition-transform" :class="expanded ? 'rotate-90' : ''" viewBox="0 0 16 16" fill="none">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<template v-if="expanded">
<!-- 分类切换 chips加载中的分类带旋转指示 -->
<div class="mt-2 flex flex-wrap gap-1">
<button
v-for="k in REFERENCE_KINDS"
:key="k.key"
type="button"
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors"
:class="activeKind === k.key
? 'border-blue-500 bg-blue-500/15 text-blue-300'
: 'border-[#33353D] text-[#A8AFB8] hover:border-[#3A3D46] hover:text-[#E8EAED]'"
@click="activeKind = k.key"
>
<svg
v-if="loading && k.key === activeKind"
class="h-3 w-3 animate-spin text-blue-400" viewBox="0 0 24 24" fill="none"
><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="6" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /></svg>
{{ k.label }}
</button>
</div>
<div class="mt-2 min-h-10">
<!-- 加载中旋转动画暂无数据明确区分那是在查询这是查完没有 -->
<div v-if="loading && !records" class="flex items-center gap-2 py-3 text-[13px] text-[#9BA3AE]">
<svg class="h-4 w-4 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>
正在查询{{ activeLabel }}
</div>
<div v-else-if="loadErr && !records" class="py-2 text-[13px] text-[#9BA3AE]">
{{ loadErr }}
<button class="ml-1 text-blue-400 hover:underline" @click="ensure(activeKind, true)">重试</button>
</div>
<template v-else-if="records">
<!-- 前十大股东 / 前十大流通股东 -->
<template v-if="isTop10">
<div v-if="!records.length" class="py-2 text-[13px] text-[#9BA3AE]">暂无数据</div>
<template v-else>
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-[#7A818C]">{{ isFloatHolders ? '十大流通股东' : '十大股东' }}</span>
<select
v-model="selPeriod"
class="max-w-36 rounded border border-[#33353D] bg-[#16181D] px-1 py-0.5 font-mono text-xs text-[#C3C9D2] outline-none"
title="切换报告期"
>
<option v-for="p in top10Periods" :key="p" :value="p">{{ fmtYmd8(p) }}</option>
</select>
</div>
<table class="mt-1 w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-5 py-0.5 text-left font-normal">#</th>
<th class="text-left font-normal">股东</th>
<th class="w-14 text-right font-normal">持股</th>
<th class="w-11 text-right font-normal">占比%</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in top10Rows" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#7A818C]">{{ i + 1 }}</td>
<td class="break-words py-0.5 pr-1 font-sans text-[#C3C9D2]" :title="`${S(r, 'holder_name') ?? ''}${S(r, 'holder_type') ?? '—'}`">
{{ S(r, 'holder_name') ?? '—' }}
</td>
<td class="text-right text-[#E8EAED]">{{ fmtShares(N(r, 'hold_amount')) }}</td>
<td class="text-right" :class="pctClass(N(r, 'hold_change'))" :title="`持股变动 ${fmtShares(N(r, 'hold_change'))}`">
{{ fmtNum(N(r, isFloatHolders ? 'hold_float_ratio' : 'hold_ratio')) }}
</td>
</tr>
</tbody>
</table>
</template>
</template>
<!-- 质押统计周频截面表 -->
<template v-else-if="activeKind === 'pledge_stat'">
<div v-if="!records.length" class="py-2 text-[13px] text-[#9BA3AE]">暂无质押数据</div>
<template v-else>
<table class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="py-0.5 text-left font-normal">截止日</th>
<th class="text-right font-normal">质押%</th>
<th class="w-9 text-right font-normal">次数</th>
<th class="text-right font-normal">无限售万股</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'end_date')) }}</td>
<td class="text-right" :class="N(r, 'pledge_ratio') != null && N(r, 'pledge_ratio')! > 50 ? 'text-down' : 'text-[#E8EAED]'">
{{ fmtNum(N(r, 'pledge_ratio')) }}
</td>
<td class="text-right text-[#E8EAED]">{{ fmtInt(N(r, 'pledge_count')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'unrest_pledge'), 0) }}</td>
</tr>
</tbody>
</table>
<button
v-if="records.length > PAGE_LIMIT"
type="button"
class="mt-1 text-[11px] text-blue-400 hover:underline"
@click="showAll = !showAll"
>{{ showAll ? '收起' : `展开全部 ${records.length}` }}</button>
</template>
</template>
<!-- 质押明细 -->
<table v-else-if="activeKind === 'pledge_detail' && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">公告日</th>
<th class="text-left font-normal">股东</th>
<th class="w-14 text-right font-normal">万股</th>
<th class="w-11 text-right font-normal">占总股%</th>
<th class="w-[26%] text-right font-normal">解押</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'ann_date')) }}</td>
<td class="break-words py-0.5 pr-1 font-sans text-[#C3C9D2]" :title="`${S(r, 'holder_name') ?? ''}|质押方 ${S(r, 'pledgor') ?? '—'}`">
{{ S(r, 'holder_name') ?? '—' }}
</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'pledge_amount')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'p_total_ratio')) }}</td>
<td class="break-words text-right" :class="S(r, 'is_release') === '1' ? 'text-[#7A818C]' : 'text-up'">
{{ S(r, 'is_release') === '1' ? fmtYmd8(S(r, 'release_date')) : '在押' }}
</td>
</tr>
</tbody>
</table>
<!-- 回购全市场回填管道空数据可能是回填中 -->
<table v-else-if="activeKind === 'repurchase' && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">公告日</th>
<th class="w-[26%] text-left font-normal">进度</th>
<th class="text-right font-normal">数量</th>
<th class="text-right font-normal">金额</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'ann_date')) }}</td>
<td class="break-words py-0.5 font-sans text-[#C3C9D2]" :title="`价格区间 ${fmtNum(N(r, 'low_limit'))} ~ ${fmtNum(N(r, 'high_limit'))}|截止 ${fmtYmd8(S(r, 'end_date'))}`">
{{ S(r, 'proc') ?? '—' }}
</td>
<td class="text-right text-[#E8EAED]">{{ fmtShares(N(r, 'vol')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtYuan(N(r, 'amount')) }}</td>
</tr>
</tbody>
</table>
<!-- 限售解禁未来日期高亮股东数并排列在类型里 -->
<table v-else-if="activeKind === 'share_float' && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">解禁日</th>
<th class="text-left font-normal">类型</th>
<th class="w-14 text-right font-normal">亿股</th>
<th class="w-12 text-right font-normal">占比%</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5" :class="isFutureFloat(r) ? 'text-amber-400' : 'text-[#9BA3AE]'">
{{ fmtYmd8(S(r, 'float_date')) }}<span v-if="isFutureFloat(r)" title="未到期解禁"> </span>
</td>
<td class="break-words py-0.5 pr-1 font-sans text-[#C3C9D2]" :title="`${S(r, 'holder_name') ?? ''}|公告 ${fmtYmd8(S(r, 'ann_date'))}`">
{{ S(r, 'share_type') ?? '—' }}
</td>
<td class="text-right text-[#E8EAED]">{{ fmtShares(N(r, 'float_share')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'float_ratio')) }}</td>
</tr>
</tbody>
</table>
<!-- 大宗交易 -->
<table v-else-if="activeKind === 'block_trade' && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">日期</th>
<th class="w-11 text-right font-normal"></th>
<th class="w-12 text-right font-normal">万股</th>
<th class="w-12 text-right font-normal">万元</th>
<th class="text-left font-normal">买方</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'trade_date')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'price')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'vol')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtWan(N(r, 'amount')) }}</td>
<td class="break-words py-0.5 pl-1 font-sans text-[#C3C9D2]" :title="`买 ${S(r, 'buyer') ?? '—'}\n卖 ${S(r, 'seller') ?? '—'}`">
{{ S(r, 'buyer') ?? '—' }}
</td>
</tr>
</tbody>
</table>
<!-- 资金流向同花顺口径万元最新一期摘要 + 日频净额表 -->
<template v-else-if="activeKind === 'moneyflow'">
<div v-if="!records.length" class="py-2 text-[13px] text-[#9BA3AE]">暂无数据</div>
<template v-else>
<div class="grid grid-cols-2 gap-y-1 text-[13px]">
<span class="text-[#9BA3AE]">资金净流入</span>
<span class="text-right font-mono" :class="pctClass(records[0]?.net_amount)">
{{ records[0]?.net_amount == null ? '—' : fmtWan(records[0].net_amount) }}
</span>
<span class="text-[#9BA3AE]" title="近 5 个交易日主力净额(源头 2027-07 起停供)">5日主力净额</span>
<span class="text-right font-mono" :class="pctClass(records[0]?.net_d5_amount)">
{{ records[0]?.net_d5_amount == null ? '—' : fmtWan(records[0].net_d5_amount) }}
</span>
</div>
<table class="mt-1.5 w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">日期</th>
<th class="w-12 text-right font-normal">涨跌%</th>
<th class="text-right font-normal">净流入万</th>
<th class="text-right font-normal">大单万</th>
<th class="w-11 text-right font-normal">大单占%</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60"
:title="`中单 ${fmtWan(N(r, 'buy_md_amount'))}${fmtNum(N(r, 'buy_md_amount_rate'))}%)|小单 ${fmtWan(N(r, 'buy_sm_amount'))}${fmtNum(N(r, 'buy_sm_amount_rate'))}%)|收盘 ${fmtNum(N(r, 'latest'))}`">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'trade_date')) }}</td>
<td class="text-right" :class="pctClass(N(r, 'pct_change'))">{{ fmtNum(N(r, 'pct_change')) }}</td>
<td class="text-right" :class="pctClass(N(r, 'net_amount'))">{{ fmtWan(N(r, 'net_amount')) }}</td>
<td class="text-right" :class="pctClass(N(r, 'buy_lg_amount'))">{{ fmtWan(N(r, 'buy_lg_amount')) }}</td>
<td class="text-right" :class="pctClass(N(r, 'buy_lg_amount_rate'))">{{ fmtNum(N(r, 'buy_lg_amount_rate'), 1) }}</td>
</tr>
</tbody>
</table>
<button
v-if="records.length > PAGE_LIMIT"
type="button"
class="mt-1 text-[11px] text-blue-400 hover:underline"
@click="showAll = !showAll"
>{{ showAll ? '收起' : `展开全部 ${records.length}` }}</button>
</template>
</template>
<!-- 股东人数截止期截面 + 环比 -->
<template v-else-if="activeKind === 'holdernumber'">
<div v-if="!records.length" class="py-2 text-[13px] text-[#9BA3AE]">暂无数据</div>
<template v-else>
<table class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="py-0.5 text-left font-normal">截止日</th>
<th class="text-right font-normal">股东户数</th>
<th class="w-[30%] text-right font-normal">环比</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'end_date')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtInt(N(r, 'holder_num')) }}</td>
<td class="text-right" :class="pctClass(holderNumDelta(i))">{{ pctText(holderNumDelta(i)) }}</td>
</tr>
</tbody>
</table>
<button
v-if="records.length > PAGE_LIMIT"
type="button"
class="mt-1 text-[11px] text-blue-400 hover:underline"
@click="showAll = !showAll"
>{{ showAll ? '收起' : `展开全部 ${records.length}` }}</button>
</template>
</template>
<!-- 股东增减持 -->
<table v-else-if="activeKind === 'holdertrade' && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">公告日</th>
<th class="text-left font-normal">股东</th>
<th class="w-9 text-right font-normal">方向</th>
<th class="w-14 text-right font-normal">数量</th>
<th class="w-11 text-right font-normal">均价</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]">{{ fmtYmd8(S(r, 'ann_date')) }}</td>
<td class="break-words py-0.5 pr-1 font-sans text-[#C3C9D2]" :title="`${S(r, 'holder_name') ?? ''}${S(r, 'holder_type') ?? '—'})|变动后占流通 ${fmtNum(N(r, 'after_ratio'))}%`">
{{ S(r, 'holder_name') ?? '—' }}
</td>
<td class="text-right" :class="inDeClass(r)">{{ S(r, 'in_de') === 'IN' ? '增持' : S(r, 'in_de') === 'DE' ? '减持' : '—' }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtShares(N(r, 'change_vol')) }}</td>
<td class="text-right text-[#E8EAED]">{{ fmtNum(N(r, 'avg_price')) }}</td>
</tr>
</tbody>
</table>
<!-- 异常波动 / 严重异常波动原因可换行 -->
<table v-else-if="(activeKind === 'shock' || activeKind === 'high_shock') && records.length" class="w-full table-fixed font-mono text-[11px] leading-4">
<thead>
<tr class="text-[#7A818C]">
<th class="w-[30%] py-0.5 text-left font-normal">日期</th>
<th class="text-left font-normal">异常说明</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in paged(records)" :key="i" class="border-t border-[#1E2026]/60">
<td class="py-0.5 text-[#9BA3AE]" :title="`异常期间 ${S(r, 'period') ?? '—'}${S(r, 'trade_market') ?? ''}`">
{{ fmtYmd8(S(r, 'trade_date')) }}
</td>
<td class="break-words py-0.5 pl-1 font-sans text-[#C3C9D2]">{{ S(r, 'reason') ?? '—' }}</td>
</tr>
</tbody>
</table>
<!-- 其余空态含回购回填中的提示 -->
<div v-else class="py-2 text-[13px] text-[#9BA3AE]">
暂无数据
<span v-if="activeKind === 'repurchase'" class="mt-1 block text-xs leading-4 text-[#7A818C]">
回购为全市场数据首次查询在后台回填近两年记录稍后切换回本页即有
</span>
</div>
<!-- 长表通用展开/收起pledge_stat/holdernumber 之外的表格 -->
<button
v-if="['pledge_detail','repurchase','share_float','block_trade','holdertrade','shock','high_shock'].includes(activeKind)
&& records.length > PAGE_LIMIT"
type="button"
class="mt-1 text-[11px] text-blue-400 hover:underline"
@click="showAll = !showAll"
>{{ showAll ? '收起' : `展开全部 ${records.length} 条` }}</button>
</template>
<div v-else class="py-2 text-[13px] text-[#9BA3AE]">点击上方分类加载数据</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
// 迷你走势归一化折线SVG viewBox=1x1 + preserveAspectRatio=none 拉伸,
// 描边 vector-effect=non-scaling-stroke 保证粗细不缩放;
// 端点/悬停点用 HTML 圆点(非等比 viewBox 会把 SVG 圆拉成椭圆)。
// 从主页大盘总览卡片抽出指数卡片两处共用。hover 出十字点与「日期 数值」提示。
import { computed, ref } from 'vue';
const props = defineProps<{
values: number[];
dates?: string[]; // 与 values 对齐的交易日YYYYMMDDhover 提示用
pct?: number | null; // 涨跌幅决定配色(正=涨色/负=跌色/缺=灰)
}>();
const PAD = 0.08; // 上下留白,避免贴边
const geom = computed(() => {
const vals = props.values;
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 * PAD) - 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 color = computed(() => {
const p = props.pct;
if (p == null || p === 0) return '#A8AFB8';
return p > 0 ? 'var(--color-up)' : 'var(--color-down)';
});
// hover相对坐标记录点索引tooltip 跟随
const hover = ref<{ i: number; x: number; y: number } | null>(null);
function onMove(e: MouseEvent) {
if (!geom.value) 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 * (props.values.length - 1));
hover.value = { i, x: geom.value.pts[i].x, y: geom.value.pts[i].y };
}
const hoverText = computed(() => {
const h = hover.value;
if (!h) return '';
const d = props.dates?.[h.i] ?? '';
const iso = d ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '';
const v = props.values[h.i];
return `${iso} ${v != null ? v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '--'}`;
});
</script>
<template>
<div class="relative h-9" @mousemove="onMove" @mouseleave="hover = null">
<svg v-if="geom" class="h-full w-full" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<path :d="geom.area" :fill="color" fill-opacity="0.1" />
<path
:d="geom.line"
fill="none"
:stroke="color"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<!-- 端点 2px 表面环与悬停点 -->
<span
v-if="geom"
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: `${geom.last.x * 100}%`, top: `${geom.last.y * 100}%`, backgroundColor: color }"
/>
<template v-if="hover && geom">
<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.x * 100}%`, top: `${hover.y * 100}%`, backgroundColor: color }"
/>
<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.x * 100))}%` }"
>{{ hoverText }}</span>
</template>
</div>
</template>

View File

@@ -0,0 +1,53 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getEtfSyncStatus, startEtfSync } from '@/api/client';
import type { EtfSyncStatus } from '@/api/types';
/** 全市场 ETF 同步ETF 页手动触发)。与 A 股同步 store 解耦,独立维护轮询。 */
export const useEtfSyncStore = defineStore('etfSync', () => {
const syncStatus = ref<EtfSyncStatus | null>(null);
const error = ref<string | null>(null); // 启动同步的请求级错误
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function fetchStatus() {
try {
syncStatus.value = await getEtfSyncStatus();
} catch {
/* 静默:状态拉取失败不阻塞页面 */
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
/** 正在同步时开始 2s 轮询;空闲则停轮询(进入页面时也调用,承接后台遗留任务)。 */
function pollIfRunning() {
if (syncStatus.value?.running) {
if (!pollTimer) {
pollTimer = setInterval(async () => {
await fetchStatus();
if (!syncStatus.value?.running) stopPolling();
}, 2000);
}
} else {
stopPolling();
}
}
async function startSync(full = false) {
error.value = null;
try {
await startEtfSync(full);
await fetchStatus();
pollIfRunning();
} catch (e) {
error.value = e instanceof Error ? e.message : '启动同步失败';
}
}
return { syncStatus, error, fetchStatus, startSync, stopPolling, pollIfRunning };
});

View File

@@ -0,0 +1,391 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { addWatchlist, getEtfs, removeWatchlist } from '@/api/client';
import type { EtfListItem, ScreenerItemOut } from '@/api/types';
import EtfSyncBar from '@/components/EtfSyncBar.vue';
import StockDetailOverlay from '@/components/StockDetailOverlay.vue';
// ---------- 筛选状态(初始值从路由 query 还原,刷新/分享链接不丢现场) ----------
const route = useRoute();
const router = useRouter();
function qStr(key: string): string | undefined {
const v = route.query[key];
return typeof v === 'string' && v ? v : undefined;
}
const MARKETS = ['全部', '自选', '沪市', '深市'];
// 排序键后端白名单symbol/close/pct_chg/amount/total_mv/circ_mv/turnover_rate
const SORT_KEYS = ['symbol', 'close', 'pct_chg', 'amount', 'total_mv', 'circ_mv', 'turnover_rate'] as const;
type SortKey = (typeof SORT_KEYS)[number];
const search = ref(qStr('q') ?? '');
const market = ref(MARKETS.includes(qStr('market') ?? '') ? (qStr('market') as string) : '全部');
const pageSize = 100;
const page = ref(Math.max(1, parseInt(qStr('page') ?? '1', 10) || 1));
const sortParam = qStr('sort');
// 默认按总市值降序——先看规模最大的 ETF
const sort = ref<SortKey>(SORT_KEYS.includes((sortParam ?? 'total_mv') as SortKey) ? ((sortParam ?? 'total_mv') as SortKey) : 'total_mv');
const order = ref<'asc' | 'desc'>(qStr('order') === 'asc' ? 'asc' : 'desc');
// 列表状态
const items = ref<EtfListItem[]>([]);
const total = ref(0);
const loading = ref(false);
const error = ref<string | null>(null);
// 详情浮层(当前 ETF 记录在 ?code=,刷新后浮层自动重开)
const previewCode = ref<string | null>(qStr('code') ?? null);
// StockDetailOverlay 需要 ScreenerItemOut 形状;行情字段缺失时它内部有兜底
const overlayItems = computed<ScreenerItemOut[]>(() =>
items.value.map((it) => ({
ts_code: it.ts_code,
name: it.name,
close: it.close ?? null,
pct_chg: it.pct_chg ?? null,
total_mv: it.total_mv ?? null,
circ_mv: it.circ_mv ?? null,
pe_ttm: null,
pb: null,
turnover_rate: it.turnover_rate ?? null,
indicators: {},
})),
);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
// ---------- 加载(搜索防抖) ----------
let fetchToken = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
async function load() {
const token = ++fetchToken;
loading.value = true;
error.value = null;
try {
const res = await getEtfs({
search: search.value.trim(),
// 「自选」不是 etf_basic.exchange 的值,走 watched_only
exchange: market.value === '全部' || market.value === '自选' ? '' : (market.value === '沪市' ? 'SH' : 'SZ'),
watched_only: market.value === '自选',
sort: sort.value,
order: order.value,
limit: pageSize,
offset: (page.value - 1) * pageSize,
});
if (token === fetchToken) {
items.value = res.items;
total.value = res.total;
}
} catch (e) {
if (token === fetchToken) error.value = e instanceof Error ? e.message : '加载失败';
} finally {
if (token === fetchToken) loading.value = false;
}
}
watch(search, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
page.value = 1;
load();
}, 300);
});
// 筛选/排序变化回到第一页page 的 watch 会再触发 load翻页直接加载
watch([market, sort, order], () => {
if (page.value !== 1) page.value = 1;
else load();
});
watch(page, () => load());
load();
onBeforeUnmount(() => clearTimeout(debounceTimer));
function pctClass(v: number | null | undefined): string {
if (v == null) return 'text-[#9BA3AE]';
return v > 0 ? 'text-up' : v < 0 ? 'text-down' : 'text-[#A8AFB8]';
}
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 '--';
const d = v.slice(0, 10);
// etf_basic.list_date 是 YYYYMMDD统一显示为 YYYY-MM-DD
return /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : d;
}
function fmtYi(v: number | null | undefined): string {
if (v == null) return '--';
return v >= 100 ? Math.round(v).toLocaleString() : v.toFixed(2);
}
function fmtTurnover(v: number | null | undefined): string {
if (v == null) return '--';
return `${v.toFixed(2)}%`;
}
// ---------- 列排序(后端白名单键) ----------
function toggleSort(key: SortKey) {
if (sort.value === key) {
order.value = order.value === 'asc' ? 'desc' : 'asc';
} else {
sort.value = key;
// 代码列默认升序;其余(行情/规模/热度)默认降序——先看最大/最热
order.value = key === 'symbol' ? 'asc' : 'desc';
}
}
function go(delta: number) {
const next = page.value + delta;
if (next >= 1 && next <= totalPages.value) page.value = next;
}
// ---------- 自选星标(服务端为唯一事实源,本地行内即时翻转) ----------
const starBusy = ref('');
async function toggleStar(it: EtfListItem) {
if (starBusy.value === it.ts_code) return;
starBusy.value = it.ts_code;
const wasWatched = it.watched;
it.watched = !wasWatched; // 乐观更新
try {
const list = wasWatched ? await removeWatchlist(it.ts_code) : await addWatchlist(it.ts_code);
const set = new Set(list);
for (const row of items.value) row.watched = set.has(row.ts_code);
} catch {
it.watched = wasWatched; // 回滚
} finally {
starBusy.value = '';
}
}
// 详情浮层里增删自选后,刷新当前页星标
function onWatchedChange() {
load();
}
// ---------- 路由同步:状态 → ?q/&market/…replace 不产生历史记录) ----------
function buildQuery(): Record<string, string> {
const q: Record<string, string> = {};
if (search.value.trim()) q.q = search.value.trim();
if (market.value !== '全部') q.market = market.value;
if (sort.value !== 'total_mv') q.sort = sort.value;
if (order.value !== 'desc') q.order = order.value;
if (page.value > 1) q.page = String(page.value);
if (previewCode.value) q.code = previewCode.value;
return q;
}
let selfNav = 0; // 自己发起的导航在途数量:其 route 变化不回灌状态(防输入被旧 URL 覆盖)
function syncRoute(push = false) {
const query = buildQuery();
// 与当前 URL 一致就跳过,避免 state→route→state 回声
if (JSON.stringify(query) === JSON.stringify(route.query)) return;
selfNav++;
const done = () => { selfNav--; };
void (push ? router.push({ query }) : router.replace({ query })).then(done, done);
}
// 列表状态变化(含搜索防抖外的输入)随手回写 URL翻页/筛选也带着当前 ?code
watch([search, market, sort, order, page], () => syncRoute());
// 浏览器前进/后退(含返回键关掉 ?code=):把 query 应用回状态
watch(() => route.query, (q) => {
if (selfNav > 0) return;
const qOf = (k: string) => (typeof q[k] === 'string' ? (q[k] as string) : '');
search.value = qOf('q');
market.value = MARKETS.includes(qOf('market')) ? qOf('market') : '全部';
const p = parseInt(qOf('page'), 10);
page.value = Number.isFinite(p) && p >= 1 ? p : 1;
const s = qOf('sort');
sort.value = SORT_KEYS.includes(s as SortKey) ? (s as SortKey) : 'total_mv';
order.value = qOf('order') === 'asc' ? 'asc' : 'desc';
previewCode.value = qOf('code') || null;
});
// ---------- 详情浮层开关(写入 ?code= ----------
function openEtf(code: string) {
previewCode.value = code;
syncRoute(true); // push浏览器返回键 = 关闭浮层
}
function onOverlayChange(code: string) {
previewCode.value = code; // 浮层内切换(键盘/侧栏)同步到路由
syncRoute();
}
function closeOverlay() {
previewCode.value = null;
syncRoute();
}
// 表头排序按钮的箭头指示
function sortIcon(key: SortKey): string {
if (sort.value !== key) return '⇅';
return order.value === 'asc' ? '▲' : '▼';
}
function sortIconClass(key: SortKey): string {
return sort.value === key ? 'text-blue-400' : 'text-[#4A4D55]';
}
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold text-[#E8EAED]">全部ETF</h1>
<span class="text-[13px] text-[#9BA3AE]"> {{ total.toLocaleString() }} · 点击行查看 K 线详情</span>
<div class="relative ml-auto">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9BA3AE]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
<input
v-model="search"
type="text"
placeholder="搜索代码 / 名称"
class="w-56 rounded-md border border-[#33353D] bg-[#16181D] py-2 pl-9 pr-3 text-sm text-[#E8EAED] outline-none transition placeholder:text-[#7A818C] focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30"
/>
</div>
<select v-model="market" class="ipt !w-auto !py-1.5 text-[13px]" title="按交易所筛选">
<option v-for="m in MARKETS" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<EtfSyncBar />
<div v-if="error" class="mb-4 flex items-start gap-2 rounded-xl border border-red-500/30 bg-red-500/15 px-4 py-3 text-sm text-red-400">
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 3.9L1.8 18a2 2 0 001.7 3h17a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" /><path d="M12 9v4M12 17h.01" /></svg>
{{ error }}
</div>
<div class="overflow-hidden rounded-xl border border-[#26272E] bg-[#101014]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[#1E2026] text-left text-[13px] text-[#A8AFB8]">
<th class="w-10 px-2 py-3 font-medium" title="自选"></th>
<th class="px-4 py-3 font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'symbol' ? 'text-[#E8EAED]' : ''" @click="toggleSort('symbol')">
代码<span class="text-[10px] leading-none" :class="sortIconClass('symbol')">{{ sortIcon('symbol') }}</span>
</button>
</th>
<th class="px-4 py-3 font-medium">名称</th>
<th class="px-4 py-3 font-medium">交易所</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'close' ? 'text-[#E8EAED]' : ''" @click="toggleSort('close')">
最新价<span class="text-[10px] leading-none" :class="sortIconClass('close')">{{ sortIcon('close') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'pct_chg' ? 'text-[#E8EAED]' : ''" @click="toggleSort('pct_chg')">
涨跌幅<span class="text-[10px] leading-none" :class="sortIconClass('pct_chg')">{{ sortIcon('pct_chg') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元(最新交易日)">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'amount' ? 'text-[#E8EAED]' : ''" @click="toggleSort('amount')">
成交额<span class="text-[10px] leading-none" :class="sortIconClass('amount')">{{ sortIcon('amount') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元(东财快照)">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'total_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('total_mv')">
总市值<span class="text-[10px] leading-none" :class="sortIconClass('total_mv')">{{ sortIcon('total_mv') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium" title="单位:亿元(东财快照)">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'circ_mv' ? 'text-[#E8EAED]' : ''" @click="toggleSort('circ_mv')">
流通市值<span class="text-[10px] leading-none" :class="sortIconClass('circ_mv')">{{ sortIcon('circ_mv') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">
<button type="button" class="inline-flex items-center gap-1 transition hover:text-[#E8EAED]" :class="sort === 'turnover_rate' ? 'text-[#E8EAED]' : ''" @click="toggleSort('turnover_rate')">
换手率<span class="text-[10px] leading-none" :class="sortIconClass('turnover_rate')">{{ sortIcon('turnover_rate') }}</span>
</button>
</th>
<th class="px-4 py-3 text-right font-medium">上市日期</th>
<th class="px-4 py-3 text-right font-medium">数据截至</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && items.length === 0">
<td colspan="12" class="px-4 py-16 text-center text-[#9BA3AE]">加载中</td>
</tr>
<tr
v-for="it in items"
:key="it.ts_code"
class="cursor-pointer border-b border-[#1E2026] transition hover:bg-blue-500/15"
@click="openEtf(it.ts_code)"
>
<td class="px-2 py-2.5 text-center" @click.stop>
<button
type="button"
class="rounded p-0.5 transition-colors disabled:opacity-50"
:class="it.watched ? 'text-amber-500 hover:text-amber-600' : 'text-[#C3C9D2] hover:text-amber-400'"
:title="it.watched ? '移出自选' : '加入自选'"
:disabled="starBusy === it.ts_code"
@click="toggleStar(it)"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" :fill="it.watched ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[#E8EAED]">{{ it.symbol }}</td>
<td class="px-4 py-2.5 font-medium text-[#E8EAED]">{{ it.name }}</td>
<td class="px-4 py-2.5">
<span class="rounded bg-[#26272E] px-1.5 py-0.5 text-[13px] text-[#A8AFB8]">{{ it.exchange === 'SH' ? '沪市' : '深市' }}</span>
</td>
<td class="px-4 py-2.5 text-right font-mono text-sm font-medium tabular-nums" :class="pctClass(it.pct_chg)">{{ it.close?.toFixed(3) ?? '--' }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums" :class="pctClass(it.pct_chg)">{{ fmtPct(it.pct_chg) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.amount) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.total_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtYi(it.circ_mv) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm tabular-nums text-[#A8AFB8]">{{ fmtTurnover(it.turnover_rate) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm text-[#9BA3AE]">{{ fmtDate(it.list_date) }}</td>
<td class="px-4 py-2.5 text-right font-mono text-sm text-[#9BA3AE]">{{ fmtDate(it.last_ts) }}</td>
</tr>
<tr v-if="!loading && items.length === 0">
<td colspan="12" class="px-4 py-16 text-center text-[#9BA3AE]">
没有数据{{ total === 0 ? '——首次使用请先点击上方「同步ETF数据」抓取全市场 ETF' : '' }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-[#1E2026] px-4 py-3 text-[13px] text-[#A8AFB8]">
<span v-if="loading">加载中</span>
<span v-else> {{ page }} / {{ totalPages }} </span>
<div class="flex gap-2">
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page <= 1 || loading"
@click="go(-1)"
>
上一页
</button>
<button
type="button"
class="rounded border border-[#26272E] px-3 py-1.5 transition hover:border-[#3A3D46] hover:text-white disabled:opacity-40"
:disabled="page >= totalPages || loading"
@click="go(1)"
>
下一页
</button>
</div>
</div>
</div>
<!-- 全屏 ETF 详情与看股页同款当前 ETF 记录在 ?code= -->
<StockDetailOverlay
v-if="previewCode && overlayItems.length"
:items="overlayItems"
:initial="previewCode"
@close="closeOverlay"
@change="onOverlayChange"
@watched-change="onWatchedChange"
/>
</div>
</template>

View File

@@ -0,0 +1,367 @@
<script setup lang="ts">
// 指数详情页:头部最新行情 + 全量 K 线(日/周/月/年,日线基底聚合)+
// 基本信息(国内 index_basic / 国际静态表)+ 估值指标index_dailybasic仅部分国内指数
// + 成分股权重index_weight 最近月度,仅国内指数)。收盘口径。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { RouterLink } from 'vue-router';
import { dispose, init, type Chart, type KLineData } from 'klinecharts';
import { getAnyIndexCandles, getIndexDetail, getIndexWeights } from '@/api/client';
import type { Candle, IndexDetail, IndexWeights, Timeframe } from '@/api/types';
import { useSettingsStore } from '@/stores/settings';
import { darkStyles } from '@/chartStyles';
const route = useRoute();
const settings = useSettingsStore();
const code = computed(() => String(route.params.code ?? ''));
// ---------- 详情数据 ----------
const detail = ref<IndexDetail | null>(null);
const weights = ref<IndexWeights | null>(null);
const weightsError = ref('');
const loading = ref(false);
const error = ref('');
const isCn = computed(() => detail.value?.region === 'cn');
async function loadDetail(c: string) {
loading.value = true;
error.value = '';
detail.value = null;
weights.value = null;
weightsError.value = '';
try {
detail.value = await getIndexDetail(c);
} catch (e) {
error.value = e instanceof Error ? e.message : '获取指数详情失败';
} finally {
loading.value = false;
}
// 成分权重仅国内指数有数据,且独立加载(失败不阻塞页面)
if (detail.value?.region === 'cn') {
getIndexWeights(c, 50)
.then((w) => { weights.value = w; })
.catch((e) => { weightsError.value = e instanceof Error ? e.message : '获取成分权重失败'; });
}
}
// ---------- K 线v10数据只进 dataLoader切周期/换指数/换配色整图重建) ----------
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>('1d');
const container = ref<HTMLDivElement | null>(null);
const kLoading = ref(false);
const kError = 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,
}));
ch.setDataLoader({
getBars: ({ type, callback }) => {
if (type === 'init') callback(data, { forward: false, backward: false });
else callback([], { forward: false, backward: false });
},
});
ch.setSymbol({ ticker: code.value });
ch.setPeriod({ type: 'day', span: 1 });
ch.createIndicator({ name: 'MA', paneId: 'candle_pane', calcParams: [5, 10, 20, 60] });
// 成交量副图:国际指数 vol 大多缺失,全 0 时不建 VOL pane
if (candles.some((c) => c.volume > 0)) 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 loadCandles(c: string, tf: Timeframe) {
const token = ++loadToken;
kLoading.value = true;
kError.value = null;
try {
const candles = await getAnyIndexCandles(c, tf);
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) kError.value = e instanceof Error ? e.message : '获取指数K线失败';
} finally {
if (token === loadToken) kLoading.value = false;
}
}
function setTimeframe(tf: Timeframe) {
if (tf === timeframe.value) return;
timeframe.value = tf;
void loadCandles(code.value, tf);
}
function loadAll() {
void loadDetail(code.value);
void loadCandles(code.value, timeframe.value);
}
onMounted(loadAll);
onBeforeUnmount(() => {
if (container.value) dispose(container.value);
chart = null;
});
watch(code, loadAll); // 从其他入口换指数
watch(() => settings.priceTone, () => { // 涨跌配色切换重放已拉到的数据
if (lastCandles) rebuild(lastCandles);
});
// ---------- 格式化 ----------
const REGION_LABEL: Record<string, string> = { cn: 'A 股', americas: '美洲', europe: '欧洲', asia: '亚太' };
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 fmt(v: number | null | undefined, digits = 2): string {
if (v == null) return '--';
return v.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
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 fmtIso(v: string | null | undefined): string {
return v ? v.slice(0, 10) : '--';
}
/** 元 -> 万亿/亿 自适应估值市值列index_dailybasic 的 total_mv/float_mv 单位实测为元) */
function fmtMv(v: number | null | undefined): string {
if (v == null) return '--';
if (Math.abs(v) >= 1e12) return `${(v / 1e12).toFixed(2)} 万亿`;
return `${(v / 1e8).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} 亿`;
}
// ---------- 估值走势PE 小图SVG 折线 + min/max 标注) ----------
const peKey = computed(() => {
const h = detail.value?.valuation_history ?? [];
return h.some((p) => p.pe_ttm != null) ? 'pe_ttm' : 'pe';
});
const pePath = computed(() => {
const h = detail.value?.valuation_history ?? [];
const vals = h.map((p) => p[peKey.value]).filter((v): v is number => v != null);
const n = vals.length;
if (n < 2) return null;
const lo = Math.min(...vals);
const hi = Math.max(...vals);
const span = hi - lo || 1;
const pts = vals.map((v, i) => ({
x: i / (n - 1),
y: 1 - ((v - lo) / span) * 0.84 - 0.08, // 上下留白 8%
}));
const line = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${(p.x * 1000).toFixed(2)},${(p.y * 100).toFixed(2)}`).join(' ');
const first = h.find((p) => p[peKey.value] != null)?.trade_date ?? '';
return { line, area: `${line} L1000,100 L0,100 Z`, lo, hi, first };
});
</script>
<template>
<div class="w-full">
<!-- 头部返回 + 名称/代码 + 最新行情 -->
<div class="mb-4">
<RouterLink
to="/indexes"
class="mb-3 inline-flex items-center gap-1.5 text-sm font-medium text-[#A8AFB8] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M11 18l-6-6 6-6" /></svg>
国际指数
</RouterLink>
<div v-if="!detail && loading" class="h-24 animate-pulse rounded-lg border border-[#26272E] bg-[#101014]" />
<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="loadAll">重试</button>
</div>
<div v-else-if="detail" class="flex flex-wrap items-end justify-between gap-x-8 gap-y-3 rounded-lg border border-[#26272E] bg-[#101014] px-5 py-4">
<div>
<div class="flex items-center gap-2.5">
<h1 class="text-lg font-semibold text-white">{{ detail.name }}</h1>
<span class="rounded bg-[#1A1B21] px-1.5 py-0.5 font-mono text-[11px] text-[#6B7280]">{{ detail.code }}</span>
<span class="rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] text-blue-300">{{ REGION_LABEL[detail.region] ?? detail.region }}</span>
</div>
<div class="mt-1 text-xs text-[#6B7280]">
收盘口径 · {{ fmtIso(detail.quote.trade_date) }}
<template v-if="detail.basic?.publisher"> · {{ detail.basic.publisher }}</template>
</div>
</div>
<div class="flex flex-wrap items-end gap-x-8 gap-y-2">
<div class="flex items-baseline gap-2.5">
<span class="text-3xl font-semibold tabular-nums text-white">{{ fmt(detail.quote.close) }}</span>
<span class="font-mono text-sm tabular-nums" :class="dirClass(detail.quote.pct_chg)">
{{ detail.quote.pct_chg != null && detail.quote.pct_chg > 0 ? '▲' : detail.quote.pct_chg != null && detail.quote.pct_chg < 0 ? '▼' : '' }}
{{ fmtSigned(detail.quote.change) }} {{ fmtPct(detail.quote.pct_chg) }}
</span>
</div>
<dl class="grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
<div><dt class="text-[#6B7280]">今开</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.quote.open) }}</dd></div>
<div><dt class="text-[#6B7280]">最高</dt><dd class="font-mono tabular-nums text-up">{{ fmt(detail.quote.high) }}</dd></div>
<div><dt class="text-[#6B7280]">最低</dt><dd class="font-mono tabular-nums text-down">{{ fmt(detail.quote.low) }}</dd></div>
<div><dt class="text-[#6B7280]">昨收</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.quote.pre_close) }}</dd></div>
</dl>
</div>
</div>
</div>
<!-- K 线 -->
<div class="mb-4 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]">{{ detail?.name ?? '' }} K 线</span>
<span v-if="lastDate" class="font-mono text-xs tabular-nums text-[#6B7280]">截至 {{ lastDate }}</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="kLoading"
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="kError" class="flex h-full items-center justify-center text-sm text-[#A8AFB8]">
{{ kError }}
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="loadCandles(code, timeframe)">重试</button>
</div>
</div>
</div>
<!-- 基本信息 + 估值 -->
<div class="mb-4 grid gap-4 lg:grid-cols-2">
<div class="rounded-lg border border-[#26272E] bg-[#101014] p-4">
<div class="mb-3 text-sm font-medium text-[#E5E7EB]">基本信息</div>
<dl v-if="detail?.basic" class="grid grid-cols-2 gap-x-6 gap-y-2.5 text-[13px]">
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">名称</dt><dd class="text-[#E5E7EB]">{{ detail.basic.name }}</dd></div>
<div v-if="detail.basic.market" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">市场</dt><dd class="text-[#E5E7EB]">{{ detail.basic.market }}</dd></div>
<div v-if="detail.basic.publisher" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">发布方</dt><dd class="text-[#E5E7EB]">{{ detail.basic.publisher }}</dd></div>
<div v-if="detail.basic.category" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">类别</dt><dd class="text-[#E5E7EB]">{{ detail.basic.category }}</dd></div>
<div v-if="detail.basic.base_date" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">基期</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmtIso(detail.basic.base_date) }}</dd></div>
<div v-if="detail.basic.base_point != null" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">基点</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.basic.base_point) }}</dd></div>
<div v-if="detail.basic.list_date" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">发布日期</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmtIso(detail.basic.list_date) }}</dd></div>
<div v-if="detail.basic.country" class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">国家/地区</dt><dd class="text-[#E5E7EB]">{{ detail.basic.country }}</dd></div>
</dl>
<p v-else class="text-xs text-[#6B7280]">暂无基本信息</p>
<p class="mt-3 text-[10px] text-[#6B7280]">数据来源Tushare index_basic国内/ 内置静态表国际</p>
</div>
<!-- 估值 index_dailybasic 覆盖的国内指数有 -->
<div v-if="detail?.valuation" class="rounded-lg border border-[#26272E] bg-[#101014] p-4">
<div class="mb-3 flex items-baseline justify-between">
<span class="text-sm font-medium text-[#E5E7EB]">估值指标</span>
<span class="font-mono text-[10px] tabular-nums text-[#6B7280]">{{ fmtIso(detail.valuation.trade_date) }} · index_dailybasic</span>
</div>
<dl class="grid grid-cols-3 gap-3 text-[13px] sm:grid-cols-6 lg:grid-cols-3">
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">市盈率</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.valuation.pe) }}</dd></div>
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">PE-TTM</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.valuation.pe_ttm) }}</dd></div>
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">市净率</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmt(detail.valuation.pb) }}</dd></div>
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">换手率</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ detail.valuation.turnover_rate != null ? `${detail.valuation.turnover_rate.toFixed(2)}%` : '--' }}</dd></div>
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">总市值</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmtMv(detail.valuation.total_mv) }}</dd></div>
<div class="flex flex-col gap-0.5"><dt class="text-xs text-[#6B7280]">流通市值</dt><dd class="font-mono tabular-nums text-[#E5E7EB]">{{ fmtMv(detail.valuation.float_mv) }}</dd></div>
</dl>
<!-- PE 走势近一年 -->
<div v-if="pePath" class="mt-4">
<div class="mb-1 flex items-center justify-between text-[10px] text-[#6B7280]">
<span>{{ peKey === 'pe_ttm' ? 'PE-TTM' : 'PE' }} 走势近一年</span>
<span class="font-mono tabular-nums"> {{ pePath.hi.toFixed(2) }} · {{ pePath.lo.toFixed(2) }}</span>
</div>
<div class="relative h-[96px]">
<svg class="h-full w-full" viewBox="0 0 1000 100" preserveAspectRatio="none" aria-hidden="true">
<path :d="pePath.area" fill="#3B82F6" fill-opacity="0.08" />
<path :d="pePath.line" fill="none" stroke="#60A5FA" stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
</div>
<div class="mt-0.5 flex justify-between font-mono text-[10px] tabular-nums text-[#6B7280]">
<span>{{ fmtIso(pePath.first) }}</span>
<span>{{ fmtIso(detail.valuation.trade_date) }}</span>
</div>
</div>
</div>
</div>
<!-- 成分权重仅国内指数 -->
<div v-if="isCn" class="rounded-lg border border-[#26272E] bg-[#101014] p-4">
<div class="mb-3 flex items-baseline justify-between">
<span class="text-sm font-medium text-[#E5E7EB]">成分股权重 TOP 50</span>
<span v-if="weights" class="font-mono text-[10px] tabular-nums text-[#6B7280]">
{{ fmtIso(weights.trade_date) }} · {{ weights.total }} · index_weight 月度快照
</span>
</div>
<div v-if="!weights && !weightsError" class="py-6 text-center text-xs text-[#6B7280]">成分权重加载中</div>
<div v-else-if="weightsError" class="py-3 text-xs text-[#A8AFB8]">
{{ weightsError }}
<button type="button" class="ml-2 text-blue-500 hover:underline" @click="loadDetail(code)">重试</button>
</div>
<div v-else-if="weights" class="overflow-x-auto">
<table class="w-full min-w-[560px] text-[13px]">
<thead>
<tr class="border-b border-[#26272E] text-left text-xs text-[#6B7280]">
<th class="w-10 py-2 pr-2 font-normal">#</th>
<th class="py-2 pr-4 font-normal">代码</th>
<th class="py-2 pr-4 font-normal">名称</th>
<th class="py-2 pr-4 text-right font-normal">权重</th>
<th class="w-[38%] py-2 font-normal"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(w, i) in weights.items"
:key="w.con_code"
class="border-b border-[#1A1B21] last:border-0"
>
<td class="py-1.5 pr-2 font-mono text-xs tabular-nums text-[#6B7280]">{{ i + 1 }}</td>
<td class="py-1.5 pr-4 font-mono tabular-nums text-[#E5E7EB]">{{ w.con_code }}</td>
<td class="py-1.5 pr-4 text-[#E5E7EB]">{{ w.name ?? '--' }}</td>
<td class="py-1.5 pr-4 text-right font-mono tabular-nums text-[#E5E7EB]">{{ w.weight.toFixed(2) }}%</td>
<td class="py-1.5">
<div class="h-1.5 w-full overflow-hidden rounded bg-[#1A1B21]">
<div class="h-full rounded bg-blue-500/70" :style="{ width: `${Math.max(2, (w.weight / weights.items[0].weight) * 100)}%` }" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
// 国际指数卡片页tushare index_global 覆盖的 21 个全球主要指数,
// 按地区分组(美洲/欧洲/亚太),卡片 = 最新收盘 + 涨跌幅 + 45 日迷你走势,
// 点击卡片进入 /indexes/:code 指数详情K 线 / 基本信息 / 估值 / 成分权重)。
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { getGlobalIndexes } from '@/api/client';
import type { GlobalIndexQuote, GlobalIndexList, GlobalRegion } from '@/api/types';
import Sparkline from '@/components/Sparkline.vue';
const REGION_META: Record<GlobalRegion, { label: string; badge: string }> = {
americas: { label: '美洲', badge: 'bg-blue-500/15 text-blue-300' },
europe: { label: '欧洲', badge: 'bg-violet-500/15 text-violet-300' },
asia: { label: '亚太', badge: 'bg-amber-500/15 text-amber-300' },
};
const list = ref<GlobalIndexList | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
list.value = await getGlobalIndexes();
} catch (e) {
error.value = e instanceof Error ? e.message : '获取国际指数失败';
} finally {
loading.value = false;
}
}
onMounted(load);
const groups = computed(() => {
const items = list.value?.items ?? [];
return (Object.keys(REGION_META) as GlobalRegion[])
.map((r) => ({ region: r, ...REGION_META[r], items: items.filter((i) => i.region === r) }))
.filter((g) => g.items.length > 0);
});
const updatedAt = computed(() => {
const s = list.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 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');
}
// 国内常用指数快捷入口(国际页同时提供 A 股主要指数的详情跳转)
const CN_QUICK: { code: string; name: string }[] = [
{ code: '000001.SH', name: '上证指数' },
{ code: '399001.SZ', name: '深证成指' },
{ code: '399006.SZ', name: '创业板指' },
{ code: '000688.SH', name: '科创50' },
{ code: '000300.SH', name: '沪深300' },
{ code: '000852.SH', name: '中证1000' },
];
function cardTo(it: GlobalIndexQuote) {
return `/indexes/${encodeURIComponent(it.code)}`;
}
</script>
<template>
<div class="w-full">
<div class="mb-4 flex items-baseline justify-between">
<div>
<h1 class="text-lg font-semibold text-white">国际指数</h1>
<p class="mt-1 text-xs text-[#6B7280]">全球主要市场指数 · 收盘口径Tushare index_global</p>
</div>
<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="!list && loading" class="space-y-6">
<div v-for="g in 3" :key="g" class="space-y-2">
<div class="h-3 w-16 animate-pulse rounded bg-[#101014]" />
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<div v-for="i in 6" :key="i" class="h-[118px] animate-pulse rounded-lg border border-[#26272E] bg-[#101014]" />
</div>
</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="list">
<!-- A 股主要指数快捷入口 -->
<div class="mb-5 flex flex-wrap items-center gap-2 rounded-lg border border-[#26272E] bg-[#101014] px-4 py-3">
<span class="text-xs text-[#6B7280]">A 股指数</span>
<RouterLink
v-for="q in CN_QUICK"
:key="q.code"
:to="`/indexes/${encodeURIComponent(q.code)}`"
class="rounded-md border border-[#26272E] px-2.5 py-1 text-[13px] text-[#9BA3AE] transition-colors hover:border-[#3A3D46] hover:text-[#E5E7EB] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>{{ q.name }}</RouterLink>
</div>
<div v-for="g in groups" :key="g.region" class="mb-6 last:mb-0">
<div class="mb-2 flex items-center gap-2 text-xs text-[#6B7280]">
<span :class="['rounded px-1.5 py-0.5 text-[10px]', g.badge]">{{ g.label }}</span>
<span>{{ g.items.length }} 个指数</span>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<RouterLink
v-for="it in g.items"
:key="it.code"
:to="cardTo(it)"
class="group rounded-lg border border-[#26272E] bg-[#101014] px-4 pb-3 pt-3 transition-all hover:-translate-y-0.5 hover:border-[#3A3D46] hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
>
<div class="flex items-baseline justify-between gap-2">
<span class="truncate text-sm font-medium text-[#E5E7EB]">{{ it.name }}</span>
<span class="shrink-0 rounded bg-[#1A1B21] px-1.5 py-0.5 text-[10px] text-[#6B7280]">{{ it.country }}</span>
</div>
<div class="mt-1.5 flex items-baseline gap-2">
<span class="text-xl font-semibold tabular-nums 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>
<Sparkline class="mt-2" :values="it.spark" :dates="it.spark_dates" :pct="it.pct_chg" />
<div class="mt-1 flex items-center justify-between text-[10px] text-[#6B7280]">
<span class="font-mono tabular-nums">{{ fmtDate(it.trade_date) }} 收盘</span>
<span class="opacity-0 transition-opacity group-hover:opacity-100">查看详情 </span>
</div>
</RouterLink>
</div>
</div>
<div v-if="list.errors.length" class="mt-2 text-[10px] text-[#6B7280]" :title="list.errors.join('')">
{{ list.errors.length }} 项数据获取失败已跳过
</div>
</template>
</div>
</template>