feat(FEAT-MO-001): 完成TDX市场概览首屏
This commit is contained in:
@@ -9,11 +9,16 @@ import type {
|
||||
|
||||
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' }),
|
||||
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' }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+23
-2
@@ -1,11 +1,32 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory } from 'vue-router';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from './App.vue';
|
||||
import { createAppRouter } from './router';
|
||||
|
||||
// 应用外壳测试只验证导航和主区域,不应因页面挂载触发真实 Axios 请求。
|
||||
vi.mock('@/api/market-overview', () => {
|
||||
const response = {
|
||||
source: 'tdx',
|
||||
status: 'unavailable',
|
||||
data: null,
|
||||
snapshotId: 'test',
|
||||
asOf: '2026-08-18T00:00:00.000Z',
|
||||
warnings: ['测试环境未连接 TDX'],
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
getSummary: vi.fn().mockResolvedValue(response),
|
||||
getIndices: vi.fn().mockResolvedValue(response),
|
||||
getBreadth: vi.fn().mockResolvedValue(response),
|
||||
getSectors: vi.fn().mockResolvedValue(response),
|
||||
getFunds: vi.fn().mockResolvedValue(response),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('应用外壳', () => {
|
||||
it('提供可访问的应用标题和主内容区域', async () => {
|
||||
const router = createAppRouter(createMemoryHistory());
|
||||
@@ -21,6 +42,6 @@ describe('应用外壳', () => {
|
||||
expect(wrapper.get('header').attributes('aria-label')).toBe('应用导航');
|
||||
expect(wrapper.get('main').attributes('id')).toBe('main-content');
|
||||
expect(wrapper.text()).toContain('A 股市场分析工具');
|
||||
expect(wrapper.text()).toContain('工程基线已就绪');
|
||||
expect(wrapper.text()).toContain('市场综合概览');
|
||||
});
|
||||
});
|
||||
|
||||
+8
-2
@@ -10,8 +10,14 @@ const appTitle = import.meta.env.VITE_APP_TITLE || 'A 股市场分析工具';
|
||||
<NMessageProvider>
|
||||
<div class="app-shell">
|
||||
<header class="app-header" aria-label="应用导航">
|
||||
<span class="app-mark" aria-hidden="true">A</span>
|
||||
<strong>{{ appTitle }}</strong>
|
||||
<div class="app-identity">
|
||||
<span class="app-mark" aria-hidden="true">A</span>
|
||||
<div>
|
||||
<strong>{{ appTitle }}</strong>
|
||||
<small>市场复盘工作台</small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="header-context">只读行情 · TDX</span>
|
||||
</header>
|
||||
<main id="main-content" class="app-main">
|
||||
<RouterView />
|
||||
|
||||
@@ -4,16 +4,14 @@ import {
|
||||
type RouterHistory,
|
||||
type RouteRecordRaw,
|
||||
} from 'vue-router';
|
||||
import { h } from 'vue';
|
||||
|
||||
import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: {
|
||||
// 工程基线不引入运行时模板编译器;业务首页落地前用渲染函数提供可观测占位内容。
|
||||
render: () => h('section', { 'aria-label': '应用就绪' }, [h('p', '工程基线已就绪')]),
|
||||
},
|
||||
component: MarketOverviewPage,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+474
-1
@@ -44,7 +44,7 @@ textarea {
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
padding: 0 20px;
|
||||
color: #fff;
|
||||
@@ -52,6 +52,29 @@ textarea {
|
||||
border-bottom: 1px solid #343b44;
|
||||
}
|
||||
|
||||
.app-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-identity strong,
|
||||
.app-identity small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-identity small {
|
||||
margin-top: 2px;
|
||||
color: #aeb7c2;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-context {
|
||||
color: #aeb7c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-mark {
|
||||
display: inline-grid;
|
||||
width: 28px;
|
||||
@@ -69,10 +92,460 @@ textarea {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.market-overview-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
background: #e8edf1;
|
||||
border: 1px solid #d5dce3;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.view-switcher button,
|
||||
.refresh-button,
|
||||
.quiet-button,
|
||||
.sector-list button {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.view-switcher button {
|
||||
min-width: 72px;
|
||||
padding: 7px 12px;
|
||||
color: #4c5966;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.view-switcher button.active {
|
||||
color: #17212b;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgb(28 40 52 / 14%);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar-actions label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #65717d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions select {
|
||||
min-height: 32px;
|
||||
padding: 0 28px 0 9px;
|
||||
color: #24303b;
|
||||
background: #fff;
|
||||
border: 1px solid #ccd4dc;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
color: #fff;
|
||||
background: #167d82;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.refresh-button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.overview-conclusion,
|
||||
.overview-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #dbe1e6;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 3px 14px rgb(26 38 49 / 5%);
|
||||
}
|
||||
|
||||
.overview-conclusion {
|
||||
padding: 22px 24px 18px;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: #167d82;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.conclusion-heading,
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.conclusion-heading {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.conclusion-heading h1,
|
||||
.panel-heading h2 {
|
||||
margin: 0;
|
||||
color: #17212b;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.conclusion-heading h1 {
|
||||
font-size: clamp(24px, 3vw, 38px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel-heading h2 {
|
||||
margin-top: 4px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.conclusion-copy {
|
||||
margin: 10px 0 0;
|
||||
color: #475563;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.source-status {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
gap: 5px 7px;
|
||||
min-width: 142px;
|
||||
padding: 9px 11px;
|
||||
color: #167d82;
|
||||
background: #eff8f7;
|
||||
border: 1px solid #cbe6e2;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source-status small {
|
||||
grid-column: 1 / -1;
|
||||
color: #6d7a85;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-status[data-status='unavailable'] {
|
||||
color: #a45e1b;
|
||||
background: #fff7ec;
|
||||
border-color: #f0d7b5;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: currentcolor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.conclusion-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 21px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e6eaee;
|
||||
}
|
||||
|
||||
.conclusion-facts div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.conclusion-facts span,
|
||||
.panel-meta,
|
||||
.breadth-values span,
|
||||
.column-label {
|
||||
color: #7b8792;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.conclusion-facts strong {
|
||||
color: #2e3b47;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.module-warning {
|
||||
margin: 13px 0 0;
|
||||
color: #a45e1b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overview-grid-top {
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
|
||||
.overview-grid-bottom {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
min-height: 190px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
align-items: center;
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quiet-button {
|
||||
padding: 6px 9px;
|
||||
color: #167d82;
|
||||
background: #eff8f7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.drivers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 17px;
|
||||
}
|
||||
|
||||
.driver-column + .driver-column {
|
||||
padding-left: 17px;
|
||||
border-left: 1px solid #e6eaee;
|
||||
}
|
||||
|
||||
.column-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.column-label.positive,
|
||||
.positive {
|
||||
color: #c33f3f;
|
||||
}
|
||||
|
||||
.column-label.negative,
|
||||
.negative {
|
||||
color: #19846e;
|
||||
}
|
||||
|
||||
.driver-column ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #485664;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.driver-column li {
|
||||
position: relative;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.driver-column li::before {
|
||||
position: absolute;
|
||||
top: 0.62em;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #aeb8c1;
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.index-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.index-item {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-height: 112px;
|
||||
padding: 13px;
|
||||
color: #4c5966;
|
||||
text-align: left;
|
||||
background: #f8fafb;
|
||||
border: 1px solid #e3e8ec;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.index-item:hover {
|
||||
border-color: #a5c9c7;
|
||||
box-shadow: 0 2px 8px rgb(26 38 49 / 8%);
|
||||
}
|
||||
|
||||
.index-item strong {
|
||||
color: #1c2a35;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.index-item small {
|
||||
color: #7d8893;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.index-item[data-direction='up'] small {
|
||||
color: #c33f3f;
|
||||
}
|
||||
|
||||
.index-item[data-direction='down'] small {
|
||||
color: #19846e;
|
||||
}
|
||||
|
||||
.breadth-values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.breadth-values div {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.breadth-values strong {
|
||||
color: #2e3b47;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.module-placeholder,
|
||||
.module-unavailable {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: 104px;
|
||||
gap: 6px;
|
||||
color: #8a959f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.module-unavailable strong {
|
||||
color: #a45e1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.module-unavailable span {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sector-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.sector-list button {
|
||||
padding: 7px 9px;
|
||||
color: #4c5966;
|
||||
background: #f3f7f7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.overview-grid-top,
|
||||
.overview-grid-bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-header,
|
||||
.app-main {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.header-context {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-toolbar,
|
||||
.conclusion-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.view-switcher {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.view-switcher button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.overview-conclusion {
|
||||
padding: 18px 16px 15px;
|
||||
}
|
||||
|
||||
.conclusion-heading h1 {
|
||||
font-size: 27px;
|
||||
}
|
||||
|
||||
.source-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.conclusion-facts {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.conclusion-facts strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drivers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.driver-column + .driver-column {
|
||||
padding-top: 14px;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid #e6eaee;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.index-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import MarketBreadth from './components/MarketBreadth.vue';
|
||||
import MarketConclusion from './components/MarketConclusion.vue';
|
||||
import MarketDrivers from './components/MarketDrivers.vue';
|
||||
import SectorOverview from './components/SectorOverview.vue';
|
||||
import FundTrend from './components/FundTrend.vue';
|
||||
import IndexOverview from './components/IndexOverview.vue';
|
||||
import { useMarketOverview } from './useMarketOverview';
|
||||
|
||||
const {
|
||||
summary,
|
||||
indices,
|
||||
breadth,
|
||||
sectors,
|
||||
funds,
|
||||
isLoading,
|
||||
analysisHorizon,
|
||||
analysisEngine,
|
||||
refresh,
|
||||
setAnalysisHorizon,
|
||||
setAnalysisEngine,
|
||||
} = useMarketOverview();
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
|
||||
function openDetail(label: string) {
|
||||
// 详情页尚未立项,先保留明确入口并避免伪造导航地址。
|
||||
console.info(`市场概览详情入口:${label}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="market-overview-page">
|
||||
<div class="overview-toolbar">
|
||||
<div class="view-switcher" aria-label="分析视角">
|
||||
<button
|
||||
v-for="option in ['综合', '短线', '中长线']"
|
||||
:key="option"
|
||||
type="button"
|
||||
:class="{ active: analysisHorizon === option }"
|
||||
@click="setAnalysisHorizon(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label>
|
||||
<span>分析引擎</span>
|
||||
<select
|
||||
:value="analysisEngine"
|
||||
@change="setAnalysisEngine(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option>规则</option>
|
||||
<option>AI</option>
|
||||
<option>混合</option>
|
||||
<option>对照</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-button"
|
||||
:disabled="isLoading"
|
||||
title="重新读取 TDX 行情"
|
||||
@click="refresh"
|
||||
>
|
||||
{{ isLoading ? '读取中…' : '刷新行情' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarketConclusion :response="summary.response" :loading="summary.status === 'loading'" />
|
||||
|
||||
<div class="overview-grid overview-grid-top">
|
||||
<MarketDrivers :response="summary.response" />
|
||||
<IndexOverview
|
||||
:response="indices.response"
|
||||
:loading="indices.status === 'loading'"
|
||||
@select="openDetail"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid overview-grid-bottom">
|
||||
<MarketBreadth :response="breadth.response" :loading="breadth.status === 'loading'" />
|
||||
<SectorOverview
|
||||
:response="sectors.response"
|
||||
:loading="sectors.status === 'loading'"
|
||||
@select="openDetail"
|
||||
/>
|
||||
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { FundsResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel" aria-labelledby="funds-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">资金观察</span>
|
||||
<h2 id="funds-title">资金趋势</h2>
|
||||
</div>
|
||||
<span class="panel-meta">数据口径</span>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<div v-else class="module-placeholder">暂无资金趋势数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndicesResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{ response: IndicesResponse | null; loading: boolean }>();
|
||||
const emit = defineEmits<{ select: [code: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel" aria-labelledby="indices-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">核心指数</span>
|
||||
<h2 id="indices-title">主要市场温度</h2>
|
||||
</div>
|
||||
<span class="panel-meta">{{ response?.asOf ? response.asOf.slice(11, 19) : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取指数…</div>
|
||||
<div v-else-if="!response?.data?.items.length" class="module-placeholder">
|
||||
TDX 指数数据不可用
|
||||
</div>
|
||||
<div v-else class="index-grid">
|
||||
<button
|
||||
v-for="item in response.data.items"
|
||||
:key="item.code"
|
||||
type="button"
|
||||
class="index-item"
|
||||
:data-direction="item.direction"
|
||||
:title="`进入${item.name}详细分析`"
|
||||
@click="emit('select', item.code)"
|
||||
>
|
||||
<span>{{ item.name }}</span>
|
||||
<strong>{{ item.price.toFixed(2) }}</strong>
|
||||
<small>{{
|
||||
item.changePercent == null
|
||||
? '—'
|
||||
: `${item.changePercent > 0 ? '+' : ''}${item.changePercent.toFixed(2)}%`
|
||||
}}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { BreadthResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel" aria-labelledby="breadth-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">市场宽度</span>
|
||||
<h2 id="breadth-title">涨跌分布与量能</h2>
|
||||
</div>
|
||||
<span class="panel-meta">分钟级</span>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取市场宽度…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<div v-else-if="response?.data" class="breadth-values">
|
||||
<div>
|
||||
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>平盘</span><strong>{{ response.data.unchanged }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="module-placeholder">暂无宽度数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { SummaryResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{
|
||||
response: SummaryResponse | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-conclusion" aria-labelledby="market-overview-title">
|
||||
<div class="section-kicker">市场综合概览</div>
|
||||
<div class="conclusion-heading">
|
||||
<div>
|
||||
<h1 id="market-overview-title">盘面先看结论,再看证据</h1>
|
||||
<p class="conclusion-copy">
|
||||
{{ loading ? '正在读取 TDX 行情…' : response?.data?.verdict || 'TDX 数据暂不可用' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="source-status" :data-status="response?.status || 'unavailable'">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span>{{ response?.source === 'tdx' ? 'TDX 行情' : '等待数据' }}</span>
|
||||
<small>{{ response?.asOf ? `更新于 ${response.asOf}` : '尚未连接' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conclusion-facts">
|
||||
<div>
|
||||
<span>市场状态</span>
|
||||
<strong>{{ response?.data?.marketStatus || '待确认' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>量能状态</span>
|
||||
<strong>{{ response?.data?.volumeStatus || '待确认' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>情绪状态</span>
|
||||
<strong>{{ response?.data?.sentimentStatus || '待确认' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="response?.warnings.length" class="module-warning">{{ response.warnings[0] }}</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { SummaryResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{ response: SummaryResponse | null }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel drivers-panel" aria-labelledby="drivers-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">判断依据</span>
|
||||
<h2 id="drivers-title">驱动与风险</h2>
|
||||
</div>
|
||||
<button type="button" class="quiet-button" title="查看当前结论依据">查看依据</button>
|
||||
</div>
|
||||
<div class="drivers-grid">
|
||||
<div class="driver-column">
|
||||
<span class="column-label positive">驱动</span>
|
||||
<ul>
|
||||
<li v-for="driver in response?.data?.drivers || ['等待 TDX 数据']" :key="driver">
|
||||
{{ driver }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="driver-column">
|
||||
<span class="column-label negative">风险</span>
|
||||
<ul>
|
||||
<li v-for="risk in response?.data?.risks || ['等待 TDX 数据']" :key="risk">{{ risk }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { SectorsResponse } from '@/types/market-overview';
|
||||
|
||||
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
|
||||
const emit = defineEmits<{ select: [sector: string] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overview-panel" aria-labelledby="sectors-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<span class="section-kicker">板块扫描</span>
|
||||
<h2 id="sectors-title">强弱分布</h2>
|
||||
</div>
|
||||
<span class="panel-meta">点击查看详情</span>
|
||||
</div>
|
||||
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
|
||||
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||
<strong>暂不可用</strong>
|
||||
<span>{{ response.warnings[0] }}</span>
|
||||
</div>
|
||||
<div v-else-if="response?.data?.items.length" class="sector-list">
|
||||
<button
|
||||
v-for="sector in response.data.items"
|
||||
:key="sector"
|
||||
type="button"
|
||||
@click="emit('select', sector)"
|
||||
>
|
||||
{{ sector }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="module-placeholder">暂无板块数据</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2,7 +2,14 @@ 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;
|
||||
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 () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
SummaryResponse,
|
||||
} from '@/types/market-overview';
|
||||
|
||||
type MarketResponse = SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
||||
type MarketResponse =
|
||||
SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
||||
|
||||
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
||||
status: OverviewModuleStatus;
|
||||
@@ -37,7 +38,9 @@ function writePreference(key: string, value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function useMarketOverview(api: ReturnType<typeof createMarketOverviewApi> = marketOverviewApi) {
|
||||
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());
|
||||
|
||||
@@ -14,6 +14,7 @@ test('应用外壳在桌面和移动视口均可正常呈现', async ({ page })
|
||||
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.getByText('A 股市场分析工具')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '盘面先看结论,再看证据' })).toBeVisible();
|
||||
|
||||
const hasHorizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
|
||||
Reference in New Issue
Block a user