feat(FEAT-MO-001): 同步市场概览前端契约

This commit is contained in:
2026-08-18 13:43:48 +08:00
parent 15a25002ce
commit 27bc3c198f
7 changed files with 476 additions and 40 deletions
+5 -40
View File
@@ -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"
}
}
+261
View File
@@ -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<string, never>;
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'];
};
};
};
};
}
+29
View File
@@ -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<RequestClient>[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',
]);
});
});
+22
View File
@@ -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<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
getIndices: () => requestClient<IndicesResponse>({ method: 'get', url: '/api/v1/market-overview/indices' }),
getBreadth: () => requestClient<BreadthResponse>({ method: 'get', url: '/api/v1/market-overview/breadth' }),
getSectors: () => requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
getFunds: () => requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
};
}
const marketOverviewApi = createMarketOverviewApi();
export default marketOverviewApi;
+14
View File
@@ -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<SummaryResponse['data']>;
export type IndicesData = NonNullable<IndicesResponse['data']>;
export type BreadthData = NonNullable<BreadthResponse['data']>;
export type SectorsData = NonNullable<SectorsResponse['data']>;
export type FundsData = NonNullable<FundsResponse['data']>;
export type OverviewModuleStatus = 'idle' | 'loading' | 'ready' | 'error';
@@ -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('混合');
});
});
@@ -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<TResponse extends MarketResponse> {
status: OverviewModuleStatus;
response: TResponse | null;
}
function createState<TResponse extends MarketResponse>(): OverviewModuleState<TResponse> {
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<typeof createMarketOverviewApi> = marketOverviewApi) {
const summary = ref<OverviewModuleState<SummaryResponse>>(createState());
const indices = ref<OverviewModuleState<IndicesResponse>>(createState());
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
const sectors = ref<OverviewModuleState<SectorsResponse>>(createState());
const funds = ref<OverviewModuleState<FundsResponse>>(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<TResponse extends MarketResponse>(
state: { value: OverviewModuleState<TResponse> },
loader: () => Promise<TResponse>,
): Promise<void> {
try {
state.value.response = await loader();
state.value.status = 'ready';
} catch {
state.value.response = null;
state.value.status = 'error';
}
}
async function refresh(): Promise<void> {
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,
};
}