feat(FEAT-MO-005): 展示市场结构与成交额趋势
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **a-share-analysis** (3793 symbols, 5473 relationships, 115 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **a-share-analysis** (4210 symbols, 6274 relationships, 147 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
|
||||
import { BadRequestException, Controller, Get, Inject, Query } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
@ApiTags('market-overview-structure')
|
||||
@Controller('market-overview')
|
||||
export class MarketStructureController {
|
||||
constructor(private readonly service: MarketStructureService) {}
|
||||
constructor(@Inject(MarketStructureService) private readonly service: MarketStructureService) {}
|
||||
|
||||
@Get('sector-rankings')
|
||||
@ApiQuery({ name: 'category', required: false, enum: ['industry', 'concept', 'board'] })
|
||||
|
||||
+137
-1
@@ -542,6 +542,10 @@ textarea {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-grid-structure {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
min-height: 190px;
|
||||
padding: 18px;
|
||||
@@ -556,6 +560,125 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structure-tabs,
|
||||
.structure-sort {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.structure-tabs button,
|
||||
.structure-sort button {
|
||||
min-height: 28px;
|
||||
padding: 4px 9px;
|
||||
color: #62717d;
|
||||
background: #f2f5f6;
|
||||
border: 1px solid #dfe6e9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.structure-tabs button.active,
|
||||
.structure-sort button.active {
|
||||
color: #11686c;
|
||||
background: #e8f5f3;
|
||||
border-color: #a9d3cf;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.structure-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.structure-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 31px;
|
||||
padding: 5px 7px;
|
||||
color: #3e4d58;
|
||||
background: #f7f9fa;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.structure-row:hover {
|
||||
border-color: #b9d7d4;
|
||||
background: #f0f7f6;
|
||||
}
|
||||
|
||||
.structure-row-static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.structure-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-value {
|
||||
color: #c33f3f;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-value[data-direction='down'] {
|
||||
color: #19846e;
|
||||
}
|
||||
|
||||
.structure-row small {
|
||||
max-width: 84px;
|
||||
overflow: hidden;
|
||||
color: #87939c;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.turnover-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.turnover-summary strong {
|
||||
color: #1c2a35;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.turnover-summary span {
|
||||
color: #7d8893;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.turnover-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
margin-top: 13px;
|
||||
overflow: visible;
|
||||
background: #f7f9fa;
|
||||
border: 1px solid #e3e9ec;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
padding: 6px 9px;
|
||||
color: #167d82;
|
||||
@@ -838,7 +961,8 @@ textarea {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.overview-grid-top,
|
||||
.overview-grid-bottom {
|
||||
.overview-grid-bottom,
|
||||
.overview-grid-structure {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -896,6 +1020,18 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.structure-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.structure-row small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.turnover-chart {
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
.driver-column + .driver-column {
|
||||
padding-top: 14px;
|
||||
padding-left: 0;
|
||||
|
||||
@@ -9,6 +9,9 @@ import SectorOverview from './components/SectorOverview.vue';
|
||||
import FundTrend from './components/FundTrend.vue';
|
||||
import IndexOverview from './components/IndexOverview.vue';
|
||||
import ShortTermEmotion from './components/ShortTermEmotion.vue';
|
||||
import SectorRankingPanel from './components/SectorRankingPanel.vue';
|
||||
import CapitalFlowPanel from './components/CapitalFlowPanel.vue';
|
||||
import TurnoverTrendPanel from './components/TurnoverTrendPanel.vue';
|
||||
import { useMarketOverview } from './useMarketOverview';
|
||||
|
||||
const {
|
||||
@@ -17,12 +20,19 @@ const {
|
||||
breadth,
|
||||
sectors,
|
||||
funds,
|
||||
sectorRankings,
|
||||
capitalFlow,
|
||||
turnoverTrend,
|
||||
shortTermEmotion,
|
||||
shortTermConclusion,
|
||||
sectorCategory,
|
||||
capitalFlowCategory,
|
||||
isLoading,
|
||||
analysisHorizon,
|
||||
analysisEngine,
|
||||
refresh,
|
||||
refreshSectorRankings,
|
||||
refreshCapitalFlow,
|
||||
setAnalysisHorizon,
|
||||
setAnalysisEngine,
|
||||
} = useMarketOverview();
|
||||
@@ -99,6 +109,26 @@ function openSectorDetail(code: string) {
|
||||
@select="openSectorDetail"
|
||||
/>
|
||||
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||
</div>
|
||||
|
||||
<div class="overview-grid overview-grid-structure">
|
||||
<SectorRankingPanel
|
||||
:response="sectorRankings.response"
|
||||
:loading="sectorRankings.status === 'loading'"
|
||||
:category="sectorCategory"
|
||||
@category-change="refreshSectorRankings"
|
||||
@select="openSectorDetail"
|
||||
/>
|
||||
<CapitalFlowPanel
|
||||
:response="capitalFlow.response"
|
||||
:loading="capitalFlow.status === 'loading'"
|
||||
:category="capitalFlowCategory"
|
||||
@category-change="refreshCapitalFlow"
|
||||
/>
|
||||
<TurnoverTrendPanel
|
||||
:response="turnoverTrend.response"
|
||||
:loading="turnoverTrend.status === 'loading'"
|
||||
/>
|
||||
<ShortTermEmotion
|
||||
:response="shortTermEmotion.response"
|
||||
:loading="shortTermEmotion.status === 'loading'"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { CapitalFlowResponse } from '@/types/market-overview';
|
||||
|
||||
type Category = 'industry' | 'concept' | 'board';
|
||||
type Sort = 'inflow' | 'outflow';
|
||||
|
||||
const props = defineProps<{
|
||||
response: CapitalFlowResponse | null;
|
||||
loading: boolean;
|
||||
category: Category;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'category-change': [category: Category] }>();
|
||||
const sort = ref<Sort>('inflow');
|
||||
const categoryLabels: Record<Category, string> = {
|
||||
industry: '行业',
|
||||
concept: '题材',
|
||||
board: '板块',
|
||||
};
|
||||
|
||||
const items = computed(() => {
|
||||
const source = props.response?.data?.items ?? [];
|
||||
return [...source].sort((left, right) => {
|
||||
const leftValue = left.netInflow ?? Number.NEGATIVE_INFINITY;
|
||||
const rightValue = right.netInflow ?? Number.NEGATIVE_INFINITY;
|
||||
return sort.value === 'inflow' ? rightValue - leftValue : leftValue - rightValue;
|
||||
});
|
||||
});
|
||||
|
||||
function formatFlow(value: number | null | undefined): string {
|
||||
return value == null ? '未提供' : `${value > 0 ? '+' : ''}${value.toFixed(2)} 亿`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel structure-panel" aria-labelledby="capital-flow-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">资金方向</span>
|
||||
<h2 id="capital-flow-title">{{ categoryLabels[category] }}资金流向</h2>
|
||||
</div>
|
||||
<span class="panel-meta">净额口径</span>
|
||||
</div>
|
||||
<div class="structure-tabs" role="tablist" aria-label="资金分类">
|
||||
<button
|
||||
v-for="(label, value) in categoryLabels"
|
||||
:key="value"
|
||||
type="button"
|
||||
:data-category="value"
|
||||
:class="{ active: category === value }"
|
||||
@click="emit('category-change', value as Category)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="structure-sort" role="group" aria-label="资金排序">
|
||||
<button type="button" :class="{ active: sort === 'inflow' }" @click="sort = 'inflow'">
|
||||
净流入
|
||||
</button>
|
||||
<button type="button" :class="{ active: sort === 'outflow' }" @click="sort = 'outflow'">
|
||||
净流出
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取资金流向…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<template v-else-if="items.length">
|
||||
<div v-if="response?.status !== 'fresh'" class="module-warning">
|
||||
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||
<span>{{ response?.warnings[0] }}</span>
|
||||
</div>
|
||||
<div class="structure-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.code ?? item.name"
|
||||
class="structure-row structure-row-static"
|
||||
>
|
||||
<span class="structure-name">{{ item.name }}</span>
|
||||
<strong
|
||||
class="structure-value"
|
||||
:data-direction="(item.netInflow ?? 0) >= 0 ? 'up' : 'down'"
|
||||
>
|
||||
{{ formatFlow(item.netInflow) }}
|
||||
</strong>
|
||||
<small>{{
|
||||
item.flowDefinition === 'unavailable' ? '口径未提供' : item.flowDefinition
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="module-placeholder">暂无资金流向数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import CapitalFlowPanel from './CapitalFlowPanel.vue';
|
||||
import SectorRankingPanel from './SectorRankingPanel.vue';
|
||||
import TurnoverTrendPanel from './TurnoverTrendPanel.vue';
|
||||
|
||||
const meta = {
|
||||
snapshotId: '20260825-103000',
|
||||
asOf: '2026-08-25T10:30:00+08:00',
|
||||
source: 'aktools' as const,
|
||||
status: 'fresh' as const,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe('市场结构首屏面板', () => {
|
||||
it('展示行业排行并支持类别切换和详情事件', async () => {
|
||||
const wrapper = mount(SectorRankingPanel, {
|
||||
props: {
|
||||
loading: false,
|
||||
category: 'industry' as const,
|
||||
response: {
|
||||
...meta,
|
||||
data: {
|
||||
category: 'industry' as const,
|
||||
sort: 'rise' as const,
|
||||
items: [
|
||||
{
|
||||
category: 'industry' as const,
|
||||
code: 'BK1',
|
||||
name: '半导体',
|
||||
changePercent: 3.2,
|
||||
turnoverAmount: 12.3,
|
||||
advancingCount: 20,
|
||||
decliningCount: 2,
|
||||
leader: '测试股份',
|
||||
tradeDate: '20260825',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('半导体');
|
||||
expect(wrapper.text()).toContain('+3.20%');
|
||||
await wrapper.get('[data-category="concept"]').trigger('click');
|
||||
expect(wrapper.emitted('category-change')).toEqual([['concept']]);
|
||||
await wrapper.get('[data-code="BK1"]').trigger('click');
|
||||
expect(wrapper.emitted('select')).toEqual([['BK1']]);
|
||||
});
|
||||
|
||||
it('资金流向缺失时显示 partial 警告且不显示零净流入', () => {
|
||||
const wrapper = mount(CapitalFlowPanel, {
|
||||
props: {
|
||||
loading: false,
|
||||
category: 'industry' as const,
|
||||
response: {
|
||||
...meta,
|
||||
status: 'partial' as const,
|
||||
warnings: ['部分资金流向字段不可用'],
|
||||
data: {
|
||||
category: 'industry' as const,
|
||||
sort: 'inflow' as const,
|
||||
items: [
|
||||
{
|
||||
category: 'industry' as const,
|
||||
code: 'BK1',
|
||||
name: '未知',
|
||||
netInflow: null,
|
||||
netOutflow: null,
|
||||
turnoverAmount: 1,
|
||||
tradeDate: null,
|
||||
flowDefinition: 'unavailable',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('部分数据');
|
||||
expect(wrapper.text()).toContain('部分资金流向字段不可用');
|
||||
expect(wrapper.text()).toContain('未提供');
|
||||
expect(wrapper.text()).not.toContain('0.00');
|
||||
});
|
||||
|
||||
it('成交额趋势绘制非空 SVG 并展示单位', () => {
|
||||
const wrapper = mount(TurnoverTrendPanel, {
|
||||
props: {
|
||||
loading: false,
|
||||
response: {
|
||||
...meta,
|
||||
source: 'tdx' as const,
|
||||
data: {
|
||||
granularity: '5' as const,
|
||||
unit: '亿元' as const,
|
||||
marketCount: 3,
|
||||
invalidPointCount: 0,
|
||||
points: [
|
||||
{ time: '2026-08-25 09:30:00', amount: 1, cumulativeAmount: 1 },
|
||||
{ time: '2026-08-25 09:35:00', amount: 2, cumulativeAmount: 3 },
|
||||
{ time: '2026-08-25 09:40:00', amount: 1.5, cumulativeAmount: 4.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('亿元');
|
||||
expect(wrapper.find('svg').exists()).toBe(true);
|
||||
expect(wrapper.find('polyline').attributes('points')).not.toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import type { SectorRankingsResponse } from '@/types/market-overview';
|
||||
|
||||
type Category = 'industry' | 'concept' | 'board';
|
||||
type Sort = 'rise' | 'fall' | 'turnover';
|
||||
|
||||
const props = defineProps<{
|
||||
response: SectorRankingsResponse | null;
|
||||
loading: boolean;
|
||||
category: Category;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [code: string];
|
||||
'category-change': [category: Category];
|
||||
}>();
|
||||
|
||||
const sort = ref<Sort>('rise');
|
||||
const categoryLabels: Record<Category, string> = {
|
||||
industry: '行业',
|
||||
concept: '题材',
|
||||
board: '板块',
|
||||
};
|
||||
const sortLabels: Record<Sort, string> = { rise: '涨幅', fall: '跌幅', turnover: '成交额' };
|
||||
|
||||
const items = computed(() => {
|
||||
const source = props.response?.data?.items ?? [];
|
||||
return [...source].sort((left, right) => {
|
||||
const leftValue = sort.value === 'turnover' ? left.turnoverAmount : left.changePercent;
|
||||
const rightValue = sort.value === 'turnover' ? right.turnoverAmount : right.changePercent;
|
||||
const leftNumber = leftValue ?? Number.NEGATIVE_INFINITY;
|
||||
const rightNumber = rightValue ?? Number.NEGATIVE_INFINITY;
|
||||
const difference = sort.value === 'fall' ? leftNumber - rightNumber : rightNumber - leftNumber;
|
||||
return difference || left.name.localeCompare(right.name, 'zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
return value == null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatAmount(value: number | null | undefined): string {
|
||||
return value == null ? '—' : `${value.toFixed(2)} 亿`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel structure-panel" aria-labelledby="sector-ranking-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">结构扫描</span>
|
||||
<h2 id="sector-ranking-title">{{ categoryLabels[category] }}强弱排行</h2>
|
||||
</div>
|
||||
<span class="panel-meta">Top {{ response?.data?.items.length ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="structure-tabs" role="tablist" aria-label="板块分类">
|
||||
<button
|
||||
v-for="(label, value) in categoryLabels"
|
||||
:key="value"
|
||||
type="button"
|
||||
:data-category="value"
|
||||
:class="{ active: category === value }"
|
||||
@click="emit('category-change', value as Category)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="structure-sort" role="group" aria-label="排行口径">
|
||||
<button
|
||||
v-for="(label, value) in sortLabels"
|
||||
:key="value"
|
||||
type="button"
|
||||
:class="{ active: sort === value }"
|
||||
@click="sort = value as Sort"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取{{ categoryLabels[category] }}排行…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<template v-else-if="items.length">
|
||||
<div v-if="response?.status !== 'fresh'" class="module-warning">
|
||||
<strong>{{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||
<span>{{ response?.warnings[0] }}</span>
|
||||
</div>
|
||||
<div class="structure-list">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.code"
|
||||
type="button"
|
||||
class="structure-row"
|
||||
:data-code="item.code"
|
||||
:title="`进入${item.name}详细分析`"
|
||||
@click="emit('select', item.code)"
|
||||
>
|
||||
<span class="structure-name">{{ item.name }}</span>
|
||||
<span class="structure-value" data-direction="up">{{
|
||||
formatPercent(item.changePercent)
|
||||
}}</span>
|
||||
<small>{{
|
||||
sort === 'turnover'
|
||||
? formatAmount(item.turnoverAmount)
|
||||
: formatAmount(item.turnoverAmount)
|
||||
}}</small>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="module-placeholder">暂无{{ categoryLabels[category] }}排行数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { TurnoverTrendResponse } from '@/types/market-overview';
|
||||
|
||||
const props = defineProps<{ response: TurnoverTrendResponse | null; loading: boolean }>();
|
||||
|
||||
const chartPoints = computed(() => {
|
||||
const points = props.response?.data?.points ?? [];
|
||||
if (!points.length) return '';
|
||||
const max = Math.max(...points.map((point) => point.cumulativeAmount), 1);
|
||||
const width = 620;
|
||||
const height = 150;
|
||||
return points
|
||||
.map((point, index) => {
|
||||
const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
|
||||
const y = height - (point.cumulativeAmount / max) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const latest = computed(() => props.response?.data?.points.at(-1)?.cumulativeAmount ?? null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="overview-panel structure-panel turnover-panel"
|
||||
aria-labelledby="turnover-trend-title"
|
||||
>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">量能观察</span>
|
||||
<h2 id="turnover-trend-title">全天成交额趋势</h2>
|
||||
</div>
|
||||
<span class="panel-meta">{{ response?.data?.granularity ?? '—' }} 分钟</span>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取成交额趋势…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<template v-else-if="response?.data?.points.length">
|
||||
<div v-if="response.status !== 'fresh'" class="module-warning">
|
||||
<strong>{{ response.status === 'partial' ? '部分数据' : '数据延迟' }}</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<div class="turnover-summary">
|
||||
<strong>{{ latest?.toFixed(2) ?? '—' }}</strong>
|
||||
<span>{{ response.data.unit }}累计成交额</span>
|
||||
</div>
|
||||
<svg class="turnover-chart" viewBox="0 0 640 180" role="img" aria-label="全天累计成交额趋势">
|
||||
<polyline
|
||||
:points="chartPoints"
|
||||
fill="none"
|
||||
stroke="#167d82"
|
||||
stroke-width="3"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<div v-else class="module-placeholder">暂无成交额趋势数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -50,4 +50,28 @@ describe('市场概览状态', () => {
|
||||
expect(second.analysisHorizon.value).toBe('短线');
|
||||
expect(second.analysisEngine.value).toBe('混合');
|
||||
});
|
||||
|
||||
it('切换行业结构分类时重新请求对应数据', async () => {
|
||||
const api = {
|
||||
getSectorRankings: vi.fn().mockResolvedValue(response),
|
||||
getCapitalFlow: vi.fn().mockResolvedValue(response),
|
||||
};
|
||||
const state = useMarketOverview(api as never);
|
||||
|
||||
await state.refreshSectorRankings('concept');
|
||||
await state.refreshCapitalFlow('board');
|
||||
|
||||
expect(api.getSectorRankings).toHaveBeenCalledWith({
|
||||
category: 'concept',
|
||||
sort: 'rise',
|
||||
limit: 10,
|
||||
});
|
||||
expect(api.getCapitalFlow).toHaveBeenCalledWith({
|
||||
category: 'board',
|
||||
sort: 'inflow',
|
||||
limit: 10,
|
||||
});
|
||||
expect(state.sectorCategory.value).toBe('concept');
|
||||
expect(state.capitalFlowCategory.value).toBe('board');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ type MarketResponse =
|
||||
| CapitalFlowResponse
|
||||
| TurnoverTrendResponse;
|
||||
|
||||
export type MarketStructureCategory = 'industry' | 'concept' | 'board';
|
||||
|
||||
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
||||
status: OverviewModuleStatus;
|
||||
response: TResponse | null;
|
||||
@@ -63,6 +65,8 @@ export function useMarketOverview(
|
||||
const sectorRankings = ref<OverviewModuleState<SectorRankingsResponse>>(createState());
|
||||
const capitalFlow = ref<OverviewModuleState<CapitalFlowResponse>>(createState());
|
||||
const turnoverTrend = ref<OverviewModuleState<TurnoverTrendResponse>>(createState());
|
||||
const sectorCategory = ref<MarketStructureCategory>('industry');
|
||||
const capitalFlowCategory = ref<MarketStructureCategory>('industry');
|
||||
const shortTermEmotion = ref<OverviewModuleState<ShortTermEmotionResponse>>(createState());
|
||||
const shortTermConclusion = ref<OverviewModuleState<ShortTermConclusionResponse>>(createState());
|
||||
const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合'));
|
||||
@@ -108,6 +112,22 @@ export function useMarketOverview(
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSectorRankings(category = sectorCategory.value): Promise<void> {
|
||||
sectorCategory.value = category;
|
||||
sectorRankings.value.status = 'loading';
|
||||
await loadModule(sectorRankings, () =>
|
||||
api.getSectorRankings({ category, sort: 'rise', limit: 10 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshCapitalFlow(category = capitalFlowCategory.value): Promise<void> {
|
||||
capitalFlowCategory.value = category;
|
||||
capitalFlow.value.status = 'loading';
|
||||
await loadModule(capitalFlow, () =>
|
||||
api.getCapitalFlow({ category, sort: 'inflow', limit: 10 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading();
|
||||
await Promise.all([
|
||||
@@ -116,12 +136,8 @@ export function useMarketOverview(
|
||||
loadModule(breadth, api.getBreadth),
|
||||
loadModule(sectors, api.getSectors),
|
||||
loadModule(funds, api.getFunds),
|
||||
loadModule(sectorRankings, () =>
|
||||
api.getSectorRankings({ category: 'industry', sort: 'rise', limit: 10 }),
|
||||
),
|
||||
loadModule(capitalFlow, () =>
|
||||
api.getCapitalFlow({ category: 'industry', sort: 'inflow', limit: 10 }),
|
||||
),
|
||||
refreshSectorRankings(),
|
||||
refreshCapitalFlow(),
|
||||
loadModule(turnoverTrend, () => api.getTurnoverTrend({ interval: '5' })),
|
||||
loadModule(shortTermEmotion, api.getShortTermEmotion),
|
||||
loadModule(shortTermConclusion, api.getShortTermConclusion),
|
||||
@@ -149,10 +165,14 @@ export function useMarketOverview(
|
||||
turnoverTrend,
|
||||
shortTermEmotion,
|
||||
shortTermConclusion,
|
||||
sectorCategory,
|
||||
capitalFlowCategory,
|
||||
isLoading,
|
||||
analysisHorizon,
|
||||
analysisEngine,
|
||||
refresh,
|
||||
refreshSectorRankings,
|
||||
refreshCapitalFlow,
|
||||
setAnalysisHorizon,
|
||||
setAnalysisEngine,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user