Files
zjjk/frontend/src/components/dashboard/OverviewPanel.vue

253 lines
6.1 KiB
Vue
Raw Normal View History

2026-03-20 21:47:30 +08:00
<script setup lang="ts">
import { computed } from 'vue'
import type { OverviewResponse } from '../../types/api'
import { formatAmount, formatPrecision, formatTimestamp } from '../../utils/formatters'
import MetricCard from './MetricCard.vue'
import TrendChart from './TrendChart.vue'
const props = defineProps<{
overview: OverviewResponse
}>()
const minuteChartSeries = computed(() => {
const southboundPoints = props.overview.minute_timeline
.filter((item) => item.amount_hkd_billion !== null)
.map((item) => ({
label: item.timestamp.slice(11, 16),
value: item.amount_hkd_billion ?? 0
}))
const benchmarkSeries = props.overview.benchmark_series.map((series) => ({
key: series.key,
label: series.label,
color: series.key === 'hsi' ? '#6fd0ff' : '#9bffb0',
axis: 'right' as const,
dashed: series.key === 'hsi',
formatter: (value: number) => `${value.toFixed(2)} ${series.unit}`,
points: series.points
.filter((item) => item.value !== null)
.map((item) => ({
label: item.timestamp.slice(11, 16),
value: item.value ?? 0
}))
}))
return [
{
key: 'southbound',
label: '南向净流入',
color: '#d6ad47',
axis: 'left' as const,
fill: true,
formatter: (value: number) => `${value.toFixed(2)} 亿港元`,
points: southboundPoints
},
...benchmarkSeries
].filter((item) => item.points.length)
})
</script>
<template>
<section class="panel">
<div class="panel__header">
<div>
<p class="panel__eyebrow">Realtime Overview</p>
<h2 class="panel__title">实时总览</h2>
</div>
<div class="panel__meta">
<span>更新 {{ formatTimestamp(overview.snapshot.updated_at) }}</span>
<span>{{ overview.snapshot.source_name }}</span>
<span>{{ formatPrecision(overview.snapshot.total_net_inflow.precision) }}</span>
</div>
</div>
<div class="overview-grid">
<MetricCard :metric="overview.snapshot.total_net_inflow" />
<MetricCard :metric="overview.snapshot.cumulative_net_inflow" />
<MetricCard :metric="overview.snapshot.shanghai_net_inflow" />
<MetricCard :metric="overview.snapshot.shenzhen_net_inflow" />
<MetricCard :metric="overview.snapshot.one_min_change" />
<MetricCard :metric="overview.snapshot.five_min_change" />
</div>
<div class="overview-body">
<article class="overview-card overview-card--chart">
<div class="overview-card__head">
<h3>分钟级净流入与港股指数叠加</h3>
<span>左轴为南向资金右轴为恒生指数与恒生科技</span>
</div>
<div class="overview-card__chart">
<TrendChart :series="minuteChartSeries" />
</div>
</article>
<article class="overview-card">
<div class="overview-card__head">
<h3>交易状态</h3>
<span>{{ formatPrecision(overview.snapshot.total_net_inflow.precision) }}</span>
</div>
<div class="overview-status">
<div class="overview-status__row">
<span>下一阈值</span>
<strong>{{ formatAmount(overview.snapshot.next_threshold_hkd_billion) }}</strong>
</div>
<div class="overview-status__row">
<span>阈值进度</span>
<strong>{{ (overview.snapshot.threshold_progress * 100).toFixed(1) }}%</strong>
</div>
<div class="overview-status__track">
<span class="overview-status__fill" :style="{ width: `${overview.snapshot.threshold_progress * 100}%` }"></span>
</div>
<p class="overview-status__reason">
{{
overview.snapshot.unavailable_reason ??
'当前已接入东方财富真实接口,页面与数据库快照保持一致。'
}}
</p>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.panel {
display: grid;
gap: 0.7rem;
min-height: 100%;
padding: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 14, 26, 0.88);
}
.panel__header {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: flex-start;
}
.panel__eyebrow {
margin: 0;
color: var(--color-accent);
letter-spacing: 0.15em;
text-transform: uppercase;
font-size: 0.74rem;
}
.panel__title {
margin: 0.2rem 0 0;
font-family: var(--font-display);
font-size: 1.18rem;
}
.panel__meta {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
justify-content: flex-end;
color: var(--color-text-subtle);
font-size: 0.82rem;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.overview-body {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.75fr);
gap: 0.7rem;
}
.overview-card {
display: grid;
gap: 0.65rem;
padding: 0.8rem;
background: rgba(13, 20, 36, 0.75);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.overview-card--chart {
min-height: 27rem;
}
.overview-card__head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
}
.overview-card__head h3 {
margin: 0;
font-size: 1rem;
}
.overview-card__head span {
color: var(--color-text-subtle);
font-size: 0.78rem;
}
.overview-card__chart {
min-height: 21rem;
}
.overview-status {
display: grid;
gap: 0.85rem;
align-content: start;
}
.overview-status__row {
display: flex;
justify-content: space-between;
gap: 0.9rem;
}
.overview-status__row span {
color: var(--color-text-subtle);
}
.overview-status__track {
height: 0.55rem;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
overflow: hidden;
}
.overview-status__fill {
display: block;
height: 100%;
background: linear-gradient(90deg, #d7ad47, #e8d4a3);
}
.overview-status__reason {
margin: 0;
color: var(--color-text-muted);
line-height: 1.6;
}
@media (max-width: 1100px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.overview-grid {
grid-template-columns: 1fr;
}
.panel__header {
flex-direction: column;
}
}
</style>