feat(FEAT-MO-004): 实现指数板块详情分析页

This commit is contained in:
2026-08-25 00:16:43 +08:00
parent f8f4da93dc
commit 0131f863b6
32 changed files with 2262 additions and 8 deletions
+144
View File
@@ -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 },
},
]);
});
});
+28
View File
@@ -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;
+13
View File
@@ -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 的路由实例,使浏览器运行与单元测试共享同一路由表。 */
+244
View File
@@ -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];
}