Initial commit

This commit is contained in:
wanghep
2026-03-20 21:47:30 +08:00
commit 2eab960303
83 changed files with 51694 additions and 0 deletions

37
frontend/src/App.vue Normal file
View 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>

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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
View 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
View 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>
}

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />