feat(FEAT-MO-001): 同步市场概览前端契约
This commit is contained in:
@@ -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'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user