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
+1 -1
View File
@@ -29,7 +29,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **a-share-analysis** (3472 symbols, 4950 relationships, 104 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **a-share-analysis** (3730 symbols, 5410 relationships, 115 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -0,0 +1,10 @@
# FEAT-MO-004 设计说明
设计基线为 `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md`。
后端新增详情 Controller/Service/Mapper,复用现有 TDX Client 的超时、响应体上限和错误脱敏边界;指数和板块使用独立代码白名单和数据口径。前端新增详情路由、独立 Axios API 文件、共享详情页和轻量 SVG K 线图,概览按钮仅负责导航。
详情数据失败采用模块级降级,页面仍保留标题、来源、更新时间和 warning。分时能力不通过探测时不返回假数据,UI 只展示明确的不可用边界。
安全策略:Controller/Service 双层参数校验、代码白名单、固定周期、数量上限、响应字段白名单和错误脱敏。性能策略:单次最多 120 点、固定图表尺寸、页面卸载释放图表状态,不新增数据库和缓存热点。
@@ -0,0 +1,28 @@
# GitNexus 开发前基线
- 功能编号:FEAT-MO-004
- 记录时间:2026年8月24日 GMT+8 23:37:42
- 分支:main
- 基线提交:f8f4da93dc8f
## 工作区
```text
?? "SRMS3.6.0\345\270\270\350\247\201\351\227\256\351\242\230\346\265\213\350\257\204\351\242\230.2csv"
?? docs/features/FEAT-MO-004-index-sector-detail/
?? docs/superpowers/plans/2026-08-24-index-sector-detail.md
```
## GitNexus 状态
```text
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis
Indexed: 8/24/2026, 11:37:41 PM
Indexed commit: f8f4da9
Current commit: f8f4da9
Status: ✅ up-to-date
```
## 后续人工分析
在修改代码前,将 GitNexus query、context、impact 的结论写入 `gitnexus/impact-plan.md`。高风险结果必须先获得用户确认。
@@ -0,0 +1,37 @@
# GitNexus 开发前影响计划
- 功能编号:FEAT-MO-004
- 总体风险:MEDIUM(既有概览服务扩展会触及 Controller、模块、服务测试和 OpenAPI 导出;无 HIGH/CRITICAL)
## 直接与间接影响
### 查询与上下文
- GitNexus 仓库:`a-share-analysis`,开发前索引与 `f8f4da9` 一致且 up-to-date。
- 影响目标:`MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter`。
- `MarketOverviewController` upstream:3 个直接文件消费者(概览 E2E、OpenAPI 导出、模块注册),总影响 7 个文件,风险 LOW。
- `MarketOverviewService` upstream:5 个直接文件消费者(概览 E2E、服务测试、OpenAPI 导出、Controller、模块),总影响 9 个文件,风险 MEDIUM。
- `createTdxClient` upstream:TDX Client 单元测试 1 个直接消费者,风险 LOW;详情复用其稳定 `request<T>()` 边界,不修改协议实现。
- `createAppRouter` upstream:`App.test.ts` 1 个直接消费者,风险 LOW;新增路由需要补充路由测试和根路径回归。
- `MarketOverviewPage` 未被索引为符号,按文件级人工检查确认只消费概览 composable 和两个选择事件;新增导航不改变概览请求契约。
- 执行流:详情是新增只读 HTTP 流程;既有首页五个概览流程仅通过模块注册和共享 TDX Client 间接关联,需回归验证。
## 跨模块同步修改
| 范围 | 判断 | 同步动作 |
| --- | --- | --- |
| HTTP/OpenAPI | 需要 | 新增两个详情路由、DTO、生成 OpenAPI 并校验 |
| Web API | 需要 | 新增详情 Axios 文件与类型适配 |
| 消息契约 | 不需要 | 只读同步请求,不产生消息 |
| 数据库/Redis | 不需要 | 不持久化、不缓存,且不触碰生产库 |
| API 进程 | 需要 | 注册详情 Controller/Service,复用已校验 TDX Client |
| Scheduler/Worker | 不需要 | 无异步任务或定时采集 |
| 配置/部署 | 不需要 | 继续使用现有 TDX 配置和内网隧道,不新增环境变量 |
| 测试 | 需要 | Mapper、Service、HTTP、Web 路由/状态、视觉和全量门禁 |
| 文档 | 需要 | 功能档案、TDX 探测、开发总结、Review、QA、GitNexus 前后对比 |
### 设计决策
1. 不扩展首页接口大对象,详情接口独立,降低既有消费者的契约风险。
2. 不修改 `createTdxClient`,只复用其超时、响应体上限和错误脱敏;分时能力未验证时明确返回不可用。
3. 指数和板块共享页面外壳与 K 线点类型,但保留各自白名单和业务摘要映射。
@@ -0,0 +1,6 @@
# FEAT-MO-004 实施记录
详细实施计划见 [`docs/superpowers/plans/2026-08-24-index-sector-detail.md`](../../../superpowers/plans/2026-08-24-index-sector-detail.md)。
执行顺序:开发前 Schema/GitNexus 门禁 → TDX 能力探测 → Mapper → Service/Controller → Web 路由和详情页 → 契约、测试、视觉验证、Review 和治理收尾。
@@ -0,0 +1,9 @@
featureId: FEAT-MO-004
slug: index-sector-detail
name: 指数与板块详情分析页
status: in_progress
release: unreleased
source: tdx
scope: internal_lan_only
createdAt: 2026-08-24
@@ -0,0 +1,29 @@
# FEAT-MO-004 指数与板块详情分析页 PRD
## 原始需求
市场综合概览中的指数、板块可点击进入详细分析页,支持使用 TDX 真实数据进行复盘查看。
## 用户与场景
- 个人用户在概览页发现指数或强势板块后,进入详情确认交易日期、价格变化和 K 线走势。
- 内网开发环境优先,TDX 通过当前 Mac 本机隧道访问;不把 TDX 暴露到公网。
## V1 范围
- 指数详情和一级行业板块详情。
- 共享详情页外壳、数据状态、更新时间、来源、警告和 K 线图。
- 分时视图由 TDX 能力探测结果决定,未通过时明确不可用。
- 事实型数据和规则计算结果,不提供买卖建议、目标价、仓位建议或交易操作。
## 非目标
成分股排名、主力净流入、盘中新闻、AI 结论、用户体系、订阅付费和数据库/Redis/RocketMQ 持久化均不在本版本。
## 验收标准
1. 点击概览中的指数或板块可进入对应详情 URL,非法代码不触发 TDX 请求。
2. API 返回 `instrument`、`quote`、`series`、`availableViews` 和统一元数据,K 线不超过 120 点。
3. TDX 不可达、交易日延迟、字段缺失和空序列分别显示明确状态,不伪造图表。
4. Web 显示上海时间,桌面和 390px 视口无横向溢出,图表容器尺寸稳定。
@@ -0,0 +1,23 @@
# FEAT-MO-004 需求与约束
## 功能需求
- FR-001:提供指数详情 HTTP API。
- FR-002:提供一级行业板块详情 HTTP API。
- FR-003:详情 API 只接受登记代码、`period=1d` 和 `1..120` 的 `count`。
- FR-004:详情响应包含 K 线序列、报价、交易日期、状态、来源和警告。
- FR-005:概览指数和板块按钮导航到详情页,详情页可返回概览。
- FR-006:分时仅在 TDX 探测通过后启用,未启用时返回 `intraday=false`。
## 非功能需求
- NFR-001:TDX 原始异常和地址不得暴露给 Web。
- NFR-002:本机 TDX 可达时详情首屏 P95 目标小于 2 秒;不可达时 10 秒内返回不可用状态。
- NFR-003:详情请求失败不影响首页五个概览模块。
- NFR-004:API `asOf` 使用 UTC ISO,Web 使用 `Asia/Shanghai` 展示。
- NFR-005:前端请求只能经过 Axios 封装,后端 DTO 作为 OpenAPI 契约来源。
## 约束判断
消息、数据库、Redis、Scheduler、Worker 和部署配置无需同步修改;详情为只读实时查询,不产生异步任务或持久化数据。OpenAPI、Web API、前端路由、测试和文档需要同步更新。
@@ -0,0 +1,6 @@
# FEAT-MO-004 回退说明
详情功能为无数据库结构变更的只读模块。若发布后出现问题,先回退前端详情路由和概览导航,再回退后端详情 Controller/Service 注册;首页已有五个概览接口不依赖详情模块。回退不执行生产数据库操作、不删除 TDX 配置、不修改用户数据。
回退验证包括:`GET /api/v1/market-overview/indices`、`sectors` 仍返回 200;Web 根路径可加载;OpenAPI 与生成契约重新生成并通过边界测试。
@@ -0,0 +1,29 @@
# FEAT-MO-004 TDX 能力探测报告
- 探测日期:2026-08-24(开发环境)
- 数据源:TDX 开发隧道(地址和请求体按安全规则不记录)
- 探测方式:只读 JSON-RPC 请求,输出仅保留状态码、耗时、字段名和脱敏代码。
## 已通过能力
| 能力 | 方法 | 结果 | 观测 |
| --- | --- | --- | --- |
| 指数日 K 线 | `get_market_data` | 通过 | HTTP 200,业务 `ErrorId=0`,返回 `Value` 及分页元数据;两条核心指数请求耗时约 56ms |
| 一级行业目录 | `get_stock_list` | 通过 | HTTP 200,业务 `ErrorId=0`,返回数组,首个脱敏记录代码为 `881001.SH` |
| 一级行业日 K 线 | `get_market_data` | 通过 | 对目录首个代码请求 5 条日线,HTTP 200,业务 `ErrorId=0`,返回对应 `Value`;耗时约 47ms |
## 未通过能力
| 能力 | 候选方法 | 结果 | 处理 |
| --- | --- | --- | --- |
| 分时 | `get_minute_data` | 未通过 | 服务返回协议错误,未暴露原始错误内容;V1 返回 `availableViews.intraday=false` |
| 分时 | `get_minute_data_ex` | 未通过 | 服务返回协议错误;不继续猜测参数或伪造分时序列 |
| 历史分时 | `get_history_minute_data` | 未通过 | 服务返回协议错误;延期到独立能力验证 |
## 安全和稳定性结论
- 所有探测均为固定只读方法,未调用交易、账户、自选或写入接口。
- 日 K 线字段沿用已验证的 `Date/Open/High/Low/Close/Amount/Volume`;详情服务仍会二次校验字段长度、日期和数量上限。
- 真实响应只用于能力判断,不作为提交到仓库的原始样本;报告不包含地址、请求体、凭据或原始异常。
- 本次观测不代表生产容量证明;详情页仍按单请求最多 120 点和现有 TDX 超时配置执行。
@@ -0,0 +1,163 @@
# 指数与板块详情分析页实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在市场综合概览中实现可点击的指数详情页,并在同一外壳上接入一级行业板块详情;只展示经过 TDX 校验的事实数据和规则计算结果。
**Architecture:** 后端新增详情业务模块,复用现有 TDX Client 和概览元数据,但不把详情数据拼入首页接口。详情 Controller 负责参数白名单与查询上限,Service 按指数/板块分别读取和映射日 K 线,Web 通过独立 Axios API 和共享详情页面外壳展示状态、K 线与可用视图;分时能力只有在探测报告通过后才启用。
**Tech Stack:** NestJS 11、TypeScript 5.9、Vue 3、Vue Router 4、Axios、Naive UI、原生 SVG/Canvas(除非依赖评估证明需要新增图表库)、TDX JSON-RPC。
**Spec:** `docs/superpowers/specs/2026-08-24-index-sector-detail-design.md`
## Global Constraints
- Node 使用 `22.x`,MySQL 仅保留 `5.7.x` 兼容约束,排序规则为 `utf8mb4_general_ci`。
- 详情接口只读 TDX,不启用 MySQL、Redis、RocketMQ、AI 或用户体系。
- TDX 原始地址、请求体、凭据和原始异常不得进入 Web 响应;错误必须分类并脱敏。
- API 响应 `asOf` 保持 UTC ISO,Web 统一按 `Asia/Shanghai` 展示。
- 每个详情请求最多返回 120 个 K 线点;`period` 仅接受已验证的 `1d`。
- 不提交密钥、密码、`.env`、生产数据或 `.gitnexus/`;继续使用项目级 Gitea。
- 修改代码前完成 GitNexus upstream impact;提交前运行 `gitnexus_detect_changes`。
- 中文 Conventional Commit,提交验证通过后立即推送远程。
## 文件边界
### 新增
- `server/apps/api/src/market-overview/detail/market-detail.dto.ts`:详情响应、报价、K 线点和可用视图 OpenAPI DTO。
- `server/apps/api/src/market-overview/detail/market-detail.mapper.ts`:指数/板块 K 线的安全字段映射、日期排序、数量裁剪和状态辅助。
- `server/apps/api/src/market-overview/detail/market-detail.service.ts`:详情查询、业务代码白名单、TDX 错误降级和指数/板块口径分流。
- `server/apps/api/src/market-overview/detail/market-detail.controller.ts`:两个详情路由、查询参数校验和 Swagger 声明。
- `server/apps/api/src/market-overview/detail/market-detail.service.test.ts`、`market-detail.mapper.test.ts`:服务与映射单元测试。
- `server/apps/api/test/market-detail.e2e-spec.ts`:HTTP 参数、响应状态和脱敏测试。
- `web/src/api/market-overview/detail.ts`:详情 API Axios 封装。
- `web/src/views/market-overview/detail/MarketDetailPage.vue`:共享详情页外壳和加载/错误/延迟状态。
- `web/src/views/market-overview/detail/MarketDetailChart.vue`:固定尺寸的轻量 SVG K 线图和不可用边界。
- `web/src/views/market-overview/detail/market-detail.ts`:前端详情响应类型适配与展示辅助。
- `web/src/views/market-overview/detail/MarketDetailPage.test.ts`、`MarketDetailChart.test.ts`:路由状态、时区和图表输入测试。
- `docs/features/FEAT-MO-004-index-sector-detail/`:PRD、需求、设计、计划、测试、回退、开发总结、GitNexus、Review 和 QA 证据。
### 修改
- `server/apps/api/src/market-overview/market-overview.module.ts`:注册详情 Controller/Service。
- `server/apps/api/src/market-overview/tdx/tdx.types.ts`:仅在探测确认后补充分时响应类型;日 K 线已有类型保持兼容。
- `web/src/app/router.ts`:新增 `/market-overview/indices/:code` 与 `/market-overview/sectors/:code` 路由。
- `web/src/views/market-overview/MarketOverviewPage.vue`:把指数/板块选择事件改为真实路由导航。
- `server/openapi/openapi.json`、`web/src/api/generated/schema.ts`:通过既有生成脚本更新契约,不手写漂移。
## 实施任务
### Task 1:建立功能档案、基线与影响分析
**Files:**
- Create: `docs/features/FEAT-MO-004-index-sector-detail/manifest.yaml`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/prd.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/requirements.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/design.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/rollback.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/impact-plan.md`
- [ ] 运行 `npm run schema:baseline`、`npm run schema:verify`;如果开发库为空,记录 `captured_empty`,不得伪造 SQL。
- [ ] 运行 `npm run governance:start -- FEAT-MO-004 index-sector-detail`,确认基线已提交、已推送且 GitNexus up-to-date。
- [ ] 对 `MarketOverviewController`、`MarketOverviewService`、`createTdxClient`、`createAppRouter` 和 `MarketOverviewPage` 执行 upstream impact,记录直接调用方、执行流、模块影响和风险。
- [ ] 将 HTTP/OpenAPI、Web API、消息、数据库、Redis、Scheduler、Worker、部署、测试和文档的同步判断写入 `gitnexus/impact-plan.md`。
### Task 2:TDX 能力探测和脱敏证据
**Files:**
- Create: `docs/features/FEAT-MO-004-index-sector-detail/tdx-capability-probe.md`
- Test: `server/apps/api/src/market-overview/tdx/tdx.client.test.ts`(只补充固定方法/参数边界测试,如确有必要)
- [ ] 使用当前开发环境 TDX 隧道探测 `get_market_data` 的日 K 线、候选分时方法、时间字段、响应上限和一级行业代码历史 K 线。
- [ ] 记录方法名、参数形状、返回字段、耗时、是否可重复和脱敏失败原因;禁止把地址、请求体凭据或原始异常写入文档。
- [ ] 若分时协议或字段完整性未通过,只登记 `intraday=false`,不改造协议、不生成假数据。
### Task 3:先写详情 Mapper 失败测试并实现安全 K 线映射
**Files:**
- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.test.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.mapper.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.dto.ts`
**Interfaces:**
- `mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPoint[]`
- `mapDetailQuote(series: TdxKlineSeries, definition: DetailInstrumentDefinition): DetailQuoteDto | null`
- `trimAndSortSeries(points: DetailSeriesPoint[], count: number): DetailSeriesPoint[]`
- [ ] 为空序列、日期非法、字段长度不一致、倒序日期、缺少金额/成交量、超过 120 条和相邻收盘价为零编写失败测试。
- [ ] 实现只读取 `Date/Open/High/Low/Close/Amount/Volume`,按合法交易日期升序排序,单点字段缺失时返回 `null` 或降级状态,不抛出原始数据。
- [ ] 实现价格、涨跌幅和方向计算;板块报价只暴露设计中确认的强弱字段。
- [ ] 运行 mapper 定向测试并确认通过。
### Task 4:实现详情 Service、Controller 和 OpenAPI DTO
**Files:**
- Create: `server/apps/api/src/market-overview/detail/market-detail.service.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.controller.ts`
- Create: `server/apps/api/src/market-overview/detail/market-detail.service.test.ts`
- Modify: `server/apps/api/src/market-overview/market-overview.module.ts`
**Interfaces:**
- `MarketDetailService.getIndexDetail(code: string, period: DetailPeriod, count: number): Promise<IndexDetailResponseDto>`
- `MarketDetailService.getSectorDetail(code: string, period: DetailPeriod, count: number): Promise<SectorDetailResponseDto>`
- `GET /api/v1/market-overview/indices/:code/detail?period=1d&count=120`
- `GET /api/v1/market-overview/sectors/:code/detail?period=1d&count=120`
- [ ] 先写成功、非法代码、非法 period/count、TDX 超时、协议错误、空序列、部分字段缺失和延迟交易日测试。
- [ ] Controller 只接受白名单指数/一级行业代码、`period=1d` 和 `1..120` 的整数 count;非法参数由 Nest 标准 400 返回。
- [ ] Service 调用固定 `get_market_data`,不把方法名或原始 params 从 HTTP 传入;指数和板块使用独立定义表和 warning 文案。
- [ ] 以 `fresh/delayed/partial/unavailable` 生成元数据;详情失败只影响本次请求,不影响首页接口。
- [ ] 注册模块并生成 OpenAPI;运行 API 单元测试和 E2E 定向测试。
### Task 5:接入 Web 路由、Axios API 和共享详情页面
**Files:**
- Create: `web/src/api/market-overview/detail.ts`
- Create: `web/src/views/market-overview/detail/market-detail.ts`
- Create: `web/src/views/market-overview/detail/MarketDetailPage.vue`
- Create: `web/src/views/market-overview/detail/MarketDetailChart.vue`
- Create: `web/src/views/market-overview/detail/MarketDetailPage.test.ts`
- Create: `web/src/views/market-overview/detail/MarketDetailChart.test.ts`
- Modify: `web/src/app/router.ts`
- Modify: `web/src/views/market-overview/MarketOverviewPage.vue`
- Modify: `web/src/styles/index.scss`
**Interfaces:**
- `createMarketDetailApi(requestClient): { getIndexDetail(code, params), getSectorDetail(code, params) }`
- `MarketDetailPage` consumes route `type/code`, calls only the Axios API wrapper, and renders `instrument/quote/series/availableViews`.
- [ ] 先写路由参数、加载、空、错误、延迟、上海时区和 390px 视口测试。
- [ ] 实现页面固定结构:返回概览、名称/代码/来源/更新时间、核心行情、K 线、数据质量和事实型复盘摘要。
- [ ] 实现轻量 SVG 图表,固定容器尺寸,点数多于 120 时不额外扩张;`intraday=false` 只显示边界说明。
- [ ] 修改概览点击事件使用 `router.push`,不在页面直接调用 Axios 或 TDX。
- [ ] 运行 Web 定向测试、typecheck 和生产构建;确认移动端无横向溢出。
### Task 6:契约、全量验证与 Review
**Files:**
- Modify: `server/openapi/openapi.json`
- Modify: `web/src/api/generated/schema.ts`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/test-plan.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/implementation-plan.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/development-summary.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/code-review.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/review/standards-review.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/test-results.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/qa/visual-qa.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/after.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/detected-changes.md`
- Create: `docs/features/FEAT-MO-004-index-sector-detail/gitnexus/comparison.md`
- [ ] 运行 `npm run check:contracts`,确认后端 DTO、OpenAPI 和 Web 类型一致。
- [ ] 运行定向测试、`npm run verify`、开发服务器 Playwright 桌面/390px 截图和非空图表检查。
- [ ] 执行代码 Review、开发规范符合性 Review,记录发现、处置和残余风险。
- [ ] 运行 `npm run governance:finish -- FEAT-MO-004 index-sector-detail`,刷新 GitNexus 并保存前后对比。
- [ ] 运行 `gitnexus_detect_changes`,确认只影响详情模块、路由、契约和预期文档。
- [ ] 使用中文 Conventional Commit 提交并立即推送项目 Gitea;推送失败不得标记完成。
## 计划自检
- 设计覆盖:指数/板块双切片、TDX 能力门、状态降级、路由、契约、时区、安全、性能、测试、回退均有对应任务。
- 占位检查:没有以 TBD/TODO 或“适当处理”代替具体行为;分时未验证时明确返回 `intraday=false`。
- 类型一致性:Service 参数、DTO 字段、Web API 方法和页面消费字段与设计文档一致。
@@ -0,0 +1,80 @@
import { BadRequestException, Controller, Get, Inject, Param, Query } from '@nestjs/common';
import { ApiOkResponse, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
import {
IndexDetailResponseDto,
SectorDetailResponseDto,
type DetailPeriod,
} from './market-detail.dto';
import { MarketDetailService } from './market-detail.service';
type DetailQuery = { period?: string; count?: string };
function parseDetailQuery(query: DetailQuery): { period: DetailPeriod; count: number } {
const period = query.period ?? '1d';
if (period !== '1d') throw new BadRequestException('只支持 1d 周期');
const count = query.count === undefined ? 120 : Number(query.count);
if (
!/^\d+$/.test(query.count ?? '120') ||
!Number.isSafeInteger(count) ||
count < 1 ||
count > 120
) {
throw new BadRequestException('count 必须是 1 到 120 的整数');
}
return { period, count };
}
function assertCodeShape(code: string): void {
if (!/^\d{6}\.(SH|SZ|BJ)$/.test(code)) {
throw new BadRequestException('代码格式不可用');
}
}
@ApiTags('market-overview-detail')
@Controller('market-overview')
export class MarketDetailController {
constructor(@Inject(MarketDetailService) private readonly service: MarketDetailService) {}
@Get('indices/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的指数代码', example: '000001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: IndexDetailResponseDto })
getIndexDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<IndexDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getIndexDetail(code, parsed.period, parsed.count);
}
@Get('sectors/:code/detail')
@ApiParam({ name: 'code', description: '带市场后缀的一级行业代码', example: '881001.SH' })
@ApiQuery({ name: 'period', required: false, enum: ['1d'], example: '1d' })
@ApiQuery({
name: 'count',
required: false,
type: Number,
minimum: 1,
maximum: 120,
example: 120,
})
@ApiOkResponse({ type: SectorDetailResponseDto })
getSectorDetail(
@Param('code') code: string,
@Query() query: DetailQuery,
): Promise<SectorDetailResponseDto> {
assertCodeShape(code);
const parsed = parseDetailQuery(query);
return this.service.getSectorDetail(code, parsed.period, parsed.count);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { MarketOverviewMetaDto, type QuoteDirection } from '../market-overview.dto';
export type DetailInstrumentType = 'index' | 'sector';
export type DetailPeriod = '1d';
export class DetailInstrumentDto {
@ApiProperty({ type: String })
code!: string;
@ApiProperty({ type: String })
name!: string;
@ApiProperty({ enum: ['index', 'sector'] })
type!: DetailInstrumentType;
}
export class DetailQuoteDto {
@ApiProperty({ type: Number })
price!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent!: number | null;
@ApiProperty({ enum: ['up', 'down', 'flat'] })
direction!: QuoteDirection;
@ApiProperty({ type: String, example: '20260818' })
tradeDate!: string;
}
export class DetailSeriesPointDto {
@ApiProperty({ type: String, example: '20260818' })
time!: string;
@ApiProperty({ type: Number })
open!: number;
@ApiProperty({ type: Number })
high!: number;
@ApiProperty({ type: Number })
low!: number;
@ApiProperty({ type: Number })
close!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
amount!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
volume!: number | null;
}
export class DetailAvailableViewsDto {
@ApiProperty({ type: Boolean })
kline!: boolean;
@ApiProperty({ type: Boolean })
intraday!: boolean;
}
export class IndexDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class SectorDetailDataDto {
@ApiProperty({ type: () => DetailInstrumentDto })
instrument!: DetailInstrumentDto;
@ApiProperty({ type: () => DetailQuoteDto })
quote!: DetailQuoteDto;
@ApiProperty({ type: [DetailSeriesPointDto] })
series!: DetailSeriesPointDto[];
@ApiProperty({ type: () => DetailAvailableViewsDto })
availableViews!: DetailAvailableViewsDto;
}
export class IndexDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => IndexDetailDataDto, nullable: true })
data!: IndexDetailDataDto | null;
}
export class SectorDetailResponseDto extends MarketOverviewMetaDto {
@ApiProperty({ type: () => SectorDetailDataDto, nullable: true })
data!: SectorDetailDataDto | null;
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { TdxKlineSeries } from '../tdx/tdx.types';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
function createSeries(overrides: Partial<TdxKlineSeries> = {}): TdxKlineSeries {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情 K 线映射', () => {
it('按交易日期升序返回有效点并保留可空成交字段', () => {
const series = createSeries({
Date: ['20260818', '20260817', 'bad-date'],
Open: ['102', '100', '1'],
High: ['106', '103', '1'],
Low: ['101', '99', '1'],
Close: ['105', '102', '1'],
Amount: ['', '1000', '1'],
Volume: ['120', '100', '1'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
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: null,
volume: 120,
},
]);
});
it('过滤价格字段缺失或高低价不自洽的行', () => {
const series = createSeries({
Date: ['20260816', '20260817', '20260818'],
Open: ['100', '', '102'],
High: ['103', '104', '101'],
Low: ['99', '100', '100'],
Close: ['102', '103', '105'],
Amount: ['900', '1000', '1200'],
Volume: ['90', '100', '120'],
});
expect(mapDetailSeries(series, 120)).toEqual([
{
time: '20260816',
open: 100,
high: 103,
low: 99,
close: 102,
amount: 900,
volume: 90,
},
]);
});
it('只保留请求数量内的最近交易日并把数量限制在 120', () => {
const dates = Array.from({ length: 130 }, (_, index) => {
const date = new Date(Date.UTC(2026, 0, index + 1));
return date.toISOString().slice(0, 10).replaceAll('-', '');
});
const values = dates.map((_, index) => String(index + 1));
const series = createSeries({
Date: dates,
Open: values,
High: values,
Low: values,
Close: values,
Amount: values,
Volume: values,
});
const points = mapDetailSeries(series, 999);
expect(points).toHaveLength(120);
expect(points[0]?.time).toBe('20260111');
expect(points.at(-1)?.time).toBe('20260510');
});
it('使用最后两个有效交易日计算最新报价和涨跌幅', () => {
expect(
mapDetailQuote(createSeries(), {
code: '000001.SH',
name: '上证指数',
type: 'index',
}),
).toEqual({
price: 105,
changePercent: 2.94,
direction: 'up',
tradeDate: '20260818',
});
});
it('没有相邻有效收盘价时保留价格但将涨跌幅置空', () => {
expect(
mapDetailQuote(createSeries({ Close: ['0', '105'] }), {
code: '881001.SH',
name: 'A股板块',
type: 'sector',
}),
).toEqual({
price: 105,
changePercent: null,
direction: 'flat',
tradeDate: '20260818',
});
});
});
@@ -0,0 +1,111 @@
import type { QuoteDirection } from '../market-overview.dto';
import type {
DetailInstrumentType,
DetailQuoteDto,
DetailSeriesPointDto,
} from './market-detail.dto';
import type { IndexDefinition, TdxKlineSeries } from '../tdx/tdx.types';
export type DetailInstrumentDefinition = IndexDefinition & { type: DetailInstrumentType };
const MAX_DETAIL_POINTS = 120;
function toNumber(value: string | undefined): number | null {
if (value === undefined || value.trim() === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function toOptionalNonNegativeNumber(value: string | undefined): number | null {
const parsed = toNumber(value);
return parsed !== null && parsed >= 0 ? parsed : null;
}
function toTradeDate(value: string | undefined): string | null {
if (!value || !/^\d{8}$/.test(value)) return null;
const year = Number(value.slice(0, 4));
const month = Number(value.slice(4, 6));
const day = Number(value.slice(6, 8));
const date = new Date(0);
date.setUTCHours(0, 0, 0, 0);
date.setUTCFullYear(year, month - 1, day);
return year >= 1000 &&
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
? value
: null;
}
function isValidOhlc(open: number, high: number, low: number, close: number): boolean {
return high >= Math.max(open, close, low) && low <= Math.min(open, close, high);
}
function mapPoint(series: TdxKlineSeries, index: number): DetailSeriesPointDto | null {
const time = toTradeDate(series.Date[index]);
const open = toNumber(series.Open[index]);
const high = toNumber(series.High[index]);
const low = toNumber(series.Low[index]);
const close = toNumber(series.Close[index]);
if (time === null || open === null || high === null || low === null || close === null) {
return null;
}
if (!isValidOhlc(open, high, low, close)) return null;
return {
time,
open,
high,
low,
close,
amount: toOptionalNonNegativeNumber(series.Amount[index]),
volume: toOptionalNonNegativeNumber(series.Volume[index]),
};
}
/** 只映射详情图表需要的字段,过滤坏点并按交易日稳定排序,防止原始数组污染页面。 */
export function mapDetailSeries(series: TdxKlineSeries, count: number): DetailSeriesPointDto[] {
const length = Math.min(
series.Date.length,
series.Open.length,
series.High.length,
series.Low.length,
series.Close.length,
);
const byDate = new Map<string, DetailSeriesPointDto>();
for (let index = 0; index < length; index += 1) {
const point = mapPoint(series, index);
if (point) byDate.set(point.time, point);
}
const safeCount = Number.isSafeInteger(count)
? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS)
: MAX_DETAIL_POINTS;
return [...byDate.values()]
.sort((left, right) => left.time.localeCompare(right.time))
.slice(-safeCount);
}
function mapDirection(changePercent: number | null): QuoteDirection {
if (changePercent === null || changePercent === 0) return 'flat';
return changePercent > 0 ? 'up' : 'down';
}
/** 报价使用最后两个有效交易日;上一收盘为零时不计算涨跌幅,避免无意义的无穷值。 */
export function mapDetailQuote(
series: TdxKlineSeries,
_definition: DetailInstrumentDefinition,
): DetailQuoteDto | null {
const points = mapDetailSeries(series, MAX_DETAIL_POINTS);
const latest = points.at(-1);
if (!latest) return null;
const previous = points.at(-2);
const changePercent =
previous && previous.close > 0
? Math.round(((latest.close - previous.close) / previous.close) * 100 * 100) / 100
: null;
return {
price: latest.close,
changePercent,
direction: mapDirection(changePercent),
tradeDate: latest.time,
};
}
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TdxClientError } from '../tdx/tdx.client';
import type { TdxClient } from '../tdx/tdx.client';
import { MarketDetailService } from './market-detail.service';
function createKline(overrides: Record<string, string[]> = {}) {
return {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
...overrides,
};
}
describe('市场详情服务', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T03:00:00.000Z'));
});
it('返回指数详情并限制 TDX 请求为固定周期和数量', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline() },
}),
};
const service = new MarketDetailService(client);
const response = await service.getIndexDetail('000001.SH', '1d', 120);
expect(response).toMatchObject({
source: 'tdx',
status: 'fresh',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
quote: { price: 105, changePercent: 2.94, tradeDate: '20260818' },
availableViews: { kline: true, intraday: false },
},
});
expect(client.request).toHaveBeenCalledWith('get_market_data', {
stock_list: ['000001.SH'],
count: 120,
dividend_type: 'none',
period: '1d',
});
});
it('未知指数代码不触发 TDX 请求并返回不可用状态', async () => {
const client: TdxClient = { request: vi.fn() };
const response = await new MarketDetailService(client).getIndexDetail('999999.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数代码不可用']);
expect(client.request).not.toHaveBeenCalled();
});
it('先校验一级行业目录,再读取板块详情', async () => {
const client: TdxClient = {
request: vi
.fn()
.mockResolvedValueOnce({ ErrorId: '0', Value: [{ Code: '881001.SH', Name: '行业总览' }] })
.mockResolvedValueOnce({ ErrorId: '0', Value: { '881001.SH': createKline() } }),
};
const response = await new MarketDetailService(client).getSectorDetail('881001.SH', '1d', 60);
expect(response).toMatchObject({
status: 'fresh',
data: { instrument: { code: '881001.SH', name: '行业总览', type: 'sector' } },
});
expect(client.request).toHaveBeenNthCalledWith(1, 'get_stock_list', {
market: '16',
list_type: 1,
});
expect(client.request).toHaveBeenNthCalledWith(2, 'get_market_data', {
stock_list: ['881001.SH'],
count: 60,
dividend_type: 'none',
period: '1d',
});
});
it('交易日期不是上海当前日期时标记 delayed 并保留数据', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: { '000001.SH': createKline({ Date: ['20260815', '20260817'] }) },
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 10);
expect(response.status).toBe('delayed');
expect(response.warnings).toEqual(['指数交易日期为 20260817,非当前日期']);
expect(response.data?.series).toHaveLength(2);
});
it('必填字段坏点会保留有效 K 线并标记 partial', async () => {
const client: TdxClient = {
request: vi.fn().mockResolvedValue({
ErrorId: '0',
Value: {
'000001.SH': createKline({ Open: ['100', ''], Close: ['102', '105'] }),
},
}),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('partial');
expect(response.data?.series).toHaveLength(1);
expect(response.warnings).toEqual(['指数部分 K 线字段不可用']);
});
it('TDX 超时只返回固定脱敏文案', async () => {
const client: TdxClient = {
request: vi.fn().mockRejectedValue(new TdxClientError('timeout', '原始超时细节')),
};
const response = await new MarketDetailService(client).getIndexDetail('000001.SH', '1d', 120);
expect(response.status).toBe('unavailable');
expect(response.data).toBeNull();
expect(response.warnings).toEqual(['指数请求超时']);
expect(JSON.stringify(response)).not.toContain('原始超时细节');
});
});
@@ -0,0 +1,169 @@
import { Inject, Injectable } from '@nestjs/common';
import type { TdxClient } from '../tdx/tdx.client';
import { TdxClientError } from '../tdx/tdx.client';
import {
CORE_INDEXES,
type IndexDefinition,
type TdxKlineSeries,
type TdxMarketDataResult,
type TdxStockListResult,
} from '../tdx/tdx.types';
import { TDX_CLIENT_PORT, type MarketOverviewResponse } from '../market-overview.service';
import type { DetailInstrumentDefinition } from './market-detail.mapper';
import { mapDetailQuote, mapDetailSeries } from './market-detail.mapper';
import type {
DetailPeriod,
IndexDetailDataDto,
IndexDetailResponseDto,
SectorDetailDataDto,
SectorDetailResponseDto,
} from './market-detail.dto';
const MAX_DETAIL_POINTS = 120;
function createSnapshotMeta(): Pick<
MarketOverviewResponse<never>,
'snapshotId' | 'asOf' | 'source'
> {
const asOf = new Date().toISOString();
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
return { snapshotId, asOf, source: 'tdx' };
}
function getShanghaiCalendarDate(asOf: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(asOf));
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}${values.month}${values.day}`;
}
function normalizeCount(count: number): number {
return Number.isSafeInteger(count) ? Math.min(Math.max(count, 1), MAX_DETAIL_POINTS) : 120;
}
function createTradeDateWarning(subject: string, tradeDate: string, asOf: string): string | null {
return tradeDate === getShanghaiCalendarDate(asOf)
? null
: `${subject}交易日期为 ${tradeDate},非当前日期`;
}
function getSeries(result: TdxMarketDataResult, code: string): TdxKlineSeries | null {
const series = result.Value?.[code];
return series && typeof series === 'object' ? series : null;
}
@Injectable()
export class MarketDetailService {
constructor(@Inject(TDX_CLIENT_PORT) private readonly tdxClient: TdxClient) {}
async getIndexDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<IndexDetailResponseDto> {
const definition = CORE_INDEXES.find((item) => item.code === code);
if (!definition || period !== '1d') {
const meta = createSnapshotMeta();
return { ...meta, status: 'unavailable', data: null, warnings: ['指数代码不可用'] };
}
return this.loadDetail({ ...definition, type: 'index' }, period, count, '指数');
}
async getSectorDetail(
code: string,
period: DetailPeriod,
count: number,
): Promise<SectorDetailResponseDto> {
const meta = createSnapshotMeta();
if (period !== '1d')
return { ...meta, status: 'unavailable', data: null, warnings: ['板块查询周期不可用'] };
try {
const catalog = await this.tdxClient.request<TdxStockListResult>('get_stock_list', {
market: '16',
list_type: 1,
});
const definition = catalog.Value?.find((item) => item.Code === code);
if (!definition)
return { ...meta, status: 'unavailable', data: null, warnings: ['板块代码不可用'] };
return this.loadDetail(
{ code: definition.Code, name: definition.Name, type: 'sector' },
period,
count,
'板块',
meta,
);
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, '板块')],
};
}
}
private async loadDetail<TData extends IndexDetailDataDto | SectorDetailDataDto>(
definition: DetailInstrumentDefinition,
period: DetailPeriod,
count: number,
subject: '指数' | '板块',
inheritedMeta?: Pick<MarketOverviewResponse<never>, 'snapshotId' | 'asOf' | 'source'>,
): Promise<MarketOverviewResponse<TData>> {
const meta = inheritedMeta ?? createSnapshotMeta();
const safeCount = normalizeCount(count);
try {
const result = await this.tdxClient.request<TdxMarketDataResult>('get_market_data', {
stock_list: [definition.code],
count: safeCount,
dividend_type: 'none',
period,
});
const rawSeries = getSeries(result, definition.code);
const series = rawSeries ? mapDetailSeries(rawSeries, safeCount) : [];
const quote = rawSeries ? mapDetailQuote(rawSeries, definition) : null;
if (!rawSeries || !series.length || !quote) {
return { ...meta, status: 'unavailable', data: null, warnings: [`${subject}行情不可用`] };
}
const expectedRows = Math.min(safeCount, rawSeries.Date.length);
const isPartial = series.length < expectedRows;
const tradeDateWarning = createTradeDateWarning(subject, quote.tradeDate, meta.asOf);
const status = isPartial ? 'partial' : tradeDateWarning ? 'delayed' : 'fresh';
const data = {
instrument: { code: definition.code, name: definition.name, type: definition.type },
quote,
series,
// 分时候选协议尚未通过能力探测,禁止在页面伪造该视图。
availableViews: { kline: true, intraday: false },
} as TData;
return {
...meta,
status,
data,
warnings: isPartial
? [`${subject}部分 K 线字段不可用`]
: tradeDateWarning
? [tradeDateWarning]
: [],
};
} catch (error) {
return {
...meta,
status: 'unavailable',
data: null,
warnings: [this.toWarning(error, subject)],
};
}
}
private toWarning(error: unknown, subject: string): string {
if (error instanceof TdxClientError && error.kind === 'timeout') return `${subject}请求超时`;
if (error instanceof TdxClientError && error.kind === 'network') return `${subject}网络不可用`;
return `${subject}数据不可用`;
}
}
@@ -7,6 +7,8 @@ import { ShortTermMarketController } from './short-term.controller';
import { AKTOOLS_CLIENT_PORT, ShortTermMarketService } from './short-term.service';
import { createAktoolsClient } from './aktools/aktools.client';
import { createTdxClient } from './tdx/tdx.client';
import { MarketDetailController } from './detail/market-detail.controller';
import { MarketDetailService } from './detail/market-detail.service';
@Module({})
export class MarketOverviewModule {
@@ -14,9 +16,10 @@ export class MarketOverviewModule {
static register(config: ApiRuntimeConfig): DynamicModule {
return {
module: MarketOverviewModule,
controllers: [MarketOverviewController, ShortTermMarketController],
controllers: [MarketOverviewController, MarketDetailController, ShortTermMarketController],
providers: [
MarketOverviewService,
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: createTdxClient(config.tdx) },
ShortTermMarketService,
{ provide: AKTOOLS_CLIENT_PORT, useValue: createAktoolsClient(config.aktools) },
@@ -0,0 +1,120 @@
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MarketDetailController } from '../src/market-overview/detail/market-detail.controller';
import { MarketDetailService } from '../src/market-overview/detail/market-detail.service';
import { TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service';
const indexResponse = {
ErrorId: '0',
Value: {
'000001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
};
describe('市场详情 HTTP API', () => {
let app: INestApplication | undefined;
afterEach(async () => {
await app?.close();
app = undefined;
});
it('返回指数详情并隐藏 TDX 原始字段', async () => {
const client = { request: vi.fn().mockResolvedValue(indexResponse) };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=2')
.expect(200);
expect(response.body).toMatchObject({
source: 'tdx',
data: {
instrument: { code: '000001.SH', name: '上证指数', type: 'index' },
series: [{ time: '20260817' }, { time: '20260818' }],
availableViews: { kline: true, intraday: false },
},
});
expect(JSON.stringify(response.body)).not.toContain('ErrorId');
});
it('拒绝越界数量和非法代码且不调用 TDX', async () => {
const client = { request: vi.fn() };
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/not-a-code/detail')
.expect(400);
await request(app.getHttpServer())
.get('/api/v1/market-overview/indices/000001.SH/detail?count=121')
.expect(400);
expect(client.request).not.toHaveBeenCalled();
});
it('板块详情先读目录再返回行业 K 线', async () => {
const client = {
request: vi
.fn()
.mockResolvedValueOnce({
ErrorId: '0',
Value: [{ Code: '881001.SH', Name: '行业总览' }],
})
.mockResolvedValueOnce({
ErrorId: '0',
Value: {
'881001.SH': {
Date: ['20260817', '20260818'],
Open: ['100', '102'],
High: ['103', '106'],
Low: ['99', '101'],
Close: ['102', '105'],
Amount: ['1000', '1200'],
Volume: ['100', '120'],
},
},
}),
};
const moduleRef = await Test.createTestingModule({
controllers: [MarketDetailController],
providers: [MarketDetailService, { provide: TDX_CLIENT_PORT, useValue: client }],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
const response = await request(app.getHttpServer())
.get('/api/v1/market-overview/sectors/881001.SH/detail')
.expect(200);
expect(response.body.data.instrument).toEqual({
code: '881001.SH',
name: '行业总览',
type: 'sector',
});
expect(client.request).toHaveBeenCalledTimes(2);
});
});
+196
View File
@@ -40,6 +40,46 @@
"required": ["status", "detail"],
"type": "object"
},
"DetailAvailableViewsDto": {
"properties": {
"intraday": { "type": "boolean" },
"kline": { "type": "boolean" }
},
"required": ["kline", "intraday"],
"type": "object"
},
"DetailInstrumentDto": {
"properties": {
"code": { "type": "string" },
"name": { "type": "string" },
"type": { "enum": ["index", "sector"], "type": "string" }
},
"required": ["code", "name", "type"],
"type": "object"
},
"DetailQuoteDto": {
"properties": {
"changePercent": { "nullable": true, "type": "number" },
"direction": { "enum": ["up", "down", "flat"], "type": "string" },
"price": { "type": "number" },
"tradeDate": { "example": "20260818", "type": "string" }
},
"required": ["price", "direction", "tradeDate"],
"type": "object"
},
"DetailSeriesPointDto": {
"properties": {
"amount": { "nullable": true, "type": "number" },
"close": { "type": "number" },
"high": { "type": "number" },
"low": { "type": "number" },
"open": { "type": "number" },
"time": { "example": "20260818", "type": "string" },
"volume": { "nullable": true, "type": "number" }
},
"required": ["time", "open", "high", "low", "close"],
"type": "object"
},
"FundDataDto": {
"properties": {
"changePercent": { "type": "number" },
@@ -101,6 +141,40 @@
"required": ["status", "process", "checkedAt", "dependencies"],
"type": "object"
},
"IndexDetailDataDto": {
"properties": {
"availableViews": {
"$ref": "#/components/schemas/DetailAvailableViewsDto"
},
"instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" },
"quote": { "$ref": "#/components/schemas/DetailQuoteDto" },
"series": {
"items": { "$ref": "#/components/schemas/DetailSeriesPointDto" },
"type": "array"
}
},
"required": ["instrument", "quote", "series", "availableViews"],
"type": "object"
},
"IndexDetailResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/IndexDetailDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"IndexQuoteDto": {
"properties": {
"changePercent": { "nullable": true, "type": "number" },
@@ -152,6 +226,40 @@
"required": ["items"],
"type": "object"
},
"SectorDetailDataDto": {
"properties": {
"availableViews": {
"$ref": "#/components/schemas/DetailAvailableViewsDto"
},
"instrument": { "$ref": "#/components/schemas/DetailInstrumentDto" },
"quote": { "$ref": "#/components/schemas/DetailQuoteDto" },
"series": {
"items": { "$ref": "#/components/schemas/DetailSeriesPointDto" },
"type": "array"
}
},
"required": ["instrument", "quote", "series", "availableViews"],
"type": "object"
},
"SectorDetailResponseDto": {
"properties": {
"asOf": { "format": "date-time", "type": "string" },
"data": {
"allOf": [{ "$ref": "#/components/schemas/SectorDetailDataDto" }],
"nullable": true,
"type": "object"
},
"snapshotId": { "type": "string" },
"source": { "enum": ["tdx"], "type": "string" },
"status": {
"enum": ["fresh", "delayed", "partial", "unavailable"],
"type": "string"
},
"warnings": { "items": { "type": "string" }, "type": "array" }
},
"required": ["snapshotId", "asOf", "source", "status", "warnings", "data"],
"type": "object"
},
"SectorQuoteDto": {
"properties": {
"changePercent": { "type": "number" },
@@ -401,6 +509,50 @@
"tags": ["market-overview"]
}
},
"/api/v1/market-overview/indices/{code}/detail": {
"get": {
"operationId": "MarketDetailController_getIndexDetail",
"parameters": [
{
"in": "query",
"name": "count",
"required": false,
"schema": {
"example": 120,
"maximum": 120,
"minimum": 1,
"type": "number"
}
},
{
"in": "query",
"name": "period",
"required": false,
"schema": { "enum": ["1d"], "type": "string" }
},
{
"description": "带市场后缀的指数代码",
"in": "path",
"name": "code",
"required": true,
"schema": { "example": "000001.SH" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IndexDetailResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-detail"]
}
},
"/api/v1/market-overview/sectors": {
"get": {
"operationId": "MarketOverviewController_getSectors",
@@ -418,6 +570,50 @@
"tags": ["market-overview"]
}
},
"/api/v1/market-overview/sectors/{code}/detail": {
"get": {
"operationId": "MarketDetailController_getSectorDetail",
"parameters": [
{
"in": "query",
"name": "count",
"required": false,
"schema": {
"example": 120,
"maximum": 120,
"minimum": 1,
"type": "number"
}
},
{
"in": "query",
"name": "period",
"required": false,
"schema": { "enum": ["1d"], "type": "string" }
},
{
"description": "带市场后缀的一级行业代码",
"in": "path",
"name": "code",
"required": true,
"schema": { "example": "881001.SH" }
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SectorDetailResponseDto"
}
}
},
"description": ""
}
},
"tags": ["market-overview-detail"]
}
},
"/api/v1/market-overview/short-term/conclusion": {
"get": {
"operationId": "ShortTermMarketController_getConclusion",
+9 -1
View File
@@ -9,6 +9,7 @@ import { format } from 'prettier';
import { HealthController } from '../apps/api/src/health.controller';
import { MarketOverviewController } from '../apps/api/src/market-overview/market-overview.controller';
import { MarketDetailController } from '../apps/api/src/market-overview/detail/market-detail.controller';
import { ShortTermMarketController } from '../apps/api/src/market-overview/short-term.controller';
import {
HealthService,
@@ -19,6 +20,7 @@ import {
MarketOverviewService,
TDX_CLIENT_PORT,
} from '../apps/api/src/market-overview/market-overview.service';
import { MarketDetailService } from '../apps/api/src/market-overview/detail/market-detail.service';
import { createAktoolsClient } from '../apps/api/src/market-overview/aktools/aktools.client';
import {
AKTOOLS_CLIENT_PORT,
@@ -27,10 +29,16 @@ import {
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
@Module({
controllers: [HealthController, MarketOverviewController, ShortTermMarketController],
controllers: [
HealthController,
MarketOverviewController,
MarketDetailController,
ShortTermMarketController,
],
providers: [
HealthService,
MarketOverviewService,
MarketDetailService,
{ provide: TDX_CLIENT_PORT, useValue: { request: async () => ({}) } },
ShortTermMarketService,
{
+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];
}