diff --git a/AGENTS.md b/AGENTS.md index 71c3028..c3cb4d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ # 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. diff --git a/server/apps/api/src/market-overview/structure/market-structure.controller.ts b/server/apps/api/src/market-overview/structure/market-structure.controller.ts index 42ed125..5ca95da 100644 --- a/server/apps/api/src/market-overview/structure/market-structure.controller.ts +++ b/server/apps/api/src/market-overview/structure/market-structure.controller.ts @@ -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'] }) diff --git a/web/src/styles/index.scss b/web/src/styles/index.scss index 1903ef5..b8e0840 100644 --- a/web/src/styles/index.scss +++ b/web/src/styles/index.scss @@ -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; diff --git a/web/src/views/market-overview/MarketOverviewPage.vue b/web/src/views/market-overview/MarketOverviewPage.vue index 322bda2..404f35c 100644 --- a/web/src/views/market-overview/MarketOverviewPage.vue +++ b/web/src/views/market-overview/MarketOverviewPage.vue @@ -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" /> + + +
+ + + +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('inflow'); +const categoryLabels: Record = { + 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)} 亿`; +} + + + diff --git a/web/src/views/market-overview/components/MarketStructurePanels.test.ts b/web/src/views/market-overview/components/MarketStructurePanels.test.ts new file mode 100644 index 0000000..c58fe70 --- /dev/null +++ b/web/src/views/market-overview/components/MarketStructurePanels.test.ts @@ -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(''); + }); +}); diff --git a/web/src/views/market-overview/components/SectorRankingPanel.vue b/web/src/views/market-overview/components/SectorRankingPanel.vue new file mode 100644 index 0000000..7628ebe --- /dev/null +++ b/web/src/views/market-overview/components/SectorRankingPanel.vue @@ -0,0 +1,115 @@ + + + diff --git a/web/src/views/market-overview/components/TurnoverTrendPanel.vue b/web/src/views/market-overview/components/TurnoverTrendPanel.vue new file mode 100644 index 0000000..fb94db7 --- /dev/null +++ b/web/src/views/market-overview/components/TurnoverTrendPanel.vue @@ -0,0 +1,64 @@ + + + diff --git a/web/src/views/market-overview/useMarketOverview.test.ts b/web/src/views/market-overview/useMarketOverview.test.ts index ad43a71..239d9bc 100644 --- a/web/src/views/market-overview/useMarketOverview.test.ts +++ b/web/src/views/market-overview/useMarketOverview.test.ts @@ -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'); + }); }); diff --git a/web/src/views/market-overview/useMarketOverview.ts b/web/src/views/market-overview/useMarketOverview.ts index bd04ad5..de2143d 100644 --- a/web/src/views/market-overview/useMarketOverview.ts +++ b/web/src/views/market-overview/useMarketOverview.ts @@ -27,6 +27,8 @@ type MarketResponse = | CapitalFlowResponse | TurnoverTrendResponse; +export type MarketStructureCategory = 'industry' | 'concept' | 'board'; + export interface OverviewModuleState { status: OverviewModuleStatus; response: TResponse | null; @@ -63,6 +65,8 @@ export function useMarketOverview( const sectorRankings = ref>(createState()); const capitalFlow = ref>(createState()); const turnoverTrend = ref>(createState()); + const sectorCategory = ref('industry'); + const capitalFlowCategory = ref('industry'); const shortTermEmotion = ref>(createState()); const shortTermConclusion = ref>(createState()); const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合')); @@ -108,6 +112,22 @@ export function useMarketOverview( } } + async function refreshSectorRankings(category = sectorCategory.value): Promise { + sectorCategory.value = category; + sectorRankings.value.status = 'loading'; + await loadModule(sectorRankings, () => + api.getSectorRankings({ category, sort: 'rise', limit: 10 }), + ); + } + + async function refreshCapitalFlow(category = capitalFlowCategory.value): Promise { + capitalFlowCategory.value = category; + capitalFlow.value.status = 'loading'; + await loadModule(capitalFlow, () => + api.getCapitalFlow({ category, sort: 'inflow', limit: 10 }), + ); + } + async function refresh(): Promise { 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, };