Initial commit
This commit is contained in:
37
frontend/src/App.vue
Normal file
37
frontend/src/App.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import HomePortal from './components/home/HomePortal.vue'
|
||||
import SouthboundWorkspace from './components/southbound/SouthboundWorkspace.vue'
|
||||
import AShareMonitorPage from './components/ashare/AShareMonitorPage.vue'
|
||||
|
||||
type ViewKey = 'home' | 'southbound' | 'ashare'
|
||||
|
||||
const activeView = shallowRef<ViewKey>('home')
|
||||
|
||||
function openView(view: Exclude<ViewKey, 'home'>) {
|
||||
activeView.value = view
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
activeView.value = 'home'
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const titleMap: Record<ViewKey, string> = {
|
||||
home: '资金监控页面',
|
||||
southbound: '资金监控页面 - 南向资金',
|
||||
ashare: '资金监控页面 - A股资金'
|
||||
}
|
||||
document.title = titleMap[activeView.value]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomePortal
|
||||
v-if="activeView === 'home'"
|
||||
@open-southbound="openView('southbound')"
|
||||
@open-ashare="openView('ashare')"
|
||||
/>
|
||||
<SouthboundWorkspace v-else-if="activeView === 'southbound'" @back="goHome" />
|
||||
<AShareMonitorPage v-else @back="goHome" />
|
||||
</template>
|
||||
45
frontend/src/assets/main.css
Normal file
45
frontend/src/assets/main.css
Normal file
@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--font-display: 'Noto Serif SC', serif;
|
||||
--font-body: 'IBM Plex Sans', sans-serif;
|
||||
--color-bg: #04070d;
|
||||
--color-panel: #08101d;
|
||||
--color-text: #f4efe2;
|
||||
--color-text-muted: rgba(244, 239, 226, 0.72);
|
||||
--color-text-subtle: rgba(244, 239, 226, 0.54);
|
||||
--color-accent: #d6ad47;
|
||||
--color-accent-soft: #ead39d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
153
frontend/src/components/ashare/AShareCategoryPanel.vue
Normal file
153
frontend/src/components/ashare/AShareCategoryPanel.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
interface MetricDefinition {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
metrics: MetricDefinition[]
|
||||
tableColumns: string[]
|
||||
status: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="category-panel">
|
||||
<header class="category-panel__header">
|
||||
<div>
|
||||
<p class="category-panel__eyebrow">Eastmoney Standard</p>
|
||||
<h2 class="category-panel__title">{{ title }}</h2>
|
||||
<p class="category-panel__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="category-panel__status">{{ status }}</span>
|
||||
</header>
|
||||
|
||||
<div class="category-panel__body">
|
||||
<article class="category-panel__metrics">
|
||||
<h3>标准指标</h3>
|
||||
<ul>
|
||||
<li v-for="metric in metrics" :key="metric.label">
|
||||
<strong>{{ metric.label }}</strong>
|
||||
<span>{{ metric.description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="category-panel__table">
|
||||
<h3>建议列表字段</h3>
|
||||
<div class="category-panel__columns">
|
||||
<span v-for="column in tableColumns" :key="column">{{ column }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.category-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.84);
|
||||
}
|
||||
|
||||
.category-panel__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.category-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: #7be0d4;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.category-panel__title {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.category-panel__subtitle {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.category-panel__status {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(123, 224, 212, 0.22);
|
||||
background: rgba(123, 224, 212, 0.08);
|
||||
color: #9ef2e8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.category-panel__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.9fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.category-panel__metrics,
|
||||
.category-panel__table {
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(5, 11, 20, 0.72);
|
||||
}
|
||||
|
||||
.category-panel__metrics h3,
|
||||
.category-panel__table h3 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.category-panel__metrics ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.category-panel__metrics li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.category-panel__metrics span {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.category-panel__columns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.category-panel__columns span {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.category-panel__header,
|
||||
.category-panel__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.category-panel__header {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
331
frontend/src/components/ashare/AShareFlowTable.vue
Normal file
331
frontend/src/components/ashare/AShareFlowTable.vue
Normal file
@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import type { AShareFlowRecord } from '../../types/api'
|
||||
import { formatFlowAmountYi, formatPercent, formatPrice, formatTimestamp } from '../../utils/formatters'
|
||||
|
||||
type SortField =
|
||||
| 'code'
|
||||
| 'name'
|
||||
| 'latest_price'
|
||||
| 'change_percent'
|
||||
| 'main_net_inflow'
|
||||
| 'super_large_net_inflow'
|
||||
| 'large_net_inflow'
|
||||
| 'medium_net_inflow'
|
||||
| 'small_net_inflow'
|
||||
| 'rolling_net_inflow_5d'
|
||||
| 'rolling_net_inflow_10d'
|
||||
| 'rolling_net_inflow_30d'
|
||||
| 'rolling_net_inflow_60d'
|
||||
| 'rolling_net_inflow_90d'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
records: AShareFlowRecord[]
|
||||
filterPlaceholder: string
|
||||
}>()
|
||||
|
||||
const searchKeyword = shallowRef('')
|
||||
const sortField = shallowRef<SortField | null>(null)
|
||||
const sortDirection = shallowRef<'desc' | 'asc'>('desc')
|
||||
|
||||
const tableColumns: Array<{ key?: SortField; label: string }> = [
|
||||
{ key: 'code', label: '代码' },
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'latest_price', label: '最新价' },
|
||||
{ key: 'change_percent', label: '涨跌幅' },
|
||||
{ key: 'main_net_inflow', label: '主力净流入' },
|
||||
{ key: 'super_large_net_inflow', label: '超大单净流入' },
|
||||
{ key: 'large_net_inflow', label: '大单净流入' },
|
||||
{ key: 'medium_net_inflow', label: '中单净流入' },
|
||||
{ key: 'small_net_inflow', label: '小单净流入' },
|
||||
{ key: 'rolling_net_inflow_5d', label: '5日净流入' },
|
||||
{ key: 'rolling_net_inflow_10d', label: '10日净流入' },
|
||||
{ key: 'rolling_net_inflow_30d', label: '30日净流入' },
|
||||
{ key: 'rolling_net_inflow_60d', label: '60日净流入' },
|
||||
{ key: 'rolling_net_inflow_90d', label: '90日净流入' },
|
||||
{ label: '更新时间' }
|
||||
]
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return props.records
|
||||
}
|
||||
|
||||
return props.records.filter((record) => {
|
||||
return record.name.toLowerCase().includes(keyword) || record.code.toLowerCase().includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
const sortedRecords = computed(() => {
|
||||
if (!sortField.value) {
|
||||
return filteredRecords.value
|
||||
}
|
||||
|
||||
const direction = sortDirection.value === 'desc' ? -1 : 1
|
||||
|
||||
return [...filteredRecords.value].sort((left, right) => {
|
||||
const field = sortField.value as SortField
|
||||
const leftValue = left[field]
|
||||
const rightValue = right[field]
|
||||
|
||||
if (typeof leftValue === 'string' || typeof rightValue === 'string') {
|
||||
return String(leftValue ?? '').localeCompare(String(rightValue ?? '')) * direction
|
||||
}
|
||||
|
||||
const fallback = sortDirection.value === 'desc' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY
|
||||
return (((leftValue as number | null) ?? fallback) - ((rightValue as number | null) ?? fallback)) * direction
|
||||
})
|
||||
})
|
||||
|
||||
function setSortField(field: SortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'desc' ? 'asc' : 'desc'
|
||||
return
|
||||
}
|
||||
|
||||
sortField.value = field
|
||||
sortDirection.value = field === 'code' || field === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flow-table">
|
||||
<header class="flow-table__header">
|
||||
<div>
|
||||
<h3 class="flow-table__title">{{ title }}</h3>
|
||||
<p class="flow-table__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<span class="flow-table__count">{{ sortedRecords.length }} 条</span>
|
||||
</header>
|
||||
|
||||
<div class="flow-table__toolbar">
|
||||
<label class="flow-table__search">
|
||||
<span>名称筛选</span>
|
||||
<input v-model="searchKeyword" type="text" :placeholder="filterPlaceholder" />
|
||||
</label>
|
||||
<p class="flow-table__hint">默认保持原始返回顺序,点击表头后才开始排序,再次点击切换升降序。</p>
|
||||
</div>
|
||||
|
||||
<div class="flow-table__wrap">
|
||||
<table class="flow-table__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in tableColumns" :key="column.label">
|
||||
<button
|
||||
v-if="column.key"
|
||||
type="button"
|
||||
class="flow-table__th-button"
|
||||
:class="{ 'flow-table__th-button--active': sortField === column.key }"
|
||||
@click="setSortField(column.key)"
|
||||
>
|
||||
{{ column.label }}
|
||||
<span class="flow-table__th-order">
|
||||
{{
|
||||
sortField === column.key
|
||||
? sortDirection === 'desc'
|
||||
? '↓'
|
||||
: '↑'
|
||||
: '↕'
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<span v-else>{{ column.label }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in sortedRecords" :key="`${record.code}-${record.trade_date}`">
|
||||
<td>{{ record.code }}</td>
|
||||
<td>
|
||||
<div class="flow-table__name-cell">
|
||||
<a
|
||||
v-if="record.detail_url"
|
||||
class="flow-table__link"
|
||||
:href="record.detail_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ record.name }}
|
||||
</a>
|
||||
<span v-else>{{ record.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatPrice(record.latest_price) }}</td>
|
||||
<td
|
||||
:class="
|
||||
record.change_percent && record.change_percent > 0
|
||||
? 'up'
|
||||
: record.change_percent && record.change_percent < 0
|
||||
? 'down'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ formatPercent(record.change_percent) }}
|
||||
</td>
|
||||
<td
|
||||
:class="
|
||||
record.main_net_inflow && record.main_net_inflow > 0
|
||||
? 'up'
|
||||
: record.main_net_inflow && record.main_net_inflow < 0
|
||||
? 'down'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ formatFlowAmountYi(record.main_net_inflow) }}
|
||||
</td>
|
||||
<td>{{ formatFlowAmountYi(record.super_large_net_inflow) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.large_net_inflow) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.medium_net_inflow) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.small_net_inflow) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.rolling_net_inflow_5d) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.rolling_net_inflow_10d) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.rolling_net_inflow_30d) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.rolling_net_inflow_60d) }}</td>
|
||||
<td>{{ formatFlowAmountYi(record.rolling_net_inflow_90d) }}</td>
|
||||
<td>{{ formatTimestamp(record.updated_at ?? record.snapshot_time) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flow-table {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.84);
|
||||
}
|
||||
|
||||
.flow-table__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.flow-table__title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.flow-table__subtitle {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.flow-table__count {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-table__toolbar {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.flow-table__search {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.flow-table__search span {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.flow-table__search input {
|
||||
width: min(26rem, 100%);
|
||||
padding: 0.72rem 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(4, 10, 17, 0.82);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.flow-table__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.flow-table__wrap {
|
||||
overflow: auto;
|
||||
max-height: min(55dvh, 42rem);
|
||||
}
|
||||
|
||||
.flow-table__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1680px;
|
||||
}
|
||||
|
||||
.flow-table__table th,
|
||||
.flow-table__table td {
|
||||
padding: 0.8rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-table__table th {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-table__th-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flow-table__th-button:hover,
|
||||
.flow-table__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.flow-table__th-button--active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.flow-table__th-order {
|
||||
color: #7be0d4;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.flow-table__name-cell {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.flow-table__link {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.up {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: #65d8b5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.flow-table__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
392
frontend/src/components/ashare/AShareMonitorPage.vue
Normal file
392
frontend/src/components/ashare/AShareMonitorPage.vue
Normal file
@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
import AShareFlowTable from './AShareFlowTable.vue'
|
||||
import { useAShareData } from '../../composables/useAShareData'
|
||||
import { formatTimestamp } from '../../utils/formatters'
|
||||
|
||||
type AShareTab = 'index' | 'sector' | 'stock'
|
||||
type SectorTab = 'industry' | 'concept' | 'region'
|
||||
|
||||
defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { state, hasData, load } = useAShareData()
|
||||
const activeTab = shallowRef<AShareTab>('index')
|
||||
const activeSectorTab = shallowRef<SectorTab>('industry')
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
const sectorGroups = computed(() => state.value.sectorRealtime?.sector_types ?? {})
|
||||
|
||||
const currentSectorRecords = computed(() => {
|
||||
const group = sectorGroups.value[activeSectorTab.value]
|
||||
return group?.records ?? []
|
||||
})
|
||||
|
||||
const currentSectorLabel = computed(() => sectorGroups.value[activeSectorTab.value]?.label ?? '')
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="ashare-shell">
|
||||
<div class="ashare-shell__backdrop"></div>
|
||||
|
||||
<div class="ashare-shell__content">
|
||||
<header class="ashare-topbar">
|
||||
<button class="ashare-topbar__back" type="button" @click="$emit('back')">返回主页面</button>
|
||||
<div class="ashare-topbar__meta">
|
||||
<span class="ashare-topbar__label">A股资金监控</span>
|
||||
<button class="ashare-topbar__refresh" type="button" @click="load">刷新数据</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="state.loading" class="state-panel">
|
||||
<p class="state-panel__eyebrow">Loading</p>
|
||||
<h1>正在加载 A 股资金数据</h1>
|
||||
<p>当前优先展示指数资金流和板块资金流,数据来自东方财富真实接口。</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="state.error" class="state-panel state-panel--error">
|
||||
<p class="state-panel__eyebrow">Connection Error</p>
|
||||
<h1>A 股资金接口不可达</h1>
|
||||
<p>{{ state.error }}</p>
|
||||
<button class="state-panel__action" type="button" @click="load">重试</button>
|
||||
</section>
|
||||
|
||||
<template v-else-if="hasData && state.indexRealtime">
|
||||
<section class="ashare-summary">
|
||||
<article class="ashare-summary__item">
|
||||
<span>当前模块</span>
|
||||
<strong>{{ activeTab === 'index' ? '指数资金' : activeTab === 'sector' ? '板块资金' : '个股资金' }}</strong>
|
||||
</article>
|
||||
<article class="ashare-summary__item">
|
||||
<span>指数记录数</span>
|
||||
<strong>{{ state.indexRealtime.records.length }} 个</strong>
|
||||
</article>
|
||||
<article class="ashare-summary__item">
|
||||
<span>板块记录数</span>
|
||||
<strong>
|
||||
{{
|
||||
Object.values(state.sectorRealtime?.sector_types ?? {}).reduce(
|
||||
(sum, group) => sum + group.records.length,
|
||||
0
|
||||
)
|
||||
}}
|
||||
个
|
||||
</strong>
|
||||
</article>
|
||||
<article class="ashare-summary__item">
|
||||
<span>最近更新</span>
|
||||
<strong>{{ formatTimestamp(state.indexRealtime.updated_at) }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="state.warnings.length" class="warning-strip">
|
||||
<p v-for="warning in state.warnings" :key="warning">{{ warning }}</p>
|
||||
</section>
|
||||
|
||||
<nav class="ashare-tabs" aria-label="ashare tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="['ashare-tabs__button', { 'ashare-tabs__button--active': activeTab === 'index' }]"
|
||||
@click="activeTab = 'index'"
|
||||
>
|
||||
<strong>指数资金流</strong>
|
||||
<span>优先展示宽基指数和主流指数资金流</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['ashare-tabs__button', { 'ashare-tabs__button--active': activeTab === 'sector' }]"
|
||||
@click="activeTab = 'sector'"
|
||||
>
|
||||
<strong>板块资金流</strong>
|
||||
<span>行业、概念、地域三类板块全量展示</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['ashare-tabs__button', { 'ashare-tabs__button--active': activeTab === 'stock' }]"
|
||||
@click="activeTab = 'stock'"
|
||||
>
|
||||
<strong>个股资金流</strong>
|
||||
<span>第三阶段开发,当前保留入口位置</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="ashare-stage">
|
||||
<template v-if="activeTab === 'index'">
|
||||
<AShareFlowTable
|
||||
title="指数资金流实时列表"
|
||||
subtitle="指数点位、涨跌幅和资金流统一在同一张表内展示,名称可直接跳转到东方财富详情。"
|
||||
filter-placeholder="筛选指数名称或代码,例如:创业板 / 932000"
|
||||
:records="state.indexRealtime.records"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'sector' && state.sectorRealtime">
|
||||
<nav class="sector-tabs" aria-label="sector tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="['sector-tabs__button', { 'sector-tabs__button--active': activeSectorTab === 'industry' }]"
|
||||
@click="activeSectorTab = 'industry'"
|
||||
>
|
||||
行业板块
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['sector-tabs__button', { 'sector-tabs__button--active': activeSectorTab === 'concept' }]"
|
||||
@click="activeSectorTab = 'concept'"
|
||||
>
|
||||
概念板块
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['sector-tabs__button', { 'sector-tabs__button--active': activeSectorTab === 'region' }]"
|
||||
@click="activeSectorTab = 'region'"
|
||||
>
|
||||
地域板块
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AShareFlowTable
|
||||
:title="`${currentSectorLabel}实时列表`"
|
||||
subtitle="当前展示东方财富实时板块资金流全量数据,支持名称筛选、代码筛选和多字段排序。"
|
||||
filter-placeholder="筛选板块名称或代码,例如:半导体 / BK1037"
|
||||
:records="currentSectorRecords"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<section v-else-if="activeTab === 'sector'" class="placeholder-card">
|
||||
<p class="placeholder-card__eyebrow">Unavailable</p>
|
||||
<h2>板块资金接口暂时不可用</h2>
|
||||
<p>本次请求中东方财富板块接口超时,指数页面不受影响。你可以先查看指数数据,稍后再点击刷新重试板块。</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="placeholder-card">
|
||||
<p class="placeholder-card__eyebrow">Planned</p>
|
||||
<h2>个股资金流放在第三阶段</h2>
|
||||
<p>当前优先级已经调整为“指数第一、板块第二、个股第三”,所以这期先把指数和板块真实数据展示跑通。</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ashare-shell {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ashare-shell__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 8% 14%, rgba(74, 217, 196, 0.14), transparent 28%),
|
||||
radial-gradient(circle at 88% 16%, rgba(100, 163, 255, 0.14), transparent 26%),
|
||||
linear-gradient(160deg, #06121a 10%, #071019 62%, #030507 100%);
|
||||
}
|
||||
|
||||
.ashare-shell__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto auto minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
width: min(1380px, calc(100vw - 24px));
|
||||
height: 100dvh;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ashare-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.ashare-topbar__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.ashare-topbar__back,
|
||||
.ashare-topbar__refresh,
|
||||
.state-panel__action {
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(8, 14, 26, 0.72);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ashare-topbar__label {
|
||||
color: rgba(158, 242, 232, 0.88);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.ashare-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.84);
|
||||
}
|
||||
|
||||
.ashare-summary__item {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid rgba(123, 224, 212, 0.16);
|
||||
background: rgba(5, 12, 20, 0.72);
|
||||
}
|
||||
|
||||
.ashare-summary__item span,
|
||||
.ashare-summary__item strong,
|
||||
.state-panel__eyebrow,
|
||||
.placeholder-card__eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ashare-summary__item span {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.ashare-summary__item strong {
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.state-panel p,
|
||||
.placeholder-card p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.warning-strip {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid rgba(255, 193, 90, 0.24);
|
||||
background: rgba(51, 34, 8, 0.48);
|
||||
color: #ffd78a;
|
||||
}
|
||||
|
||||
.warning-strip p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ashare-tabs,
|
||||
.sector-tabs {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.ashare-tabs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sector-tabs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ashare-tabs__button,
|
||||
.sector-tabs__button {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.66rem 0.8rem;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.72);
|
||||
color: var(--color-text-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ashare-tabs__button strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ashare-tabs__button--active,
|
||||
.sector-tabs__button--active {
|
||||
border-color: rgba(123, 224, 212, 0.34);
|
||||
background: rgba(34, 94, 88, 0.22);
|
||||
}
|
||||
|
||||
.ashare-stage {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding-right: 4px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.placeholder-card {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(123, 224, 212, 0.18);
|
||||
background: rgba(5, 12, 20, 0.7);
|
||||
}
|
||||
|
||||
.placeholder-card h2,
|
||||
.state-panel h1 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
padding: 1.2rem;
|
||||
margin-top: 10vh;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.88);
|
||||
}
|
||||
|
||||
.state-panel--error {
|
||||
border-color: rgba(196, 77, 77, 0.42);
|
||||
}
|
||||
|
||||
.up {
|
||||
color: #ff8d8d;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: #65d8b5;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.ashare-summary,
|
||||
.ashare-tabs,
|
||||
.sector-tabs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.ashare-shell__content {
|
||||
width: min(100vw - 16px, 1380px);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ashare-topbar,
|
||||
.ashare-topbar__meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/dashboard/AppHeader.vue
Normal file
108
frontend/src/components/dashboard/AppHeader.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetaResponse } from '../../types/api'
|
||||
import { formatMarketState } from '../../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
meta: MetaResponse
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="app-header__identity">
|
||||
<p class="app-header__eyebrow">Southbound Flow Console</p>
|
||||
<h1 class="app-header__title">{{ meta.product_name }}</h1>
|
||||
<p class="app-header__note">{{ meta.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="app-header__meta">
|
||||
<div class="app-header__badge">
|
||||
<span class="app-header__badge-label">市场状态</span>
|
||||
<strong>{{ formatMarketState(meta.market_state) }}</strong>
|
||||
</div>
|
||||
<div class="app-header__badge">
|
||||
<span class="app-header__badge-label">交易日</span>
|
||||
<strong>{{ meta.current_trade_date }}</strong>
|
||||
</div>
|
||||
<div class="app-header__badge">
|
||||
<span class="app-header__badge-label">主数据源</span>
|
||||
<strong>{{ meta.source_name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(225, 197, 117, 0.14), rgba(13, 23, 41, 0.1)),
|
||||
rgba(8, 14, 26, 0.86);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.app-header__eyebrow {
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.app-header__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.55rem, 2.2vw, 2.3rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-header__note {
|
||||
margin: 0.45rem 0 0;
|
||||
max-width: 44rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.app-header__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(9rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-header__badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 4.8rem;
|
||||
padding: 0.75rem 0.8rem;
|
||||
border: 1px solid rgba(225, 197, 117, 0.18);
|
||||
background: rgba(8, 16, 30, 0.84);
|
||||
}
|
||||
|
||||
.app-header__badge-label {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-header {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-header__meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
287
frontend/src/components/dashboard/HistoryPanel.vue
Normal file
287
frontend/src/components/dashboard/HistoryPanel.vue
Normal file
@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { HistoryResponse } from '../../types/api'
|
||||
import { formatAmount } from '../../utils/formatters'
|
||||
import TrendChart from './TrendChart.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
history: HistoryResponse
|
||||
}>()
|
||||
|
||||
const benchmarkDaily = computed(() => props.history.benchmark_history.hstech_daily ?? [])
|
||||
const benchmarkWeekly = computed(() => props.history.benchmark_history.hstech_weekly ?? [])
|
||||
const benchmarkMonthly = computed(() => props.history.benchmark_history.hstech_monthly ?? [])
|
||||
|
||||
const dailyChart = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'daily',
|
||||
label: '日净流入',
|
||||
color: '#d6ad47',
|
||||
fill: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 亿`,
|
||||
points: props.history.daily.slice(-20).map((item) => ({
|
||||
label: item.period.slice(5),
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: 'daily-hstech',
|
||||
label: '恒生科技指数',
|
||||
color: '#9bffb0',
|
||||
axis: 'right' as const,
|
||||
dashed: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 点`,
|
||||
points: benchmarkDaily.value.slice(-20).map((item) => ({
|
||||
label: item.period.slice(5),
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const weeklyChart = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'weekly',
|
||||
label: '周净流入',
|
||||
color: '#64c8ff',
|
||||
fill: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 亿`,
|
||||
points: props.history.weekly.slice(-16).map((item) => ({
|
||||
label: item.period,
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: 'weekly-hstech',
|
||||
label: '恒生科技指数',
|
||||
color: '#9bffb0',
|
||||
axis: 'right' as const,
|
||||
dashed: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 点`,
|
||||
points: benchmarkWeekly.value.slice(-16).map((item) => ({
|
||||
label: item.period,
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const monthlyChart = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'monthly',
|
||||
label: '月净流入',
|
||||
color: '#7ce0a9',
|
||||
fill: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 亿`,
|
||||
points: props.history.monthly.slice(-12).map((item) => ({
|
||||
label: item.period,
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
},
|
||||
{
|
||||
key: 'monthly-hstech',
|
||||
label: '恒生科技指数',
|
||||
color: '#9bffb0',
|
||||
axis: 'right' as const,
|
||||
dashed: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 点`,
|
||||
points: benchmarkMonthly.value.slice(-12).map((item) => ({
|
||||
label: item.period,
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const cumulativeChart = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'cumulative',
|
||||
label: '累计净流入',
|
||||
color: '#f08f6f',
|
||||
fill: true,
|
||||
formatter: (value: number) => `${value.toFixed(2)} 亿`,
|
||||
points: props.history.cumulative.slice(-20).map((item) => ({
|
||||
label: item.period.slice(5),
|
||||
value: item.amount_hkd_billion
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<p class="panel__eyebrow">Historical Ledger</p>
|
||||
<h2 class="panel__title">历史统计</h2>
|
||||
</div>
|
||||
<p class="panel__summary">统计起点 {{ history.start_date }}</p>
|
||||
</div>
|
||||
|
||||
<div class="history-summary">
|
||||
<article class="history-summary__card">
|
||||
<span>累计净流入</span>
|
||||
<strong>{{ formatAmount(history.summary.cumulative_net_inflow_hkd_billion) }}</strong>
|
||||
</article>
|
||||
<article class="history-summary__card">
|
||||
<span>历史交易日</span>
|
||||
<strong>{{ history.summary.trading_day_count }}</strong>
|
||||
</article>
|
||||
<article class="history-summary__card">
|
||||
<span>最大单日流入</span>
|
||||
<strong>{{ formatAmount(history.summary.max_single_day_inflow_hkd_billion) }}</strong>
|
||||
</article>
|
||||
<article class="history-summary__card">
|
||||
<span>最大单日流出</span>
|
||||
<strong>{{ formatAmount(history.summary.max_single_day_outflow_hkd_billion) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="history-grid">
|
||||
<article class="history-chart">
|
||||
<div class="history-chart__head">
|
||||
<h3>日净流入</h3>
|
||||
<span>叠加恒生科技指数</span>
|
||||
</div>
|
||||
<TrendChart :series="dailyChart" />
|
||||
</article>
|
||||
|
||||
<article class="history-chart">
|
||||
<div class="history-chart__head">
|
||||
<h3>周净流入</h3>
|
||||
<span>叠加恒生科技指数</span>
|
||||
</div>
|
||||
<TrendChart :series="weeklyChart" />
|
||||
</article>
|
||||
|
||||
<article class="history-chart">
|
||||
<div class="history-chart__head">
|
||||
<h3>月净流入</h3>
|
||||
<span>叠加恒生科技指数</span>
|
||||
</div>
|
||||
<TrendChart :series="monthlyChart" />
|
||||
</article>
|
||||
|
||||
<article class="history-chart">
|
||||
<div class="history-chart__head">
|
||||
<h3>累计净流入</h3>
|
||||
<span>最近 20 个节点</span>
|
||||
</div>
|
||||
<TrendChart :series="cumulativeChart" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
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: 1rem;
|
||||
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__summary {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.history-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.history-summary__card {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.72rem 0.8rem;
|
||||
background: rgba(13, 20, 36, 0.75);
|
||||
}
|
||||
|
||||
.history-summary__card span {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.history-summary__card strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.history-chart {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-height: 23rem;
|
||||
padding: 0.8rem;
|
||||
background: rgba(13, 20, 36, 0.75);
|
||||
}
|
||||
|
||||
.history-chart__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.history-chart__head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.history-chart__head span {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.history-summary,
|
||||
.history-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel__header,
|
||||
.history-summary,
|
||||
.history-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
43
frontend/src/components/dashboard/MetricCard.vue
Normal file
43
frontend/src/components/dashboard/MetricCard.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { ValueWithStatus } from '../../types/api'
|
||||
import { formatAmount, formatPrecision } from '../../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
metric: ValueWithStatus
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<p class="metric-card__label">{{ metric.label }}</p>
|
||||
<strong class="metric-card__value">{{ formatAmount(metric.amount_hkd_billion) }}</strong>
|
||||
<p class="metric-card__precision">{{ formatPrecision(metric.precision) }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
padding: 1.15rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: linear-gradient(180deg, rgba(14, 23, 40, 0.88), rgba(8, 12, 22, 0.95));
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
display: block;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__precision {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--color-accent-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
252
frontend/src/components/dashboard/OverviewPanel.vue
Normal file
252
frontend/src/components/dashboard/OverviewPanel.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<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>
|
||||
239
frontend/src/components/dashboard/PushRecordsPanel.vue
Normal file
239
frontend/src/components/dashboard/PushRecordsPanel.vue
Normal file
@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PushRecord } from '../../types/api'
|
||||
import { formatAmount, formatTimestamp } from '../../utils/formatters'
|
||||
|
||||
const props = defineProps<{
|
||||
records: PushRecord[]
|
||||
}>()
|
||||
|
||||
const visibleRecords = computed(() => props.records.slice(0, 12))
|
||||
|
||||
function getStatusLabel(status: PushRecord['status']) {
|
||||
if (status === 'sent') return '已发送'
|
||||
if (status === 'failed') return '发送失败'
|
||||
if (status === 'pending') return '待发送'
|
||||
return '已跳过'
|
||||
}
|
||||
|
||||
function getPushTypeLabel(pushType: string) {
|
||||
if (pushType === 'email') return '邮件'
|
||||
return pushType
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<p class="panel__eyebrow">Push Ledger</p>
|
||||
<h2 class="panel__title">推送记录</h2>
|
||||
</div>
|
||||
<span class="panel__count">共 {{ records.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleRecords.length" class="record-list">
|
||||
<article v-for="record in visibleRecords" :key="record.id" class="record-list__item">
|
||||
<div class="record-list__headline">
|
||||
<strong class="record-list__subject">{{ record.email_subject }}</strong>
|
||||
<span :class="['record-list__status', `record-list__status--${record.status}`]">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="record-list__summary">{{ record.email_summary }}</p>
|
||||
|
||||
<dl class="record-list__meta">
|
||||
<div class="record-list__meta-item">
|
||||
<dt>时间</dt>
|
||||
<dd>{{ formatTimestamp(record.triggered_at) }}</dd>
|
||||
</div>
|
||||
<div class="record-list__meta-item">
|
||||
<dt>类型</dt>
|
||||
<dd>{{ getPushTypeLabel(record.push_type) }}</dd>
|
||||
</div>
|
||||
<div class="record-list__meta-item">
|
||||
<dt>规则</dt>
|
||||
<dd>{{ record.rule_code }}</dd>
|
||||
</div>
|
||||
<div class="record-list__meta-item">
|
||||
<dt>触发值</dt>
|
||||
<dd>{{ formatAmount(record.trigger_value_hkd_billion) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p class="record-list__description">{{ record.description }}</p>
|
||||
<p v-if="record.error_message" class="record-list__error">{{ record.error_message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="records-empty">
|
||||
<h3 class="records-empty__title">暂无推送记录</h3>
|
||||
<p class="records-empty__text">邮件或告警触发后,这里会按时间倒序列出每一条推送结果。</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
min-height: 100%;
|
||||
padding: 1.1rem;
|
||||
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: 1rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
margin: 0.3rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.panel__count {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.record-list__item {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-left: 3px solid rgba(214, 173, 71, 0.45);
|
||||
background: rgba(13, 20, 36, 0.78);
|
||||
}
|
||||
|
||||
.record-list__headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.record-list__subject {
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.record-list__status {
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.record-list__status--sent {
|
||||
border-color: rgba(124, 224, 169, 0.5);
|
||||
color: #7ce0a9;
|
||||
}
|
||||
|
||||
.record-list__status--failed {
|
||||
border-color: rgba(255, 117, 117, 0.5);
|
||||
color: #ff7575;
|
||||
}
|
||||
|
||||
.record-list__status--pending {
|
||||
border-color: rgba(214, 173, 71, 0.5);
|
||||
color: #d6ad47;
|
||||
}
|
||||
|
||||
.record-list__status--skipped {
|
||||
border-color: rgba(153, 168, 192, 0.45);
|
||||
color: #99a8c0;
|
||||
}
|
||||
|
||||
.record-list__summary,
|
||||
.record-list__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.record-list__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.record-list__meta-item {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-list__meta-item dt {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.record-list__meta-item dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.record-list__error {
|
||||
margin: 0;
|
||||
color: #ff9797;
|
||||
}
|
||||
|
||||
.records-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
background: rgba(13, 20, 36, 0.75);
|
||||
}
|
||||
|
||||
.records-empty__title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.records-empty__text {
|
||||
max-width: 36rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.record-list__headline {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-list__meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.panel__header,
|
||||
.record-list__meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
144
frontend/src/components/dashboard/RulesPanel.vue
Normal file
144
frontend/src/components/dashboard/RulesPanel.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import type { RuleItem, SourceDiagnosticsResponse } from '../../types/api'
|
||||
|
||||
defineProps<{
|
||||
items: RuleItem[]
|
||||
diagnostics: SourceDiagnosticsResponse
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel__header">
|
||||
<div>
|
||||
<p class="panel__eyebrow">Operations Deck</p>
|
||||
<h2 class="panel__title">规则与诊断</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rules-layout">
|
||||
<div class="rules-list">
|
||||
<article v-for="item in items" :key="item.key" class="rules-list__item">
|
||||
<div>
|
||||
<h3>{{ item.label }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="diagnostics">
|
||||
<h3>采集诊断</h3>
|
||||
<dl class="diagnostics__list">
|
||||
<div>
|
||||
<dt>实时接口</dt>
|
||||
<dd>{{ diagnostics.realtime_available ? '可用' : '不可用' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>历史接口</dt>
|
||||
<dd>{{ diagnostics.historical_available ? '可用' : '不可用' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近成功</dt>
|
||||
<dd>{{ diagnostics.last_success_at ?? '暂无' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近失败</dt>
|
||||
<dd>{{ diagnostics.last_failure_at ?? '暂无' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>错误原因</dt>
|
||||
<dd>{{ diagnostics.last_error_reason ?? '无' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
padding: 1.4rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.88);
|
||||
}
|
||||
|
||||
.panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.rules-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(18rem, 0.75fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.rules-list__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(13, 20, 36, 0.75);
|
||||
}
|
||||
|
||||
.rules-list__item h3,
|
||||
.diagnostics h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rules-list__item p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
padding: 1rem;
|
||||
background: rgba(13, 20, 36, 0.75);
|
||||
}
|
||||
|
||||
.diagnostics__list {
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.diagnostics__list div {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.8rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.diagnostics__list dt {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.diagnostics__list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.rules-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rules-list__item {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
555
frontend/src/components/dashboard/TrendChart.vue
Normal file
555
frontend/src/components/dashboard/TrendChart.vue
Normal file
@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
type AxisSide = 'left' | 'right'
|
||||
|
||||
interface TrendPoint {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface TrendSeries {
|
||||
key: string
|
||||
label: string
|
||||
color: string
|
||||
axis?: AxisSide
|
||||
fill?: boolean
|
||||
dashed?: boolean
|
||||
formatter?: (value: number) => string
|
||||
points: TrendPoint[]
|
||||
}
|
||||
|
||||
interface NormalizedPoint extends TrendPoint {
|
||||
index: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface NormalizedSeries extends TrendSeries {
|
||||
axis: AxisSide
|
||||
normalized: NormalizedPoint[]
|
||||
linePath: string
|
||||
areaPath: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
series: TrendSeries[]
|
||||
}>()
|
||||
|
||||
const width = 640
|
||||
const height = 220
|
||||
const paddingLeft = 28
|
||||
const paddingRight = 28
|
||||
const paddingTop = 18
|
||||
const paddingBottom = 34
|
||||
const plotWidth = width - paddingLeft - paddingRight
|
||||
const plotHeight = height - paddingTop - paddingBottom
|
||||
const activeIndex = shallowRef<number | null>(null)
|
||||
|
||||
function defaultFormatter(value: number): string {
|
||||
return `${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function buildPath(points: NormalizedPoint[]): string {
|
||||
return points
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const axisBounds = computed(() => {
|
||||
const bounds: Record<AxisSide, { min: number; max: number }> = {
|
||||
left: { min: 0, max: 1 },
|
||||
right: { min: 0, max: 1 }
|
||||
}
|
||||
|
||||
for (const axis of ['left', 'right'] as AxisSide[]) {
|
||||
const values = props.series
|
||||
.filter((item) => (item.axis ?? 'left') === axis)
|
||||
.flatMap((item) => item.points.map((point) => point.value))
|
||||
|
||||
if (!values.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
let min = Math.min(...values)
|
||||
let max = Math.max(...values)
|
||||
if (min === max) {
|
||||
const offset = Math.abs(min || 1) * 0.04
|
||||
min -= offset
|
||||
max += offset
|
||||
}
|
||||
|
||||
bounds[axis] = { min, max }
|
||||
}
|
||||
|
||||
return bounds
|
||||
})
|
||||
|
||||
const normalizedSeries = computed<NormalizedSeries[]>(() =>
|
||||
props.series.map((item) => {
|
||||
const axis = item.axis ?? 'left'
|
||||
const bound = axisBounds.value[axis]
|
||||
const span = bound.max - bound.min || 1
|
||||
const normalized = item.points.map((point, index) => {
|
||||
const x = paddingLeft + (index / Math.max(item.points.length - 1, 1)) * plotWidth
|
||||
const y = paddingTop + ((bound.max - point.value) / span) * plotHeight
|
||||
return { ...point, index, x, y }
|
||||
})
|
||||
const linePath = buildPath(normalized)
|
||||
const first = normalized[0]
|
||||
const last = normalized[normalized.length - 1]
|
||||
const areaPath =
|
||||
item.fill && first && last
|
||||
? `${linePath} L ${last.x.toFixed(2)} ${(height - paddingBottom).toFixed(2)} L ${first.x.toFixed(2)} ${(height - paddingBottom).toFixed(2)} Z`
|
||||
: ''
|
||||
|
||||
return {
|
||||
...item,
|
||||
axis,
|
||||
normalized,
|
||||
linePath,
|
||||
areaPath
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const referencePoints = computed(() => normalizedSeries.value[0]?.normalized ?? [])
|
||||
|
||||
const tickPoints = computed(() => {
|
||||
if (!referencePoints.value.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (referencePoints.value.length <= 4) {
|
||||
return referencePoints.value
|
||||
}
|
||||
|
||||
const candidateIndexes = [
|
||||
0,
|
||||
Math.floor(referencePoints.value.length / 3),
|
||||
Math.floor((referencePoints.value.length * 2) / 3),
|
||||
referencePoints.value.length - 1
|
||||
]
|
||||
|
||||
return candidateIndexes.map((index) => referencePoints.value[index])
|
||||
})
|
||||
|
||||
const resolvedActiveIndex = computed(() => {
|
||||
if (!referencePoints.value.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeIndex.value === null) {
|
||||
return referencePoints.value.length - 1
|
||||
}
|
||||
|
||||
return Math.min(activeIndex.value, referencePoints.value.length - 1)
|
||||
})
|
||||
|
||||
const activeLabel = computed(() => {
|
||||
if (resolvedActiveIndex.value === null) {
|
||||
return null
|
||||
}
|
||||
return referencePoints.value[resolvedActiveIndex.value]?.label ?? null
|
||||
})
|
||||
|
||||
const activeX = computed(() => {
|
||||
if (resolvedActiveIndex.value === null) {
|
||||
return null
|
||||
}
|
||||
return referencePoints.value[resolvedActiveIndex.value]?.x ?? null
|
||||
})
|
||||
|
||||
const activeEntries = computed(() =>
|
||||
normalizedSeries.value
|
||||
.map((series) => {
|
||||
if (resolvedActiveIndex.value === null) {
|
||||
return null
|
||||
}
|
||||
const point = series.normalized[resolvedActiveIndex.value]
|
||||
if (!point) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
axis: series.axis,
|
||||
value: point.value,
|
||||
formattedValue: (series.formatter ?? defaultFormatter)(point.value),
|
||||
x: point.x,
|
||||
y: point.y
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
)
|
||||
|
||||
const chartSummary = computed(() =>
|
||||
normalizedSeries.value.map((series) => {
|
||||
const first = series.points[0]
|
||||
const last = series.points[series.points.length - 1]
|
||||
if (!first || !last) {
|
||||
return null
|
||||
}
|
||||
const max = series.points.reduce((best, item) => (item.value > best.value ? item : best), first)
|
||||
const min = series.points.reduce((best, item) => (item.value < best.value ? item : best), first)
|
||||
return {
|
||||
key: series.key,
|
||||
label: series.label,
|
||||
color: series.color,
|
||||
first: (series.formatter ?? defaultFormatter)(first.value),
|
||||
last: (series.formatter ?? defaultFormatter)(last.value),
|
||||
max: (series.formatter ?? defaultFormatter)(max.value),
|
||||
min: (series.formatter ?? defaultFormatter)(min.value)
|
||||
}
|
||||
}).filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
)
|
||||
|
||||
const axisTicks = computed(() => {
|
||||
return {
|
||||
left: [0, 0.5, 1].map((offset) => {
|
||||
const bound = axisBounds.value.left
|
||||
const value = bound.max - (bound.max - bound.min) * offset
|
||||
return { offset, value }
|
||||
}),
|
||||
right: [0, 0.5, 1].map((offset) => {
|
||||
const bound = axisBounds.value.right
|
||||
const value = bound.max - (bound.max - bound.min) * offset
|
||||
return { offset, value }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!referencePoints.value.length) {
|
||||
activeIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.currentTarget as SVGRectElement | null
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = target.getBoundingClientRect()
|
||||
const ratio = width / bounds.width
|
||||
const cursorX = (event.clientX - bounds.left) * ratio
|
||||
|
||||
let nearestPoint = referencePoints.value[0]
|
||||
let nearestDistance = Math.abs(cursorX - nearestPoint.x)
|
||||
|
||||
for (const point of referencePoints.value) {
|
||||
const distance = Math.abs(cursorX - point.x)
|
||||
if (distance < nearestDistance) {
|
||||
nearestPoint = point
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
activeIndex.value = nearestPoint.index
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
activeIndex.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div v-if="series.length" class="trend-chart__canvas">
|
||||
<svg class="trend-chart__svg" :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="none" aria-hidden="true">
|
||||
<g class="trend-chart__grid">
|
||||
<line
|
||||
v-for="offset in [0, 0.5, 1]"
|
||||
:key="offset"
|
||||
:x1="paddingLeft"
|
||||
:x2="width - paddingRight"
|
||||
:y1="paddingTop + plotHeight * offset"
|
||||
:y2="paddingTop + plotHeight * offset"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g class="trend-chart__axis trend-chart__axis--left">
|
||||
<text
|
||||
v-for="tick in axisTicks.left"
|
||||
:key="`left-${tick.offset}`"
|
||||
:x="4"
|
||||
:y="paddingTop + plotHeight * tick.offset + 4"
|
||||
>
|
||||
{{ tick.value.toFixed(1) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g class="trend-chart__axis trend-chart__axis--right">
|
||||
<text
|
||||
v-for="tick in axisTicks.right"
|
||||
:key="`right-${tick.offset}`"
|
||||
:x="width - 4"
|
||||
:y="paddingTop + plotHeight * tick.offset + 4"
|
||||
text-anchor="end"
|
||||
>
|
||||
{{ tick.value.toFixed(1) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g v-for="item in normalizedSeries" :key="item.key">
|
||||
<path
|
||||
v-if="item.areaPath"
|
||||
:d="item.areaPath"
|
||||
class="trend-chart__area"
|
||||
:style="{ fill: `${item.color}20` }"
|
||||
/>
|
||||
<path
|
||||
v-if="item.linePath"
|
||||
:d="item.linePath"
|
||||
fill="none"
|
||||
:stroke="item.color"
|
||||
stroke-width="3"
|
||||
:stroke-dasharray="item.dashed ? '8 6' : undefined"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g v-if="activeX !== null" class="trend-chart__focus">
|
||||
<line
|
||||
class="trend-chart__focus-line"
|
||||
:x1="activeX"
|
||||
:x2="activeX"
|
||||
:y1="paddingTop"
|
||||
:y2="height - paddingBottom"
|
||||
/>
|
||||
<circle
|
||||
v-for="entry in activeEntries"
|
||||
:key="entry.key"
|
||||
:cx="entry.x"
|
||||
:cy="entry.y"
|
||||
r="5.5"
|
||||
:fill="entry.color"
|
||||
class="trend-chart__focus-dot"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<rect
|
||||
class="trend-chart__hitbox"
|
||||
:x="paddingLeft"
|
||||
:y="paddingTop"
|
||||
:width="plotWidth"
|
||||
:height="plotHeight"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="clearHover"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div v-if="activeEntries.length" class="trend-chart__tooltip">
|
||||
<span class="trend-chart__tooltip-label">{{ activeLabel }}</span>
|
||||
<div class="trend-chart__tooltip-list">
|
||||
<div v-for="entry in activeEntries" :key="entry.key" class="trend-chart__tooltip-row">
|
||||
<span class="trend-chart__tooltip-series">
|
||||
<i :style="{ backgroundColor: entry.color }"></i>
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
<strong>{{ entry.formattedValue }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart__legend">
|
||||
<span v-for="item in normalizedSeries" :key="item.key" class="trend-chart__legend-item">
|
||||
<i :style="{ backgroundColor: item.color }"></i>
|
||||
{{ item.label }}
|
||||
<small>{{ item.axis === 'left' ? '左轴' : '右轴' }}</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="trend-chart__labels">
|
||||
<span
|
||||
v-for="point in tickPoints"
|
||||
:key="`${point.label}-${point.x}`"
|
||||
:class="['trend-chart__label', { 'trend-chart__label--active': activeLabel === point.label }]"
|
||||
>
|
||||
{{ point.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="trend-chart__empty">
|
||||
暂无可展示的真实数据
|
||||
</div>
|
||||
|
||||
<div v-if="chartSummary.length" class="trend-chart__stats">
|
||||
<article v-for="item in chartSummary" :key="item.key" class="trend-chart__stat-card">
|
||||
<p :style="{ color: item.color }">{{ item.label }}</p>
|
||||
<span>起点 {{ item.first }}</span>
|
||||
<span>终点 {{ item.last }}</span>
|
||||
<span>峰值 {{ item.max }}</span>
|
||||
<span>低点 {{ item.min }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 0.8rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trend-chart__canvas {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0.7rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.trend-chart__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 210px;
|
||||
}
|
||||
|
||||
.trend-chart__grid line {
|
||||
stroke: rgba(255, 255, 255, 0.08);
|
||||
stroke-dasharray: 4 6;
|
||||
}
|
||||
|
||||
.trend-chart__axis text {
|
||||
fill: rgba(255, 255, 255, 0.42);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.trend-chart__area {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.trend-chart__hitbox {
|
||||
fill: transparent;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.trend-chart__focus-line {
|
||||
stroke: rgba(255, 255, 255, 0.26);
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.trend-chart__focus-dot {
|
||||
stroke: rgba(8, 14, 26, 0.95);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.trend-chart__tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
min-width: 180px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(7, 11, 20, 0.94);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.trend-chart__tooltip-label {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.trend-chart__tooltip-list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.trend-chart__tooltip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.trend-chart__tooltip-series,
|
||||
.trend-chart__legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.trend-chart__tooltip-series i,
|
||||
.trend-chart__legend-item i {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trend-chart__legend {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.trend-chart__legend-item small {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.trend-chart__labels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trend-chart__label {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.72rem;
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.trend-chart__label--active {
|
||||
color: var(--color-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.trend-chart__label:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.trend-chart__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.trend-chart__stat-card {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.76rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.trend-chart__stat-card p {
|
||||
margin: 0 0 0.1rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.trend-chart__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 190px;
|
||||
color: var(--color-text-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.trend-chart__tooltip {
|
||||
left: 8px;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
frontend/src/components/home/HomePortal.vue
Normal file
126
frontend/src/components/home/HomePortal.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import PortalCard from './PortalCard.vue'
|
||||
|
||||
defineEmits<{
|
||||
'open-southbound': []
|
||||
'open-ashare': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="portal-shell">
|
||||
<div class="portal-shell__backdrop"></div>
|
||||
<section class="portal-shell__content">
|
||||
<header class="portal-hero">
|
||||
<p class="portal-hero__eyebrow">Capital Flow Command Center</p>
|
||||
<h1 class="portal-hero__title">资金监控总入口</h1>
|
||||
<p class="portal-hero__description">
|
||||
一个主页面统一进入南向资金监控和 A 股资金监控。南向资金页保持现有实时监控能力,A 股资金页按东方财富标准指标体系扩展。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="portal-grid">
|
||||
<PortalCard
|
||||
eyebrow="Southbound"
|
||||
title="南向资金监控"
|
||||
description="查看港股通(沪)与港股通(深)的实时净流入、分钟级曲线、历史趋势与推送记录。"
|
||||
:bullet-points="[
|
||||
'实时总览与分钟曲线',
|
||||
'历史净流入图表',
|
||||
'推送记录与阈值监控'
|
||||
]"
|
||||
action-label="进入南向资金页"
|
||||
accent="gold"
|
||||
@open="$emit('open-southbound')"
|
||||
/>
|
||||
|
||||
<PortalCard
|
||||
eyebrow="A Share"
|
||||
title="A股资金监控"
|
||||
description="按东方财富标准分类进入 A 股资金监控,覆盖个股资金流、板块资金流、指数资金流三类页面能力。"
|
||||
:bullet-points="[
|
||||
'个股:主力 / 超大单 / 大单 / 中单 / 小单',
|
||||
'板块:行业 / 概念 / 地域',
|
||||
'指数:宽基指数与主流指数资金流'
|
||||
]"
|
||||
action-label="进入 A 股资金页"
|
||||
accent="teal"
|
||||
@open="$emit('open-ashare')"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portal-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-shell__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 16%, rgba(214, 173, 71, 0.16), transparent 24%),
|
||||
radial-gradient(circle at 82% 18%, rgba(55, 175, 160, 0.14), transparent 26%),
|
||||
linear-gradient(150deg, #08101d 8%, #050912 56%, #030507 100%);
|
||||
}
|
||||
|
||||
.portal-shell__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
width: min(1380px, calc(100vw - 32px));
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.portal-hero {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.portal-hero__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.portal-hero__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 6vw, 5.6rem);
|
||||
line-height: 0.96;
|
||||
}
|
||||
|
||||
.portal-hero__description {
|
||||
max-width: 52rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.portal-shell__content {
|
||||
width: min(100vw - 20px, 1380px);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.portal-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
frontend/src/components/home/PortalCard.vue
Normal file
92
frontend/src/components/home/PortalCard.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
eyebrow: string
|
||||
title: string
|
||||
description: string
|
||||
bulletPoints: string[]
|
||||
actionLabel: string
|
||||
accent: 'gold' | 'teal'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article :class="['portal-card', `portal-card--${accent}`]">
|
||||
<p class="portal-card__eyebrow">{{ eyebrow }}</p>
|
||||
<h2 class="portal-card__title">{{ title }}</h2>
|
||||
<p class="portal-card__description">{{ description }}</p>
|
||||
|
||||
<ul class="portal-card__list">
|
||||
<li v-for="point in bulletPoints" :key="point">{{ point }}</li>
|
||||
</ul>
|
||||
|
||||
<button class="portal-card__action" type="button" @click="$emit('open')">
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portal-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 22rem;
|
||||
padding: 1.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.82);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.portal-card--gold {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(214, 173, 71, 0.18), transparent 35%),
|
||||
rgba(8, 14, 26, 0.84);
|
||||
}
|
||||
|
||||
.portal-card--teal {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(55, 175, 160, 0.2), transparent 35%),
|
||||
rgba(8, 14, 26, 0.84);
|
||||
}
|
||||
|
||||
.portal-card__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.portal-card__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 2.4vw, 2.8rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.portal-card__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.portal-card__list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: var(--color-text);
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.portal-card__action {
|
||||
justify-self: start;
|
||||
padding: 0.9rem 1.3rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
207
frontend/src/components/southbound/SouthboundWorkspace.vue
Normal file
207
frontend/src/components/southbound/SouthboundWorkspace.vue
Normal file
@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, shallowRef } from 'vue'
|
||||
import AppHeader from '../dashboard/AppHeader.vue'
|
||||
import HistoryPanel from '../dashboard/HistoryPanel.vue'
|
||||
import OverviewPanel from '../dashboard/OverviewPanel.vue'
|
||||
import PushRecordsPanel from '../dashboard/PushRecordsPanel.vue'
|
||||
import { useDashboardData } from '../../composables/useDashboardData'
|
||||
|
||||
type TabKey = 'overview' | 'history' | 'push'
|
||||
|
||||
defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const { state, hasData, load } = useDashboardData()
|
||||
const activeTab = shallowRef<TabKey>('overview')
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
const tabs = computed<{ key: TabKey; label: string }[]>(() => [
|
||||
{ key: 'overview', label: '实时总览' },
|
||||
{ key: 'history', label: '历史图表' },
|
||||
{ key: 'push', label: '推送记录' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="workspace-shell">
|
||||
<div class="workspace-shell__backdrop"></div>
|
||||
|
||||
<div class="workspace-shell__content">
|
||||
<header class="workspace-topbar">
|
||||
<button class="workspace-topbar__back" type="button" @click="$emit('back')">返回主页面</button>
|
||||
<div class="workspace-topbar__meta">
|
||||
<span class="workspace-topbar__label">南向资金监控</span>
|
||||
<button class="workspace-topbar__refresh" type="button" @click="load">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="state.loading" class="state-panel">
|
||||
<p class="state-panel__eyebrow">Loading</p>
|
||||
<h1>正在加载南向资金监控面板</h1>
|
||||
<p>当前正在同步实时总览、历史统计与推送记录。</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="state.error" class="state-panel state-panel--error">
|
||||
<p class="state-panel__eyebrow">Connection Error</p>
|
||||
<h1>后端接口不可达</h1>
|
||||
<p>{{ state.error }}</p>
|
||||
<button class="state-panel__action" type="button" @click="load">重试</button>
|
||||
</section>
|
||||
|
||||
<template v-else-if="hasData && state.meta && state.overview && state.history">
|
||||
<AppHeader :meta="state.meta" />
|
||||
|
||||
<nav class="tab-nav" aria-label="southbound tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
:class="['tab-nav__button', { 'tab-nav__button--active': activeTab === tab.key }]"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="tab-stage">
|
||||
<OverviewPanel v-show="activeTab === 'overview'" :overview="state.overview" />
|
||||
<HistoryPanel v-show="activeTab === 'history'" :history="state.history" />
|
||||
<PushRecordsPanel v-show="activeTab === 'push'" :records="state.pushRecords" />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-shell {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-shell__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 173, 71, 0.14), transparent 30%),
|
||||
radial-gradient(circle at 85% 20%, rgba(61, 128, 201, 0.18), transparent 25%),
|
||||
linear-gradient(160deg, #08101d 10%, #060a12 70%, #030507 100%);
|
||||
}
|
||||
|
||||
.workspace-shell__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
width: min(1380px, calc(100vw - 24px));
|
||||
height: 100dvh;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.workspace-topbar__meta {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-topbar__back,
|
||||
.workspace-topbar__refresh,
|
||||
.state-panel__action {
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(8, 14, 26, 0.72);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-topbar__label {
|
||||
color: var(--color-text-subtle);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-nav__button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 0.58rem 0.9rem;
|
||||
background: rgba(8, 14, 26, 0.68);
|
||||
color: var(--color-text-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-nav__button--active {
|
||||
border-color: rgba(214, 173, 71, 0.5);
|
||||
background: rgba(214, 173, 71, 0.16);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab-stage {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
padding: 1.2rem;
|
||||
margin-top: 10vh;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 14, 26, 0.88);
|
||||
}
|
||||
|
||||
.state-panel--error {
|
||||
border-color: rgba(196, 77, 77, 0.42);
|
||||
}
|
||||
|
||||
.state-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.state-panel h1 {
|
||||
margin: 0.4rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.state-panel p {
|
||||
max-width: 42rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.workspace-shell__content {
|
||||
width: min(100vw - 16px, 1380px);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.workspace-topbar,
|
||||
.workspace-topbar__meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/composables/useAShareData.ts
Normal file
84
frontend/src/composables/useAShareData.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import type { AShareIndexFlowResponse, AShareSectorFlowResponse } from '../types/api'
|
||||
|
||||
const API_BASE = 'http://127.0.0.1:10000/api'
|
||||
|
||||
interface AShareState {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
warnings: string[]
|
||||
indexRealtime: AShareIndexFlowResponse | null
|
||||
sectorRealtime: AShareSectorFlowResponse | null
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, timeoutMs = 10000): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAShareData() {
|
||||
const state = shallowRef<AShareState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
warnings: [],
|
||||
indexRealtime: null,
|
||||
sectorRealtime: null
|
||||
})
|
||||
|
||||
async function load() {
|
||||
state.value = { ...state.value, loading: true, error: null, warnings: [] }
|
||||
|
||||
try {
|
||||
const indexRealtime = await fetchJson<AShareIndexFlowResponse>('/ashare/index-flows/realtime')
|
||||
state.value = {
|
||||
...state.value,
|
||||
loading: false,
|
||||
error: null,
|
||||
warnings: [],
|
||||
indexRealtime,
|
||||
sectorRealtime: null
|
||||
}
|
||||
|
||||
try {
|
||||
const sectorRealtime = await fetchJson<AShareSectorFlowResponse>(
|
||||
'/ashare/sector-flows/realtime',
|
||||
12000
|
||||
)
|
||||
state.value = {
|
||||
...state.value,
|
||||
sectorRealtime
|
||||
}
|
||||
} catch {
|
||||
state.value = {
|
||||
...state.value,
|
||||
warnings: ['板块资金接口本次请求超时,页面先展示指数数据,可稍后刷新重试。']
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
state.value = {
|
||||
...state.value,
|
||||
loading: false,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasData = computed(() => Boolean(state.value.indexRealtime))
|
||||
|
||||
return {
|
||||
state,
|
||||
hasData,
|
||||
load
|
||||
}
|
||||
}
|
||||
72
frontend/src/composables/useDashboardData.ts
Normal file
72
frontend/src/composables/useDashboardData.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import type {
|
||||
HistoryResponse,
|
||||
MetaResponse,
|
||||
OverviewResponse,
|
||||
PushRecord
|
||||
} from '../types/api'
|
||||
|
||||
const API_BASE = 'http://127.0.0.1:10000/api'
|
||||
|
||||
interface DashboardState {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
meta: MetaResponse | null
|
||||
overview: OverviewResponse | null
|
||||
history: HistoryResponse | null
|
||||
pushRecords: PushRecord[]
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function useDashboardData() {
|
||||
const state = shallowRef<DashboardState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
meta: null,
|
||||
overview: null,
|
||||
history: null,
|
||||
pushRecords: []
|
||||
})
|
||||
|
||||
async function load() {
|
||||
state.value = { ...state.value, loading: true, error: null }
|
||||
try {
|
||||
const [meta, overview, history, pushRecordsResponse] = await Promise.all([
|
||||
fetchJson<MetaResponse>('/meta'),
|
||||
fetchJson<OverviewResponse>('/overview'),
|
||||
fetchJson<HistoryResponse>('/history'),
|
||||
fetchJson<{ records: PushRecord[] }>('/push-records')
|
||||
])
|
||||
|
||||
state.value = {
|
||||
loading: false,
|
||||
error: null,
|
||||
meta,
|
||||
overview,
|
||||
history,
|
||||
pushRecords: pushRecordsResponse.records
|
||||
}
|
||||
} catch (error) {
|
||||
state.value = {
|
||||
...state.value,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasData = computed(() => Boolean(state.value.meta && state.value.overview))
|
||||
|
||||
return {
|
||||
state,
|
||||
hasData,
|
||||
load
|
||||
}
|
||||
}
|
||||
5
frontend/src/main.ts
Normal file
5
frontend/src/main.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
193
frontend/src/types/api.ts
Normal file
193
frontend/src/types/api.ts
Normal file
@ -0,0 +1,193 @@
|
||||
export type Precision = 'realtime_exact' | 'close_final' | 'historical_exact' | 'unavailable'
|
||||
export type MarketState =
|
||||
| 'pre_open'
|
||||
| 'trading_am'
|
||||
| 'midday_break'
|
||||
| 'trading_pm'
|
||||
| 'finalizing'
|
||||
| 'closed'
|
||||
|
||||
export interface ValueWithStatus {
|
||||
amount_hkd_billion: number | null
|
||||
precision: Precision
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface OverviewSnapshot {
|
||||
trade_date: string
|
||||
snapshot_time: string | null
|
||||
market_state: MarketState
|
||||
total_net_inflow: ValueWithStatus
|
||||
cumulative_net_inflow: ValueWithStatus
|
||||
shanghai_net_inflow: ValueWithStatus
|
||||
shenzhen_net_inflow: ValueWithStatus
|
||||
buy_amount: ValueWithStatus
|
||||
sell_amount: ValueWithStatus
|
||||
net_buy_amount: ValueWithStatus
|
||||
one_min_change: ValueWithStatus
|
||||
five_min_change: ValueWithStatus
|
||||
threshold_progress: number
|
||||
next_threshold_hkd_billion: number
|
||||
source_name: string
|
||||
source_url: string | null
|
||||
updated_at: string | null
|
||||
unavailable_reason: string | null
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
timestamp: string
|
||||
amount_hkd_billion: number | null
|
||||
precision: Precision
|
||||
}
|
||||
|
||||
export interface BenchmarkTimelinePoint {
|
||||
timestamp: string
|
||||
value: number | null
|
||||
}
|
||||
|
||||
export interface BenchmarkTimelineSeries {
|
||||
key: string
|
||||
label: string
|
||||
unit: string
|
||||
detail_url: string | null
|
||||
points: BenchmarkTimelinePoint[]
|
||||
}
|
||||
|
||||
export interface PushRecord {
|
||||
id: string
|
||||
triggered_at: string
|
||||
push_type: string
|
||||
rule_code: string
|
||||
trigger_value_hkd_billion: number | null
|
||||
description: string
|
||||
email_subject: string
|
||||
email_summary: string
|
||||
status: 'pending' | 'sent' | 'failed' | 'skipped'
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
export interface OverviewResponse {
|
||||
snapshot: OverviewSnapshot
|
||||
minute_timeline: TimelinePoint[]
|
||||
benchmark_series: BenchmarkTimelineSeries[]
|
||||
recent_push_records: PushRecord[]
|
||||
}
|
||||
|
||||
export interface StatPoint {
|
||||
period: string
|
||||
amount_hkd_billion: number
|
||||
}
|
||||
|
||||
export interface RecentTradeDay {
|
||||
trade_date: string
|
||||
total_net_inflow_hkd_billion: number
|
||||
precision: Precision
|
||||
}
|
||||
|
||||
export interface HistorySummary {
|
||||
cumulative_net_inflow_hkd_billion: number
|
||||
trading_day_count: number
|
||||
max_single_day_inflow_hkd_billion: number
|
||||
max_single_day_outflow_hkd_billion: number
|
||||
longest_inflow_streak: number
|
||||
longest_outflow_streak: number
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
start_date: string
|
||||
daily: StatPoint[]
|
||||
weekly: StatPoint[]
|
||||
monthly: StatPoint[]
|
||||
cumulative: StatPoint[]
|
||||
benchmark_history: Record<string, StatPoint[]>
|
||||
recent_trade_days: RecentTradeDay[]
|
||||
summary: HistorySummary
|
||||
}
|
||||
|
||||
export interface RuleItem {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface RulesResponse {
|
||||
items: RuleItem[]
|
||||
}
|
||||
|
||||
export interface SourceDiagnosticsResponse {
|
||||
source_name: string
|
||||
realtime_available: boolean
|
||||
historical_available: boolean
|
||||
last_success_at: string | null
|
||||
last_failure_at: string | null
|
||||
last_error_reason: string | null
|
||||
last_success_url: string | null
|
||||
last_persisted_at: string | null
|
||||
}
|
||||
|
||||
export interface MetaResponse {
|
||||
product_name: string
|
||||
version: string
|
||||
timezone: string
|
||||
market_state: MarketState
|
||||
current_trade_date: string
|
||||
source_name: string
|
||||
source_strategy: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface AShareFlowRecord {
|
||||
trade_date: string
|
||||
code: string
|
||||
name: string
|
||||
detail_url: string | null
|
||||
latest_price: number | null
|
||||
change_amount: number | null
|
||||
change_percent: number | null
|
||||
main_net_inflow: number | null
|
||||
main_net_inflow_ratio: number | null
|
||||
super_large_net_inflow: number | null
|
||||
super_large_net_inflow_ratio: number | null
|
||||
large_net_inflow: number | null
|
||||
large_net_inflow_ratio: number | null
|
||||
medium_net_inflow: number | null
|
||||
medium_net_inflow_ratio: number | null
|
||||
small_net_inflow: number | null
|
||||
small_net_inflow_ratio: number | null
|
||||
rolling_net_inflow_5d: number | null
|
||||
rolling_net_inflow_10d: number | null
|
||||
rolling_net_inflow_30d: number | null
|
||||
rolling_net_inflow_60d: number | null
|
||||
rolling_net_inflow_90d: number | null
|
||||
updated_at: string | null
|
||||
source_name: string
|
||||
source_url: string | null
|
||||
precision: Precision
|
||||
snapshot_time: string | null
|
||||
sector_type: string | null
|
||||
sector_type_label: string | null
|
||||
}
|
||||
|
||||
export interface AShareSectorGroup {
|
||||
label: string
|
||||
records: AShareFlowRecord[]
|
||||
}
|
||||
|
||||
export interface AShareIndexFlowResponse {
|
||||
trade_date: string
|
||||
updated_at: string | null
|
||||
source_name: string
|
||||
source_url: string | null
|
||||
precision: Precision
|
||||
records: AShareFlowRecord[]
|
||||
}
|
||||
|
||||
export interface AShareSectorFlowResponse {
|
||||
trade_date: string
|
||||
updated_at: string | null
|
||||
source_name: string
|
||||
source_url: string | null
|
||||
precision: Precision
|
||||
sector_types: Record<string, AShareSectorGroup>
|
||||
}
|
||||
59
frontend/src/utils/formatters.ts
Normal file
59
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export function formatAmount(value: number | null): string {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatFlowAmountYi(value: number | null): string {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)} 亿`
|
||||
}
|
||||
|
||||
export function formatPrice(value: number | null): string {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null): string {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function formatTimestamp(value: string | null): string {
|
||||
return value ?? '待更新'
|
||||
}
|
||||
|
||||
export function formatMarketState(state: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
pre_open: '盘前',
|
||||
trading_am: '上午交易',
|
||||
midday_break: '午间休市',
|
||||
trading_pm: '下午交易',
|
||||
finalizing: '收盘确认中',
|
||||
closed: '已收盘'
|
||||
}
|
||||
|
||||
return mapping[state] ?? state
|
||||
}
|
||||
|
||||
export function formatPrecision(state: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
realtime_exact: '实时准确值',
|
||||
close_final: '收盘最终值',
|
||||
historical_exact: '历史准确值',
|
||||
unavailable: '当前不可用'
|
||||
}
|
||||
|
||||
return mapping[state] ?? state
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user