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)} 亿`;
+}
+
+
+
+
+
+
+ 资金方向
+
{{ categoryLabels[category] }}资金流向
+
+
净额口径
+
+
+
+
+
+
+
+
+ 正在读取资金流向…
+
+ 暂不可用
+ {{ response.warnings[0] }}
+
+
+
+ {{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}
+ {{ response?.warnings[0] }}
+
+
+
+ {{ item.name }}
+
+ {{ formatFlow(item.netInflow) }}
+
+ {{
+ item.flowDefinition === 'unavailable' ? '口径未提供' : item.flowDefinition
+ }}
+
+
+
+ 暂无资金流向数据
+
+
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 @@
+
+
+
+
+
+
+ 结构扫描
+
{{ categoryLabels[category] }}强弱排行
+
+
Top {{ response?.data?.items.length ?? 0 }}
+
+
+
+
+
+
+
+ 正在读取{{ categoryLabels[category] }}排行…
+
+ 暂不可用
+ {{ response.warnings[0] }}
+
+
+
+ {{ response?.status === 'partial' ? '部分数据' : '数据延迟' }}
+ {{ response?.warnings[0] }}
+
+
+
+
+
+ 暂无{{ categoryLabels[category] }}排行数据
+
+
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 @@
+
+
+
+
+
+
+ 量能观察
+
全天成交额趋势
+
+
{{ response?.data?.granularity ?? '—' }} 分钟
+
+ 正在读取成交额趋势…
+
+ 暂不可用
+ {{ response.warnings[0] }}
+
+
+
+ {{ response.status === 'partial' ? '部分数据' : '数据延迟' }}
+ {{ response.warnings[0] }}
+
+
+ {{ latest?.toFixed(2) ?? '—' }}
+ {{ response.data.unit }}累计成交额
+
+
+
+ 暂无成交额趋势数据
+
+
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,
};