From 27bc3c198f6322d5af64ecc128937320e8548fc7 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Tue, 18 Aug 2026 13:43:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(FEAT-MO-001):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E6=A6=82=E8=A7=88=E5=89=8D=E7=AB=AF=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/openapi/openapi.json | 45 +-- web/src/api/generated/schema.ts | 261 ++++++++++++++++++ web/src/api/market-overview/index.test.ts | 29 ++ web/src/api/market-overview/index.ts | 22 ++ web/src/types/market-overview.ts | 14 + .../market-overview/useMarketOverview.test.ts | 36 +++ .../market-overview/useMarketOverview.ts | 109 ++++++++ 7 files changed, 476 insertions(+), 40 deletions(-) create mode 100644 web/src/api/market-overview/index.test.ts create mode 100644 web/src/api/market-overview/index.ts create mode 100644 web/src/types/market-overview.ts create mode 100644 web/src/views/market-overview/useMarketOverview.test.ts create mode 100644 web/src/views/market-overview/useMarketOverview.ts diff --git a/server/openapi/openapi.json b/server/openapi/openapi.json index 2500513..6b2dd02 100644 --- a/server/openapi/openapi.json +++ b/server/openapi/openapi.json @@ -26,14 +26,7 @@ }, "warnings": { "items": { "type": "string" }, "type": "array" } }, - "required": [ - "snapshotId", - "asOf", - "source", - "status", - "warnings", - "data" - ], + "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" }, "DependencyHealthDto": { @@ -70,14 +63,7 @@ }, "warnings": { "items": { "type": "string" }, "type": "array" } }, - "required": [ - "snapshotId", - "asOf", - "source", - "status", - "warnings", - "data" - ], + "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" }, "HealthDependenciesDto": { @@ -139,14 +125,7 @@ }, "warnings": { "items": { "type": "string" }, "type": "array" } }, - "required": [ - "snapshotId", - "asOf", - "source", - "status", - "warnings", - "data" - ], + "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" }, "SectorDataDto": { @@ -172,14 +151,7 @@ }, "warnings": { "items": { "type": "string" }, "type": "array" } }, - "required": [ - "snapshotId", - "asOf", - "source", - "status", - "warnings", - "data" - ], + "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" }, "SummaryDataDto": { @@ -217,14 +189,7 @@ }, "warnings": { "items": { "type": "string" }, "type": "array" } }, - "required": [ - "snapshotId", - "asOf", - "source", - "status", - "warnings", - "data" - ], + "required": ["snapshotId", "asOf", "source", "status", "warnings", "data"], "type": "object" } } diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index e07eb90..67df158 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -20,15 +20,125 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/market-overview/breadth': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketOverviewController_getBreadth']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/market-overview/funds': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketOverviewController_getFunds']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/market-overview/indices': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketOverviewController_getIndices']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/market-overview/sectors': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketOverviewController_getSectors']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/market-overview/summary': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['MarketOverviewController_getSummary']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + BreadthDataDto: { + advancing: number; + declining: number; + unchanged: number; + }; + BreadthResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['BreadthDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; DependencyHealthDto: { detail: string; /** @enum {string} */ status: 'healthy' | 'unavailable' | 'disabled'; }; + FundDataDto: { + items: string[]; + }; + FundsResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['FundDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; HealthDependenciesDto: { mysql: components['schemas']['DependencyHealthDto']; redis: components['schemas']['DependencyHealthDto']; @@ -43,6 +153,62 @@ export interface components { /** @enum {string} */ status: 'healthy' | 'unavailable'; }; + IndexQuoteDto: { + changePercent?: number | null; + code: string; + /** @enum {string} */ + direction: 'up' | 'down' | 'flat'; + name: string; + price: number; + tradeDate: string; + }; + IndicesDataDto: { + items: components['schemas']['IndexQuoteDto'][]; + }; + IndicesResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['IndicesDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; + SectorDataDto: { + items: string[]; + }; + SectorsResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['SectorDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; + SummaryDataDto: { + drivers: string[]; + marketStatus: string; + risks: string[]; + sentimentStatus: string; + verdict: string; + volumeStatus: string; + }; + SummaryResponseDto: { + /** Format: date-time */ + asOf: string; + data: components['schemas']['SummaryDataDto'] | null; + snapshotId: string; + /** @enum {string} */ + source: 'tdx'; + /** @enum {string} */ + status: 'fresh' | 'delayed' | 'partial' | 'unavailable'; + warnings: string[]; + }; }; responses: never; parameters: never; @@ -79,4 +245,99 @@ export interface operations { }; }; }; + MarketOverviewController_getBreadth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['BreadthResponseDto']; + }; + }; + }; + }; + MarketOverviewController_getFunds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['FundsResponseDto']; + }; + }; + }; + }; + MarketOverviewController_getIndices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['IndicesResponseDto']; + }; + }; + }; + }; + MarketOverviewController_getSectors: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SectorsResponseDto']; + }; + }; + }; + }; + MarketOverviewController_getSummary: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SummaryResponseDto']; + }; + }; + }; + }; } diff --git a/web/src/api/market-overview/index.test.ts b/web/src/api/market-overview/index.test.ts new file mode 100644 index 0000000..c92711a --- /dev/null +++ b/web/src/api/market-overview/index.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { RequestClient } from '@/utils/request'; +import { createMarketOverviewApi } from './index'; + +describe('市场概览业务 API', () => { + it('使用独立版本化路径请求五个模块', async () => { + const requestMock = vi.fn((config: Parameters[0]) => { + void config; + return Promise.resolve({}); + }); + const requestClient = requestMock as unknown as RequestClient; + const api = createMarketOverviewApi(requestClient); + + await api.getSummary(); + await api.getIndices(); + await api.getBreadth(); + await api.getSectors(); + await api.getFunds(); + + expect(requestMock.mock.calls.map(([config]) => config.url)).toEqual([ + '/api/v1/market-overview/summary', + '/api/v1/market-overview/indices', + '/api/v1/market-overview/breadth', + '/api/v1/market-overview/sectors', + '/api/v1/market-overview/funds', + ]); + }); +}); diff --git a/web/src/api/market-overview/index.ts b/web/src/api/market-overview/index.ts new file mode 100644 index 0000000..62ca27c --- /dev/null +++ b/web/src/api/market-overview/index.ts @@ -0,0 +1,22 @@ +import request, { type RequestClient } from '@/utils/request'; +import type { + BreadthResponse, + FundsResponse, + IndicesResponse, + SectorsResponse, + SummaryResponse, +} from '@/types/market-overview'; + +export function createMarketOverviewApi(requestClient: RequestClient = request) { + return { + getSummary: () => requestClient({ method: 'get', url: '/api/v1/market-overview/summary' }), + getIndices: () => requestClient({ method: 'get', url: '/api/v1/market-overview/indices' }), + getBreadth: () => requestClient({ method: 'get', url: '/api/v1/market-overview/breadth' }), + getSectors: () => requestClient({ method: 'get', url: '/api/v1/market-overview/sectors' }), + getFunds: () => requestClient({ method: 'get', url: '/api/v1/market-overview/funds' }), + }; +} + +const marketOverviewApi = createMarketOverviewApi(); + +export default marketOverviewApi; diff --git a/web/src/types/market-overview.ts b/web/src/types/market-overview.ts new file mode 100644 index 0000000..9990833 --- /dev/null +++ b/web/src/types/market-overview.ts @@ -0,0 +1,14 @@ +import type { components } from '@/api/generated/schema'; + +export type SummaryResponse = components['schemas']['SummaryResponseDto']; +export type IndicesResponse = components['schemas']['IndicesResponseDto']; +export type BreadthResponse = components['schemas']['BreadthResponseDto']; +export type SectorsResponse = components['schemas']['SectorsResponseDto']; +export type FundsResponse = components['schemas']['FundsResponseDto']; +export type SummaryData = NonNullable; +export type IndicesData = NonNullable; +export type BreadthData = NonNullable; +export type SectorsData = NonNullable; +export type FundsData = NonNullable; + +export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error'; diff --git a/web/src/views/market-overview/useMarketOverview.test.ts b/web/src/views/market-overview/useMarketOverview.test.ts new file mode 100644 index 0000000..6255aaf --- /dev/null +++ b/web/src/views/market-overview/useMarketOverview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { useMarketOverview } from './useMarketOverview'; + +const response = { source: 'tdx', status: 'fresh', data: null, snapshotId: '20260818-101500', asOf: '2026-08-18T10:15:00+08:00', warnings: [] } as const; + +describe('市场概览状态', () => { + it('并行加载五个模块,一个模块失败不影响其他模块', async () => { + const api = { + getSummary: vi.fn().mockResolvedValue(response), + getIndices: vi.fn().mockRejectedValue(new Error('failed')), + getBreadth: vi.fn().mockResolvedValue(response), + getSectors: vi.fn().mockResolvedValue(response), + getFunds: vi.fn().mockResolvedValue(response), + }; + const state = useMarketOverview(api); + + await state.refresh(); + + expect(state.summary.value.status).toBe('ready'); + expect(state.indices.value.status).toBe('error'); + expect(state.breadth.value.status).toBe('ready'); + expect(state.isLoading.value).toBe(false); + }); + + it('分析偏好写入本地存储并可恢复', () => { + const first = useMarketOverview({} as never); + first.setAnalysisHorizon('短线'); + first.setAnalysisEngine('混合'); + + const second = useMarketOverview({} as never); + + expect(second.analysisHorizon.value).toBe('短线'); + expect(second.analysisEngine.value).toBe('混合'); + }); +}); diff --git a/web/src/views/market-overview/useMarketOverview.ts b/web/src/views/market-overview/useMarketOverview.ts new file mode 100644 index 0000000..2677837 --- /dev/null +++ b/web/src/views/market-overview/useMarketOverview.ts @@ -0,0 +1,109 @@ +import { computed, ref } from 'vue'; + +import marketOverviewApi, { type createMarketOverviewApi } from '@/api/market-overview'; +import type { + BreadthResponse, + FundsResponse, + IndicesResponse, + OverviewModuleStatus, + SectorsResponse, + SummaryResponse, +} from '@/types/market-overview'; + +type MarketResponse = SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse; + +export interface OverviewModuleState { + status: OverviewModuleStatus; + response: TResponse | null; +} + +function createState(): OverviewModuleState { + return { status: 'idle', response: null }; +} + +function readPreference(key: string, fallback: string): string { + try { + return window.localStorage.getItem(key) || fallback; + } catch { + return fallback; + } +} + +function writePreference(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + // 隐私模式或禁用存储时只保留本次页面状态,不让偏好存储阻断行情展示。 + } +} + +export function useMarketOverview(api: ReturnType = marketOverviewApi) { + const summary = ref>(createState()); + const indices = ref>(createState()); + const breadth = ref>(createState()); + const sectors = ref>(createState()); + const funds = ref>(createState()); + const analysisHorizon = ref(readPreference('a-share-analysis:overview-horizon', '综合')); + const analysisEngine = ref(readPreference('a-share-analysis:overview-engine', '规则')); + const isLoading = computed(() => + [summary.value, indices.value, breadth.value, sectors.value, funds.value].some( + (module) => module.status === 'loading', + ), + ); + + function setLoading() { + summary.value.status = 'loading'; + indices.value.status = 'loading'; + breadth.value.status = 'loading'; + sectors.value.status = 'loading'; + funds.value.status = 'loading'; + } + + async function loadModule( + state: { value: OverviewModuleState }, + loader: () => Promise, + ): Promise { + try { + state.value.response = await loader(); + state.value.status = 'ready'; + } catch { + state.value.response = null; + state.value.status = 'error'; + } + } + + async function refresh(): Promise { + setLoading(); + await Promise.all([ + loadModule(summary, api.getSummary), + loadModule(indices, api.getIndices), + loadModule(breadth, api.getBreadth), + loadModule(sectors, api.getSectors), + loadModule(funds, api.getFunds), + ]); + } + + function setAnalysisHorizon(value: string) { + analysisHorizon.value = value; + writePreference('a-share-analysis:overview-horizon', value); + } + + function setAnalysisEngine(value: string) { + analysisEngine.value = value; + writePreference('a-share-analysis:overview-engine', value); + } + + return { + summary, + indices, + breadth, + sectors, + funds, + isLoading, + analysisHorizon, + analysisEngine, + refresh, + setAnalysisHorizon, + setAnalysisEngine, + }; +}