feat(FEAT-MO-004): 实现指数板块详情分析页
This commit is contained in:
@@ -68,6 +68,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/indices/{code}/detail': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations['MarketDetailController_getIndexDetail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/sectors': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -84,6 +100,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/sectors/{code}/detail': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations['MarketDetailController_getSectorDetail'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/market-overview/short-term/conclusion': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -157,6 +189,34 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
status: 'healthy' | 'unavailable' | 'disabled';
|
||||
};
|
||||
DetailAvailableViewsDto: {
|
||||
intraday: boolean;
|
||||
kline: boolean;
|
||||
};
|
||||
DetailInstrumentDto: {
|
||||
code: string;
|
||||
name: string;
|
||||
/** @enum {string} */
|
||||
type: 'index' | 'sector';
|
||||
};
|
||||
DetailQuoteDto: {
|
||||
changePercent?: number | null;
|
||||
/** @enum {string} */
|
||||
direction: 'up' | 'down' | 'flat';
|
||||
price: number;
|
||||
/** @example 20260818 */
|
||||
tradeDate: string;
|
||||
};
|
||||
DetailSeriesPointDto: {
|
||||
amount?: number | null;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
open: number;
|
||||
/** @example 20260818 */
|
||||
time: string;
|
||||
volume?: number | null;
|
||||
};
|
||||
FundDataDto: {
|
||||
changePercent: number;
|
||||
currentAmount: number;
|
||||
@@ -194,6 +254,23 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
status: 'healthy' | 'unavailable';
|
||||
};
|
||||
IndexDetailDataDto: {
|
||||
availableViews: components['schemas']['DetailAvailableViewsDto'];
|
||||
instrument: components['schemas']['DetailInstrumentDto'];
|
||||
quote: components['schemas']['DetailQuoteDto'];
|
||||
series: components['schemas']['DetailSeriesPointDto'][];
|
||||
};
|
||||
IndexDetailResponseDto: {
|
||||
/** Format: date-time */
|
||||
asOf: string;
|
||||
data: components['schemas']['IndexDetailDataDto'] | null;
|
||||
snapshotId: string;
|
||||
/** @enum {string} */
|
||||
source: 'tdx';
|
||||
/** @enum {string} */
|
||||
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
warnings: string[];
|
||||
};
|
||||
IndexQuoteDto: {
|
||||
changePercent?: number | null;
|
||||
code: string;
|
||||
@@ -220,6 +297,23 @@ export interface components {
|
||||
SectorDataDto: {
|
||||
items: components['schemas']['SectorQuoteDto'][];
|
||||
};
|
||||
SectorDetailDataDto: {
|
||||
availableViews: components['schemas']['DetailAvailableViewsDto'];
|
||||
instrument: components['schemas']['DetailInstrumentDto'];
|
||||
quote: components['schemas']['DetailQuoteDto'];
|
||||
series: components['schemas']['DetailSeriesPointDto'][];
|
||||
};
|
||||
SectorDetailResponseDto: {
|
||||
/** Format: date-time */
|
||||
asOf: string;
|
||||
data: components['schemas']['SectorDetailDataDto'] | null;
|
||||
snapshotId: string;
|
||||
/** @enum {string} */
|
||||
source: 'tdx';
|
||||
/** @enum {string} */
|
||||
status: 'fresh' | 'delayed' | 'partial' | 'unavailable';
|
||||
warnings: string[];
|
||||
};
|
||||
SectorQuoteDto: {
|
||||
changePercent: number;
|
||||
code: string;
|
||||
@@ -405,6 +499,31 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
MarketDetailController_getIndexDetail: {
|
||||
parameters: {
|
||||
query?: {
|
||||
count?: number;
|
||||
period?: '1d';
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description 带市场后缀的指数代码 */
|
||||
code: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['IndexDetailResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
MarketOverviewController_getSectors: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -424,6 +543,31 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
MarketDetailController_getSectorDetail: {
|
||||
parameters: {
|
||||
query?: {
|
||||
count?: number;
|
||||
period?: '1d';
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description 带市场后缀的一级行业代码 */
|
||||
code: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['SectorDetailResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ShortTermMarketController_getConclusion: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RequestClient } from '@/utils/request';
|
||||
import { createMarketDetailApi } from './detail';
|
||||
|
||||
describe('市场详情业务 API', () => {
|
||||
it('使用独立详情路径和固定查询参数请求指数与板块', async () => {
|
||||
const requestMock = vi.fn((config: Parameters<RequestClient>[0]) => {
|
||||
void config;
|
||||
return Promise.resolve({});
|
||||
});
|
||||
const api = createMarketDetailApi(requestMock as unknown as RequestClient);
|
||||
|
||||
await api.getIndexDetail('000001.SH', { period: '1d', count: 120 });
|
||||
await api.getSectorDetail('881001.SH', { period: '1d', count: 60 });
|
||||
|
||||
expect(requestMock.mock.calls.map(([config]) => config)).toEqual([
|
||||
{
|
||||
method: 'get',
|
||||
url: '/api/v1/market-overview/indices/000001.SH/detail',
|
||||
params: { period: '1d', count: 120 },
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
url: '/api/v1/market-overview/sectors/881001.SH/detail',
|
||||
params: { period: '1d', count: 60 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import request, { type RequestClient } from '@/utils/request';
|
||||
import type { components } from '@/api/generated/schema';
|
||||
|
||||
export type IndexDetailResponse = components['schemas']['IndexDetailResponseDto'];
|
||||
export type SectorDetailResponse = components['schemas']['SectorDetailResponseDto'];
|
||||
export type DetailQuery = { period: '1d'; count: number };
|
||||
|
||||
/** 详情请求只在 API 文件中拼接受控路径,页面和组件不感知 Axios 或服务端地址。 */
|
||||
export function createMarketDetailApi(requestClient: RequestClient = request) {
|
||||
return {
|
||||
getIndexDetail: (code: string, params: DetailQuery) =>
|
||||
requestClient<IndexDetailResponse>({
|
||||
method: 'get',
|
||||
url: `/api/v1/market-overview/indices/${encodeURIComponent(code)}/detail`,
|
||||
params,
|
||||
}),
|
||||
getSectorDetail: (code: string, params: DetailQuery) =>
|
||||
requestClient<SectorDetailResponse>({
|
||||
method: 'get',
|
||||
url: `/api/v1/market-overview/sectors/${encodeURIComponent(code)}/detail`,
|
||||
params,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const marketDetailApi = createMarketDetailApi();
|
||||
|
||||
export default marketDetailApi;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'vue-router';
|
||||
|
||||
import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue';
|
||||
import MarketDetailPage from '@/views/market-overview/detail/MarketDetailPage.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -13,6 +14,18 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'home',
|
||||
component: MarketOverviewPage,
|
||||
},
|
||||
{
|
||||
path: '/market-overview/indices/:code',
|
||||
name: 'market-index-detail',
|
||||
component: MarketDetailPage,
|
||||
meta: { detailType: 'index' },
|
||||
},
|
||||
{
|
||||
path: '/market-overview/sectors/:code',
|
||||
name: 'market-sector-detail',
|
||||
component: MarketDetailPage,
|
||||
meta: { detailType: 'sector' },
|
||||
},
|
||||
];
|
||||
|
||||
/** 创建可注入 history 的路由实例,使浏览器运行与单元测试共享同一路由表。 */
|
||||
|
||||
@@ -174,6 +174,226 @@ textarea {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.market-detail-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: #3f5667;
|
||||
background: #fff;
|
||||
border: 1px solid #ccd6de;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-toolbar-context,
|
||||
.detail-chart-note {
|
||||
color: #73808c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-hero-panel,
|
||||
.detail-quote-panel,
|
||||
.detail-chart-panel,
|
||||
.detail-facts-panel,
|
||||
.detail-state-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #dbe1e6;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 3px 14px rgb(26 38 49 / 5%);
|
||||
}
|
||||
|
||||
.detail-hero-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-hero-panel h1 {
|
||||
margin: 8px 0 4px;
|
||||
color: #17212b;
|
||||
font-size: clamp(24px, 4vw, 38px);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.detail-hero-panel p {
|
||||
margin: 0;
|
||||
color: #73808c;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
justify-items: end;
|
||||
gap: 7px;
|
||||
min-width: 220px;
|
||||
color: #64717d;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-meta strong,
|
||||
.detail-warning strong {
|
||||
color: #177b70;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-meta strong[data-status='delayed'],
|
||||
.detail-meta strong[data-status='partial'],
|
||||
.detail-meta strong[data-status='unavailable'],
|
||||
.detail-warning strong {
|
||||
color: #a45e1b;
|
||||
}
|
||||
|
||||
.detail-quote-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-quote-panel > div {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 108px;
|
||||
padding: 18px 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.detail-quote-panel span {
|
||||
color: #71808c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-quote-panel strong {
|
||||
color: #1c2d3a;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-quote-panel strong[data-direction='up'] {
|
||||
color: #c2483f;
|
||||
}
|
||||
|
||||
.detail-quote-panel strong[data-direction='down'] {
|
||||
color: #16806b;
|
||||
}
|
||||
|
||||
.detail-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-section-heading h2 {
|
||||
margin: 5px 0 0;
|
||||
color: #17212b;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.detail-chart-panel,
|
||||
.detail-facts-panel {
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.detail-chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 180px;
|
||||
margin-top: 16px;
|
||||
background: #fbfcfd;
|
||||
border: 1px solid #edf0f3;
|
||||
}
|
||||
|
||||
.detail-chart-axis {
|
||||
stroke: #d9e0e5;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.detail-chart-line {
|
||||
fill: none;
|
||||
stroke: #167d82;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.detail-chart-empty {
|
||||
display: grid;
|
||||
min-height: 180px;
|
||||
margin-top: 16px;
|
||||
place-items: center;
|
||||
color: #73808c;
|
||||
background: #fbfcfd;
|
||||
border: 1px dashed #cfd8df;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-chart-boundary {
|
||||
margin: 10px 0 0;
|
||||
color: #8b6d48;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-warning,
|
||||
.detail-state-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 12px 16px;
|
||||
color: #6e5b40;
|
||||
background: #fff8ed;
|
||||
border: 1px solid #f0d7b5;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-state-panel {
|
||||
min-height: 150px;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #667581;
|
||||
background: #fff;
|
||||
border-color: #dbe1e6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-state-panel strong {
|
||||
color: #273744;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.detail-state-warning {
|
||||
color: #7c613e;
|
||||
background: #fff8ed;
|
||||
border-color: #f0d7b5;
|
||||
}
|
||||
|
||||
.detail-facts-panel p {
|
||||
max-width: 720px;
|
||||
margin: 14px 0 0;
|
||||
color: #53626d;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.overview-conclusion,
|
||||
.overview-panel {
|
||||
background: #fff;
|
||||
@@ -686,4 +906,28 @@ textarea {
|
||||
.index-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-hero-panel {
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-quote-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-quote-panel > div {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.detail-chart-panel,
|
||||
.detail-facts-panel {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import MarketBreadth from './components/MarketBreadth.vue';
|
||||
import MarketConclusion from './components/MarketConclusion.vue';
|
||||
@@ -25,14 +26,18 @@ const {
|
||||
setAnalysisHorizon,
|
||||
setAnalysisEngine,
|
||||
} = useMarketOverview();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
function openDetail(label: string) {
|
||||
// 详情页尚未立项,先保留明确入口并避免伪造导航地址。
|
||||
console.info(`市场概览详情入口:${label}`);
|
||||
function openIndexDetail(code: string) {
|
||||
void router.push({ name: 'market-index-detail', params: { code } });
|
||||
}
|
||||
|
||||
function openSectorDetail(code: string) {
|
||||
void router.push({ name: 'market-sector-detail', params: { code } });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -82,7 +87,7 @@ function openDetail(label: string) {
|
||||
<IndexOverview
|
||||
:response="indices.response"
|
||||
:loading="indices.status === 'loading'"
|
||||
@select="openDetail"
|
||||
@select="openIndexDetail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +96,7 @@ function openDetail(label: string) {
|
||||
<SectorOverview
|
||||
:response="sectors.response"
|
||||
:loading="sectors.status === 'loading'"
|
||||
@select="openDetail"
|
||||
@select="openSectorDetail"
|
||||
/>
|
||||
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||
<ShortTermEmotion
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import MarketDetailChart from './MarketDetailChart.vue';
|
||||
|
||||
describe('市场详情 K 线图', () => {
|
||||
it('在有序 K 线输入下渲染固定尺寸 SVG 路径', () => {
|
||||
const wrapper = mount(MarketDetailChart, {
|
||||
props: {
|
||||
series: [
|
||||
{
|
||||
time: '20260817',
|
||||
open: 100,
|
||||
high: 103,
|
||||
low: 99,
|
||||
close: 102,
|
||||
amount: 1000,
|
||||
volume: 100,
|
||||
},
|
||||
{
|
||||
time: '20260818',
|
||||
open: 102,
|
||||
high: 106,
|
||||
low: 101,
|
||||
close: 105,
|
||||
amount: 1200,
|
||||
volume: 120,
|
||||
},
|
||||
],
|
||||
availableViews: { kline: true, intraday: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('svg').attributes('viewBox')).toBe('0 0 720 260');
|
||||
expect(wrapper.get('path').attributes('d')).toMatch(/^M /);
|
||||
expect(wrapper.text()).toContain('日 K 线');
|
||||
});
|
||||
|
||||
it('无图表数据时显示边界说明而不是空白画布', () => {
|
||||
const wrapper = mount(MarketDetailChart, {
|
||||
props: { series: [], availableViews: { kline: false, intraday: false } },
|
||||
});
|
||||
|
||||
expect(wrapper.find('svg').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('当前没有可用的 K 线数据');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { MarketDetailSeries } from './market-detail';
|
||||
|
||||
const props = defineProps<{
|
||||
series: MarketDetailSeries;
|
||||
availableViews: { kline: boolean; intraday: boolean };
|
||||
}>();
|
||||
|
||||
const width = 720;
|
||||
const height = 260;
|
||||
const padding = 24;
|
||||
|
||||
const path = computed(() => {
|
||||
if (!props.series.length || !props.availableViews.kline) return '';
|
||||
const closes = props.series.map((point) => point.close);
|
||||
const min = Math.min(...closes);
|
||||
const max = Math.max(...closes);
|
||||
const range = max - min || 1;
|
||||
const xStep = (width - padding * 2) / Math.max(props.series.length - 1, 1);
|
||||
return props.series
|
||||
.map((point, index) => {
|
||||
const x = padding + index * xStep;
|
||||
const y = height - padding - ((point.close - min) / range) * (height - padding * 2);
|
||||
return `${index === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`;
|
||||
})
|
||||
.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="detail-chart-panel" aria-labelledby="detail-kline-title">
|
||||
<div class="detail-section-heading">
|
||||
<div>
|
||||
<span class="section-kicker">走势</span>
|
||||
<h2 id="detail-kline-title">日 K 线</h2>
|
||||
</div>
|
||||
<span class="detail-chart-note">TDX · {{ series.length }} 点</span>
|
||||
</div>
|
||||
<div v-if="!path" class="detail-chart-empty">当前没有可用的 K 线数据</div>
|
||||
<svg
|
||||
v-else
|
||||
class="detail-chart"
|
||||
viewBox="0 0 720 260"
|
||||
role="img"
|
||||
aria-label="日 K 线收盘价走势"
|
||||
>
|
||||
<line x1="24" y1="236" x2="696" y2="236" class="detail-chart-axis" />
|
||||
<path :d="path" class="detail-chart-line" />
|
||||
</svg>
|
||||
<p v-if="!availableViews.intraday" class="detail-chart-boundary">当前数据源未提供分时数据</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import MarketDetailPage from './MarketDetailPage.vue';
|
||||
|
||||
const { getIndexDetail, getSectorDetail } = vi.hoisted(() => ({
|
||||
getIndexDetail: vi.fn(),
|
||||
getSectorDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/market-overview/detail', () => ({
|
||||
default: { getIndexDetail, getSectorDetail },
|
||||
}));
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/market-overview/indices/:code',
|
||||
component: MarketDetailPage,
|
||||
meta: { detailType: 'index' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const freshResponse = {
|
||||
snapshotId: '20260818-030000',
|
||||
asOf: '2026-08-18T00:00:00.000Z',
|
||||
source: 'tdx' as const,
|
||||
status: 'fresh' as const,
|
||||
warnings: [],
|
||||
data: {
|
||||
instrument: { code: '000001.SH', name: '上证指数', type: 'index' as const },
|
||||
quote: { price: 3380, changePercent: 0.89, direction: 'up' as const, tradeDate: '20260818' },
|
||||
series: [
|
||||
{
|
||||
time: '20260817',
|
||||
open: 3350,
|
||||
high: 3385,
|
||||
low: 3340,
|
||||
close: 3350,
|
||||
amount: 100,
|
||||
volume: 1000,
|
||||
},
|
||||
{
|
||||
time: '20260818',
|
||||
open: 3370,
|
||||
high: 3400,
|
||||
low: 3360,
|
||||
close: 3380,
|
||||
amount: 120,
|
||||
volume: 1100,
|
||||
},
|
||||
],
|
||||
availableViews: { kline: true, intraday: false },
|
||||
},
|
||||
};
|
||||
|
||||
describe('市场详情页', () => {
|
||||
beforeEach(() => {
|
||||
getIndexDetail.mockReset();
|
||||
getSectorDetail.mockReset();
|
||||
});
|
||||
|
||||
it('按路由加载指数详情并显示上海时间、K 线和分时边界', async () => {
|
||||
getIndexDetail.mockResolvedValue(freshResponse);
|
||||
const router = createTestRouter();
|
||||
await router.push('/market-overview/indices/000001.SH');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
|
||||
await vi.waitFor(() =>
|
||||
expect(getIndexDetail).toHaveBeenCalledWith('000001.SH', { period: '1d', count: 120 }),
|
||||
);
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('上证指数'));
|
||||
|
||||
expect(wrapper.text()).toContain('上证指数');
|
||||
expect(wrapper.text()).toContain('2026-08-18 08:00:00');
|
||||
expect(wrapper.text()).toContain('日 K 线');
|
||||
expect(wrapper.text()).toContain('当前数据源未提供分时数据');
|
||||
});
|
||||
|
||||
it('路由代码格式非法时不调用 API 并显示不可用边界', async () => {
|
||||
const router = createTestRouter();
|
||||
await router.push('/market-overview/indices/not-a-code');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
|
||||
|
||||
expect(getIndexDetail).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('详情代码不可用'));
|
||||
});
|
||||
|
||||
it('TDX 返回 unavailable 时显示 warning 而不是伪造图表', async () => {
|
||||
getIndexDetail.mockResolvedValue({
|
||||
...freshResponse,
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
warnings: ['指数数据不可用'],
|
||||
});
|
||||
const router = createTestRouter();
|
||||
await router.push('/market-overview/indices/000001.SH');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(MarketDetailPage, { global: { plugins: [router] } });
|
||||
await vi.waitFor(() => expect(getIndexDetail).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('指数数据不可用'));
|
||||
|
||||
expect(wrapper.text()).toContain('指数数据不可用');
|
||||
expect(wrapper.find('svg').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import marketDetailApi from '@/api/market-overview/detail';
|
||||
import { formatShanghaiDateTime } from '@/utils/datetime';
|
||||
|
||||
import MarketDetailChart from './MarketDetailChart.vue';
|
||||
import {
|
||||
formatPercent,
|
||||
formatTradeDate,
|
||||
isMarketCode,
|
||||
isMarketDetailType,
|
||||
statusLabel,
|
||||
type MarketDetailResponse,
|
||||
type MarketDetailType,
|
||||
} from './market-detail';
|
||||
|
||||
type PageStatus = 'idle' | 'loading' | 'ready' | 'error' | 'invalid';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const response = ref<MarketDetailResponse | null>(null);
|
||||
const pageStatus = ref<PageStatus>('idle');
|
||||
|
||||
const detailType = computed<MarketDetailType | null>(() => {
|
||||
const routeType = route.params.type;
|
||||
if (isMarketDetailType(routeType)) return routeType;
|
||||
return isMarketDetailType(route.meta.detailType) ? route.meta.detailType : null;
|
||||
});
|
||||
const code = computed(() => (typeof route.params.code === 'string' ? route.params.code : ''));
|
||||
const isValidRoute = computed(() => detailType.value !== null && isMarketCode(code.value));
|
||||
const data = computed(() => response.value?.data ?? null);
|
||||
|
||||
function createInvalidState(): void {
|
||||
response.value = null;
|
||||
pageStatus.value = 'invalid';
|
||||
}
|
||||
|
||||
async function loadDetail(): Promise<void> {
|
||||
if (!isValidRoute.value || !detailType.value) {
|
||||
createInvalidState();
|
||||
return;
|
||||
}
|
||||
pageStatus.value = 'loading';
|
||||
response.value = null;
|
||||
try {
|
||||
response.value =
|
||||
detailType.value === 'index'
|
||||
? await marketDetailApi.getIndexDetail(code.value, { period: '1d', count: 120 })
|
||||
: await marketDetailApi.getSectorDetail(code.value, { period: '1d', count: 120 });
|
||||
pageStatus.value = 'ready';
|
||||
} catch {
|
||||
response.value = null;
|
||||
pageStatus.value = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
void router.push('/');
|
||||
}
|
||||
|
||||
watch([detailType, code], () => void loadDetail());
|
||||
onMounted(() => void loadDetail());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="market-detail-page">
|
||||
<div class="detail-toolbar">
|
||||
<button type="button" class="quiet-button" title="返回市场概览" @click="goBack">
|
||||
← 返回概览
|
||||
</button>
|
||||
<span class="detail-toolbar-context">只读行情 · TDX</span>
|
||||
</div>
|
||||
|
||||
<section v-if="pageStatus === 'loading'" class="detail-state-panel" aria-live="polite">
|
||||
正在读取详情行情…
|
||||
</section>
|
||||
<section v-else-if="pageStatus === 'invalid'" class="detail-state-panel detail-state-warning">
|
||||
<strong>详情代码不可用</strong>
|
||||
<span>请从市场概览中选择已登记的指数或一级行业。</span>
|
||||
</section>
|
||||
<section v-else-if="pageStatus === 'error'" class="detail-state-panel detail-state-warning">
|
||||
<strong>详情请求失败</strong>
|
||||
<span>API 暂时不可用,请返回概览后重试。</span>
|
||||
</section>
|
||||
<template v-else-if="response">
|
||||
<section class="detail-hero-panel">
|
||||
<div>
|
||||
<span class="section-kicker">{{
|
||||
detailType === 'sector' ? '板块详情' : '指数详情'
|
||||
}}</span>
|
||||
<h1>{{ data?.instrument.name ?? code }}</h1>
|
||||
<p>{{ data?.instrument.code ?? code }}</p>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>来源:{{ response.source.toUpperCase() }}</span>
|
||||
<span>更新时间:{{ formatShanghaiDateTime(response.asOf) }}</span>
|
||||
<span>交易日:{{ formatTradeDate(data?.quote.tradeDate) }}</span>
|
||||
<strong :data-status="response.status">{{ statusLabel(response.status) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!data" class="detail-state-panel detail-state-warning">
|
||||
<strong>{{ response.warnings[0] ?? '行情数据不可用' }}</strong>
|
||||
<span>当前页面不展示伪造的图表或报价。</span>
|
||||
</section>
|
||||
<template v-else>
|
||||
<section class="detail-quote-panel" aria-label="核心行情">
|
||||
<div>
|
||||
<span>最新收盘价</span>
|
||||
<strong>{{ data.quote.price.toFixed(2) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>涨跌幅</span>
|
||||
<strong :data-direction="data.quote.direction">{{
|
||||
formatPercent(data.quote.changePercent ?? null)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>有效 K 线</span>
|
||||
<strong>{{ data.series.length }} 点</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="response.status !== 'fresh'" class="detail-warning">
|
||||
<strong>{{ statusLabel(response.status) }}</strong>
|
||||
<span>{{ response.warnings[0] ?? '部分详情数据不可用' }}</span>
|
||||
</div>
|
||||
|
||||
<MarketDetailChart :series="data.series" :available-views="data.availableViews" />
|
||||
|
||||
<section class="detail-facts-panel" aria-labelledby="detail-facts-title">
|
||||
<div class="detail-section-heading">
|
||||
<div>
|
||||
<span class="section-kicker">复盘摘要</span>
|
||||
<h2 id="detail-facts-title">事实数据</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{{ data.instrument.name }}最近交易日收盘价为 {{ data.quote.price.toFixed(2) }}, 涨跌幅
|
||||
{{ formatPercent(data.quote.changePercent ?? null) }}。本页只展示 TDX
|
||||
行情和规则计算结果。
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { components } from '@/api/generated/schema';
|
||||
|
||||
export type MarketDetailType = 'index' | 'sector';
|
||||
export type MarketDetailResponse =
|
||||
| components['schemas']['IndexDetailResponseDto']
|
||||
| components['schemas']['SectorDetailResponseDto'];
|
||||
export type MarketDetailData = NonNullable<MarketDetailResponse['data']>;
|
||||
export type MarketDetailSeries = MarketDetailData['series'];
|
||||
|
||||
export function isMarketDetailType(value: unknown): value is MarketDetailType {
|
||||
return value === 'index' || value === 'sector';
|
||||
}
|
||||
|
||||
export function isMarketCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^\d{6}\.(SH|SZ|BJ)$/.test(value);
|
||||
}
|
||||
|
||||
export function formatTradeDate(value: string | undefined): string {
|
||||
return value && /^\d{8}$/.test(value)
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
|
||||
: '—';
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null): string {
|
||||
return value === null ? '—' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function statusLabel(status: MarketDetailResponse['status']): string {
|
||||
return {
|
||||
fresh: '数据新鲜',
|
||||
delayed: '交易日延迟',
|
||||
partial: '部分数据',
|
||||
unavailable: '数据不可用',
|
||||
}[status];
|
||||
}
|
||||
Reference in New Issue
Block a user