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