feat(FEAT-MO-001): 完成TDX市场概览首屏
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
# 设计
|
# 设计
|
||||||
|
|
||||||
首屏顺序为:市场结论、驱动与风险、核心指数、市场宽度与量能、板块概览、情绪与资金摘要。分析偏好保存在浏览器本地,页面只显示轻量的当前状态入口。
|
首屏顺序为:市场结论、驱动与风险、核心指数、市场宽度与量能、板块概览、资金趋势。分析偏好保存在浏览器本地,页面显示轻量的当前状态入口。Vue 不直连 TDX,由 Nest API 按模块拆分接口并隔离失败。
|
||||||
|
|
||||||
|
## 数据可用性
|
||||||
|
|
||||||
|
- `summary` 和 `indices`:使用 TDX K 线真实响应,展示来源、快照时间和指数涨跌。
|
||||||
|
- `breadth`、`sectors`、`funds`:桥接层暂未注册对应方法,使用 `unavailable` 响应和明确原因。
|
||||||
|
- 页面不把不可用指标降级为模拟值;后续补齐桥接方法时只替换后端映射,不改变前端契约。
|
||||||
|
|
||||||
|
## 运行边界
|
||||||
|
|
||||||
|
本功能只读调用 TDX。MySQL、Redis、RocketMQ 仍为已规划基础设施,但当前没有业务状态、缓存或异步任务需要它们,因此本切片不启用。
|
||||||
|
|
||||||
|
## 安全策略与评估
|
||||||
|
|
||||||
|
- TDX 地址在配置层固定为 `http://127.0.0.1:17710`,API 是唯一访问边界,Vue 不直连行情服务。
|
||||||
|
- 请求只允许固定方法和公开行情参数;客户端限制超时与 1 MiB 响应,异常脱敏,不记录原始载荷、凭据或网络地址。
|
||||||
|
- 本功能不提供交易写入、账户操作、用户输入拼接查询或 AI 密钥下发;MySQL/Redis/RocketMQ 未启用,不读取生产数据库。
|
||||||
|
- 已通过客户端、服务、HTTP/OpenAPI、边界和 E2E 测试;生产授权、长期断线恢复和公网安全仍需后续评估。
|
||||||
|
|
||||||
|
## 性能目标与评估
|
||||||
|
|
||||||
|
- 五个模块并行请求,单个 TDX 请求受配置超时保护,模块失败不阻断其他模块。
|
||||||
|
- 客户端限制响应大小,避免异常行情载荷占用过多内存;本切片不增加数据库、缓存或消息队列负载。
|
||||||
|
- 已完成构建、浏览器桌面/390px 移动端验证;未进行 7×24 采集、峰值并发和长期网络抖动压测。
|
||||||
|
|
||||||
ETF监控、两融数据、大盘云图和实时消息仍是独立一级菜单,不属于短线和中长线切换。
|
ETF监控、两融数据、大盘云图和实时消息仍是独立一级菜单,不属于短线和中长线切换。
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# FEAT-MO-001 开发总结
|
||||||
|
|
||||||
|
## 原始开发需求
|
||||||
|
|
||||||
|
实现市场概览首屏:结论优先展示市场状态、驱动与风险,并支持综合、短线、中长线视角和规则、AI、混合、对照分析选择;用户可从指数和板块入口进入后续详细分析。
|
||||||
|
|
||||||
|
## 需求与范围变化
|
||||||
|
|
||||||
|
- 数据源从早期“确定性模拟快照”调整为 TDX 只读直连,Nest API 访问本机 `127.0.0.1:17710`,Vue 不直接访问 TDX。
|
||||||
|
- API 按摘要、指数、市场宽度、板块和资金拆分,避免把整个首屏塞入单个接口。
|
||||||
|
- 当前 TDX 桥接层仅确认 `get_market_data` 可用;宽度、板块和资金方法未注册,因此对应模块明确返回 `unavailable`。
|
||||||
|
- MySQL、Redis、RocketMQ 和 AI 仍不进入本切片运行链路;它们保留为后续基础设施边界。
|
||||||
|
|
||||||
|
## 实际完成需求
|
||||||
|
|
||||||
|
- 新增 TDX 配置校验、超时/响应大小限制、错误脱敏的 Node 客户端。
|
||||||
|
- 新增市场概览 DTO、服务、控制器、OpenAPI 契约和前端 Axios API 文件。
|
||||||
|
- 完成结论、驱动与风险、核心指数、市场宽度、板块、资金趋势模块,以及加载/错误/不可用状态。
|
||||||
|
- 完成分析视角和分析引擎切换,本地保存用户偏好。
|
||||||
|
- 真实 TDX 指数和摘要已通过本机隧道冒烟;未用指数或离线样本伪造缺失指标。
|
||||||
|
|
||||||
|
## 修复缺陷
|
||||||
|
|
||||||
|
- 修正 PRD 和实施计划中仍描述“模拟数据”的旧表述。
|
||||||
|
- 避免单个 TDX 模块失败导致整页失败,改为五个模块并行、独立状态。
|
||||||
|
- 对未注册桥接方法返回结构化原因,避免前端显示“实时”或空白数据。
|
||||||
|
- 修复 Vue TypeScript 单文件组件未被 ESLint 正确解析的问题,并为 E2E 测试进程补齐必需的本机 TDX 配置边界。
|
||||||
|
- 隔离应用外壳单测中的真实 Axios 请求,消除 JSDOM 网络错误噪声,保持页面测试只验证外壳职责。
|
||||||
|
|
||||||
|
## 未完成与延期项
|
||||||
|
|
||||||
|
- TDX 桥接层补齐市场宽度、板块排行、资金趋势方法后再实现真实图表和口径。
|
||||||
|
- “查看依据”抽屉、指数/板块详情页、AI 请求和用户体系另立功能。
|
||||||
|
- 本地验证不代表生产 7×24 采集稳定性、商业授权或公网部署安全性。
|
||||||
|
|
||||||
|
## 关键提交
|
||||||
|
|
||||||
|
- `9a965fa`、`d283ee3`、`6eb34b5`、`c2fa929`、`15a2500`、`27bc3c1`
|
||||||
|
- 首屏 UI 收口提交待 Review 通过后补充。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
- GitNexus 已完成最终重新索引;提交前 CLI `detect-changes --scope staged` 映射 11 条概览/E2E 执行流并标记 high。高风险来自本功能修改核心概览服务和测试启动入口,已由定向单测、HTTP/OpenAPI、构建和 E2E 覆盖;未发现额外业务模块调用方。MCP context 在当前 worktree 未初始化,因此以 CLI 索引和静态调用证据补充记录。
|
||||||
|
- API 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`。
|
||||||
|
- 前端单测、typecheck、build、后端 TDX/映射/服务/HTTP/OpenAPI 测试通过。
|
||||||
|
- `pnpm verify:e2e`:4/4 通过(桌面和移动端各覆盖页面与健康契约)。
|
||||||
|
- 浏览器桌面与 390px 移动视口验证无横向溢出,视角与引擎切换可用。
|
||||||
|
- 开发 Schema 按用户确认跳过,沿用 `v0.0.10 captured_empty` 追溯边界,不读取生产库。
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# GitNexus 开发后基线
|
||||||
|
|
||||||
|
## GitNexus 状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Repository: /Users/citrons/Documents/Codex/2026-08-12/wo-x/a-share-analysis/.worktrees/feat-mo-001-tdx-overview
|
||||||
|
Indexed: 8/18/2026, 3:16:44 PM
|
||||||
|
Indexed commit: 27bc3c1
|
||||||
|
Current commit: 27bc3c1
|
||||||
|
Status: ✅ up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 已跟踪变更摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
AGENTS.md | 2 +-
|
||||||
|
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 工作区摘要
|
||||||
|
|
||||||
|
```text
|
||||||
|
M AGENTS.md
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/design.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/development-summary.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/gitnexus/comparison.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/gitnexus/detected-changes.md
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/implementation-plan.md
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/manifest.yaml
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/prd.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/qa/test-results.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/qa/visual-qa.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/release-notes.md
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/requirements.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/review/code-review.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/review/standards-review.md
|
||||||
|
A docs/features/FEAT-MO-001-market-overview/rollback.md
|
||||||
|
M docs/features/FEAT-MO-001-market-overview/test-plan.md
|
||||||
|
M eslint.config.mjs
|
||||||
|
M server/apps/api/src/market-overview/market-overview.service.test.ts
|
||||||
|
M server/apps/api/src/market-overview/market-overview.service.ts
|
||||||
|
M server/apps/api/src/market-overview/tdx/tdx.client.test.ts
|
||||||
|
M server/apps/api/test/market-overview.e2e-spec.ts
|
||||||
|
M server/tools/export-openapi.ts
|
||||||
|
M server/tools/run-test-api.mjs
|
||||||
|
M web/src/api/market-overview/index.ts
|
||||||
|
M web/src/app/App.test.ts
|
||||||
|
M web/src/app/App.vue
|
||||||
|
M web/src/app/router.ts
|
||||||
|
M web/src/styles/index.scss
|
||||||
|
A web/src/views/market-overview/MarketOverviewPage.vue
|
||||||
|
A web/src/views/market-overview/components/FundTrend.vue
|
||||||
|
A web/src/views/market-overview/components/IndexOverview.vue
|
||||||
|
A web/src/views/market-overview/components/MarketBreadth.vue
|
||||||
|
A web/src/views/market-overview/components/MarketConclusion.vue
|
||||||
|
A web/src/views/market-overview/components/MarketDrivers.vue
|
||||||
|
A web/src/views/market-overview/components/SectorOverview.vue
|
||||||
|
M web/src/views/market-overview/useMarketOverview.test.ts
|
||||||
|
M web/src/views/market-overview/useMarketOverview.ts
|
||||||
|
M web/tests/smoke.spec.ts
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GitNexus 前后对比
|
||||||
|
|
||||||
|
## 开发前
|
||||||
|
|
||||||
|
- 基线:`gitnexus/before.md`、`gitnexus/implementation-baseline.md`。
|
||||||
|
- 目标是把模拟首屏改为 TDX 只读概览,并拆分五个 HTTP 模块接口。
|
||||||
|
|
||||||
|
## 开发后
|
||||||
|
|
||||||
|
- CLI 已完成最终重新索引,保留 77 条执行流并识别本功能影响路径。
|
||||||
|
- 变更检测识别 11 条本功能执行流,核心路径均有测试和 E2E 验证。
|
||||||
|
- 未新增消息、数据库、Redis、Scheduler、Worker 或部署运行时依赖。
|
||||||
|
|
||||||
|
## 差异结论
|
||||||
|
|
||||||
|
直接影响集中于 TDX API、市场概览服务、前端 API/页面和测试夹具;生产数据边界、基础设施暂不启用边界和回退路径保持不变。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# GitNexus 开发后变更检测
|
||||||
|
|
||||||
|
## 检测命令
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx gitnexus analyze
|
||||||
|
npx gitnexus detect-changes --repo "$PWD" --scope staged
|
||||||
|
```
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 索引:已完成最终增量更新。
|
||||||
|
- 暂存文件:38 个文件、87 个符号(包含完整功能档案)。
|
||||||
|
- 影响执行流:11 条,CLI 风险:high。
|
||||||
|
- 主要执行流:`GetSummary`、`GetBreadth`、`GetSectors`、`GetFunds` 和 `RunTestApi`。
|
||||||
|
|
||||||
|
## 处置
|
||||||
|
|
||||||
|
high 来自本功能新增并修改市场概览核心服务、共享快照元数据函数和 E2E 启动入口,不是未知跨业务扩散。已用 TDX 客户端/映射/服务/HTTP/OpenAPI 单测、边界测试、构建和 4/4 E2E 覆盖;未发现用户、消息、数据库或 Scheduler/Worker 业务调用方。MCP context 在当前 worktree 未初始化,未将其缺失误判为低风险。
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
# 实施计划
|
# 实施计划
|
||||||
|
|
||||||
1. 初始化 Vue 3、Vite、TypeScript和 Dart Sass。
|
1. ~~初始化 Vue 3、Vite、TypeScript 和 Dart Sass。~~ 已完成。
|
||||||
2. 建立市场快照类型和确定性模拟数据。
|
2. ~~建立 TDX 配置、Node fetch 客户端、DTO、映射和五个模块接口。~~ 已完成。
|
||||||
3. 实现终端外壳、数据状态和结论区。
|
3. ~~实现终端外壳、分模块加载状态和结论区。~~ 已完成。
|
||||||
4. 实现指数、宽度、量能、板块、情绪与资金模块。
|
4. ~~实现指数、宽度、板块和资金模块;不可用指标使用结构化状态。~~ 已完成。
|
||||||
5. 实现分析偏好菜单和依据抽屉。
|
5. ~~实现分析偏好菜单和本地持久化。~~ 已完成;依据抽屉和详情页入口延期至独立功能。
|
||||||
6. 完成测试、构建、浏览器验证和视觉对比。
|
6. ~~完成单元测试、类型检查、构建、接口冒烟、浏览器桌面/移动端视觉验证。~~ 已完成。
|
||||||
|
7. 补齐 TDX 桥接层的宽度、板块和资金方法后,再单独更新对应数据口径和图表需求。
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
id: FEAT-MO-001
|
id: FEAT-MO-001
|
||||||
name: 市场综合概览
|
name: 市场综合概览
|
||||||
slug: market-overview
|
slug: market-overview
|
||||||
status: approved
|
status: in_review
|
||||||
owner: project
|
owner: project
|
||||||
created_at: 2026-08-12
|
created_at: 2026-08-12
|
||||||
requirements: requirements.md
|
requirements: requirements.md
|
||||||
prd: prd.md
|
prd: prd.md
|
||||||
design: design.md
|
design: design.md
|
||||||
implementation_plan: implementation-plan.md
|
implementation_plan: implementation-plan.md
|
||||||
traceability_note: 开发尚未开始,GitNexus与QA证据将在开发门禁执行时创建
|
traceability_note: 已完成 TDX 直连开发、GitNexus 前后分析、接口冒烟和桌面/移动端视觉验证;待代码 Review、规范 Review 和 governance:finish 收口
|
||||||
release: unreleased
|
release: unreleased
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
## 非目标
|
## 非目标
|
||||||
|
|
||||||
- 不做自选股、持仓、笔记、交易计划、社区、荐股、跟单和公开组合。
|
- 不做自选股、持仓、笔记、交易计划、社区、荐股、跟单和公开组合。
|
||||||
- 本切片不接正式行情源、RocketMQ、MySQL、Redis和付费 AI。
|
- 本切片直接接入已验证的 TDX 只读桥接层;不接入 RocketMQ、MySQL、Redis 和付费 AI。
|
||||||
- 本切片不实现用户、邀请、会员、订阅和支付。
|
- 本切片不实现用户、邀请、会员、订阅和支付。
|
||||||
- ETF监控、两融数据、大盘云图和实时消息仍是独立一级模块。
|
- ETF监控、两融数据、大盘云图和实时消息仍是独立一级模块。
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
| 编号 | 优先级 | 需求 | 可观察结果 |
|
| 编号 | 优先级 | 需求 | 可观察结果 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FR-001 | P0 | 结论优先展示市场定性 | 首屏顶部出现一句结论、市场状态、量能状态和情绪状态 |
|
| FR-001 | P0 | 结论优先展示市场定性 | 首屏顶部出现一句结论、市场状态、量能状态和情绪状态 |
|
||||||
| FR-002 | P0 | 展示驱动与风险 | 结论下方并列显示至少三条驱动和三条风险证据 |
|
| FR-002 | P0 | 展示驱动与风险 | 结论下方并列显示已由 TDX 支持的驱动和风险证据;缺少口径时明确标记 |
|
||||||
| FR-003 | P0 | 展示数据新鲜度 | 页面显示交易状态、数据更新时间和正常/延迟/不完整状态 |
|
| FR-003 | P0 | 展示数据新鲜度 | 页面显示交易状态、数据更新时间和正常/延迟/不完整状态 |
|
||||||
| FR-004 | P0 | 提供分析偏好 | 可选择综合/短线/中长线和规则/AI/混合/对照,并在本地保存默认值 |
|
| FR-004 | P0 | 提供分析偏好 | 可选择综合/短线/中长线和规则/AI/混合/对照,并在本地保存默认值 |
|
||||||
| FR-005 | P1 | 展示市场证据模块 | 指数、宽度、量能、板块、情绪和资金摘要可阅读 |
|
| FR-005 | P1 | 展示市场证据模块 | 指数、宽度、量能、板块、情绪和资金摘要可阅读 |
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 数据与口径
|
## 数据与口径
|
||||||
|
|
||||||
首个切片使用确定性模拟快照。字段仍必须包含交易日期、快照时间、来源状态、完整性和单位,以便以后无结构变更地替换为后端 API。
|
首个切片由 Nest API 直接读取 TDX 只读桥接层。指数和摘要使用真实 TDX K 线映射;当前桥接层未提供市场宽度、板块排行和资金趋势方法,因此这些模块返回结构化 `unavailable`,不得用指数或离线样本冒充。所有响应仍包含交易日期、快照时间、来源状态、完整性和单位。
|
||||||
|
|
||||||
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百分比。历史数据和低频资金数据必须显示实际数据日期,旧数据不得标记为实时。
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
|
|||||||
- Given 页面加载有效快照,When 用户进入市场概览,Then 结论、驱动、风险和更新时间在首屏可见。
|
- Given 页面加载有效快照,When 用户进入市场概览,Then 结论、驱动、风险和更新时间在首屏可见。
|
||||||
- Given 用户打开分析状态入口,When 切换周期或引擎,Then 页面状态立即更新且可选择设为默认。
|
- Given 用户打开分析状态入口,When 切换周期或引擎,Then 页面状态立即更新且可选择设为默认。
|
||||||
- Given 数据快照被标记为延迟,When 页面渲染,Then 最近有效值保留并明确显示延迟,不能显示“实时”。
|
- Given 数据快照被标记为延迟,When 页面渲染,Then 最近有效值保留并明确显示延迟,不能显示“实时”。
|
||||||
- Given 用户点击查看依据,When 抽屉打开,Then 能看到指标名称、数值、口径和更新时间。
|
- Given 用户看到查看依据入口,When 后续依据抽屉功能立项,Then 能看到指标名称、数值、口径和更新时间;本切片只保留入口。
|
||||||
|
|
||||||
## 风险与依赖
|
## 风险与依赖
|
||||||
|
|
||||||
@@ -92,3 +92,4 @@ A 股使用红涨绿跌。成交额统一展示为亿元,比例明确使用百
|
|||||||
## 变更记录
|
## 变更记录
|
||||||
|
|
||||||
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
- 0.1:建立首版 PRD,补充结论优先、分析偏好和异常状态。
|
||||||
|
- 0.2:根据 TDX 实连验证修正数据源、可用性边界和未接入基础设施范围。
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# FEAT-MO-001 测试结果
|
||||||
|
|
||||||
|
## 自动化结果
|
||||||
|
|
||||||
|
- 前端 Vitest:7 个文件、14 个测试通过。
|
||||||
|
- 后端 Vitest/Node:11 个文件、40 个测试通过;Schema、治理、边界和部署门禁通过。
|
||||||
|
- `pnpm verify:e2e`:桌面/移动端页面和健康契约共 4 个测试通过。
|
||||||
|
- `pnpm typecheck`、`pnpm build`、`pnpm format:check`:通过。
|
||||||
|
- ESLint:0 error,保留 35 条 Vue 风格 warning,不阻断门禁。
|
||||||
|
|
||||||
|
## 手工结果
|
||||||
|
|
||||||
|
- `GET /api/v1/market-overview/indices` 和 `summary` 返回真实 TDX `fresh` 快照。
|
||||||
|
- `breadth`、`sectors`、`funds` 返回明确 `unavailable`,未伪造指标。
|
||||||
|
- 桌面默认视口与 390px 移动视口无横向溢出;视角和引擎切换可用。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# FEAT-MO-001 视觉 QA
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
- 桌面默认视口:顶部应用识别、结论区、驱动/风险、核心指数和不可用模块均可见。
|
||||||
|
- 移动视口:390×844,首屏按单列排列,标题、状态、指数和模块说明不遮挡。
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
- 通过:`document.documentElement.scrollWidth` 未超过视口宽度。
|
||||||
|
- 通过:综合、短线、中长线切换和规则/AI/混合/对照选择有可见状态。
|
||||||
|
- 通过:TDX 来源、更新时间和不可用原因可见,未把不可用模块渲染为空白。
|
||||||
|
|
||||||
|
## 残余项
|
||||||
|
|
||||||
|
依据抽屉、指数/板块详情页和真实图表尚未立项,不作为本次首屏视觉验收项。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# FEAT-MO-001 发布说明
|
||||||
|
|
||||||
|
## 本次发布
|
||||||
|
|
||||||
|
- 新增 TDX 直连的市场概览首屏和五个模块 API。
|
||||||
|
- 支持综合、短线、中长线视角与规则、AI、混合、对照偏好选择。
|
||||||
|
- 明确显示 TDX 来源、快照时间、缺失能力和模块级错误。
|
||||||
|
|
||||||
|
## 明确不包含
|
||||||
|
|
||||||
|
- 不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
|
- 不包含用户体系、详情页、依据抽屉、交易操作和荐股能力。
|
||||||
|
- 不包含板块、宽度和资金真实数据,直到 TDX 桥接层提供并验证对应方法。
|
||||||
@@ -2,4 +2,11 @@
|
|||||||
|
|
||||||
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
首屏为“市场概览”模块中的综合概览,采用结论优先。市场概览内部通过轻量分析状态入口切换综合、短线和中长线视角,并支持规则、AI、混合与对照分析。
|
||||||
|
|
||||||
当前切片仅开发 Vue 3 可交互首屏和本地模拟数据,不接用户体系和正式数据链路。
|
当前切片开发 Vue 3 可交互首屏,Nest API 通过 TDX 只读桥接层提供真实指数和摘要;市场宽度、板块和资金接口保留结构化不可用状态,不使用离线样本。不接用户体系,也不启用 MySQL、Redis、RocketMQ 或 AI 运行链路。
|
||||||
|
|
||||||
|
## 本次实际范围
|
||||||
|
|
||||||
|
- 已完成综合、短线、中长线视角切换,以及规则、AI、混合、对照偏好选择;偏好只保存于浏览器本地。
|
||||||
|
- 已完成按摘要、指数、宽度、板块、资金拆分的五个 HTTP 接口和前端 API 封装。
|
||||||
|
- 已完成 TDX 请求超时、响应大小限制、错误脱敏和模块级失败隔离。
|
||||||
|
- 详情页和“查看依据”抽屉只保留入口,另立需求实现,不在本次发布范围内。
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 代码 Review
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
覆盖 TDX 客户端、市场概览后端模块、OpenAPI/前端契约、Axios API 封装、Vue 首屏组件和对应测试。
|
||||||
|
|
||||||
|
## 检查结果
|
||||||
|
|
||||||
|
- 通过:API 按模块拆分,前端请求集中在 `web/src/api/market-overview/index.ts`,Vue 不直连 TDX。
|
||||||
|
- 通过:真实指数和摘要字段经过 DTO/映射;桥接层未提供的方法返回结构化 `unavailable`,没有模拟数据冒充。
|
||||||
|
- 通过:模块并行加载且独立处理异常;TDX 请求有固定回环地址、超时、响应大小限制和错误脱敏。
|
||||||
|
- 通过:未将密码、令牌、行情原始载荷或生产数据写入代码、文档和日志。
|
||||||
|
- 通过:E2E 测试夹具显式声明回环 TDX 配置但不要求 TDX 可达,避免测试环境绕过正式配置校验。
|
||||||
|
|
||||||
|
## 安全与残余风险
|
||||||
|
|
||||||
|
- TDX 只读边界固定为回环地址,客户端限制超时和响应大小并脱敏异常;未接入交易、AI 密钥、数据库写入或生产数据。
|
||||||
|
- 残余风险是 TDX 桥接方法覆盖不全、市场数据授权和生产 7×24 稳定性尚未验证,已在需求和回退说明中标明。
|
||||||
|
|
||||||
|
## GitNexus 与同步修改
|
||||||
|
|
||||||
|
开发前已保存 GitNexus 影响计划;修改共享运行配置时曾得到 HIGH,原因是配置文件被 API/Scheduler/Worker 共用,已通过移除通用安全摘要变更和定向测试收敛到 API 实际消费者。提交前 CLI reindex 后 `detect-changes --scope staged` 标记 high,具体为 11 条本功能概览/E2E 执行流;已核对核心服务、控制器、前端 API 和测试覆盖,未发现额外业务模块调用方。当前 worktree 的 MCP context 未初始化,已保留 CLI 和静态证据,不把该限制误报为低风险。
|
||||||
|
|
||||||
|
## 性能与资源影响
|
||||||
|
|
||||||
|
- 五个模块并行请求,单请求超时并限制 1 MiB 响应;不引入 MySQL、Redis、RocketMQ 在线负载。
|
||||||
|
- 未进行 7×24、断线重连和生产压测;TDX 宽度/板块/资金方法及详情页仍待独立功能。
|
||||||
|
- ESLint 对 Vue 单文件组件仅保留 35 条既有风格 warning,无 error;格式、类型和构建门禁均通过。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 开发规范符合性 Review
|
||||||
|
|
||||||
|
## 安全、性能与架构检查
|
||||||
|
|
||||||
|
- 已建立并更新 PRD、需求、设计、实施计划、测试计划、开发总结和 Review 文档。
|
||||||
|
- 已记录安全策略、性能目标、GitNexus 影响、验证证据、范围变化和残余风险。
|
||||||
|
- 后端按业务模块组织并使用 DTO/映射;前端请求通过统一 Axios 封装,业务 API 独立存放。
|
||||||
|
- 代码注释集中在配置边界、异常恢复和偏好存储等非显然逻辑,没有逐行噪声注释。
|
||||||
|
- 未启用 MySQL、Redis、RocketMQ;本切片没有伪造基础设施已接入,也没有触碰生产数据库。
|
||||||
|
- Git 提交和推送将在 Review 通过后按中文 Conventional Commit 执行;不修改 Git 全局配置。
|
||||||
|
|
||||||
|
## GitNexus 与开发总结检查
|
||||||
|
|
||||||
|
- 开发前影响计划、开发后索引、变更检测和风险处置已归档。
|
||||||
|
- 开发总结记录原始需求、范围变化、实际完成、修复缺陷、延期项、提交和验证证据。
|
||||||
|
- QA、发布说明和回退说明与本功能档案保持同一功能编号。
|
||||||
|
|
||||||
|
最终结论:passed
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# FEAT-MO-001 回退说明
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
- TDX 请求错误导致核心指数无法展示或页面加载阻断。
|
||||||
|
- 发现前端横向溢出、来源状态误标为实时,或不可用指标被伪造。
|
||||||
|
- 生产部署验证发现配置边界、权限或错误脱敏回归。
|
||||||
|
|
||||||
|
## 操作
|
||||||
|
|
||||||
|
1. 停止当前 API/Web systemd 服务,保留日志和失败快照摘要,不保存原始行情载荷。
|
||||||
|
2. 回退到上一个已推送的 Gitea 提交/标签,重新执行源码构建和 `pnpm verify:source`。
|
||||||
|
3. 恢复 TDX 桥接隧道后只做只读接口冒烟;确认通过前不得重新对外开放。
|
||||||
|
|
||||||
|
## 数据边界
|
||||||
|
|
||||||
|
本功能没有写入 MySQL、Redis 或 RocketMQ,也没有数据库迁移;回退不执行生产数据库操作。开发 Schema 继续遵循仅读取开发库的门禁。
|
||||||
@@ -3,6 +3,32 @@
|
|||||||
- 结论、驱动和风险在首屏可见。
|
- 结论、驱动和风险在首屏可见。
|
||||||
- 分析状态可以临时切换并保存默认值。
|
- 分析状态可以临时切换并保存默认值。
|
||||||
- 依据抽屉能够打开和关闭。
|
- 依据抽屉能够打开和关闭。
|
||||||
- 时间范围切换更新图表。
|
- 五个模块并行请求,一个模块失败不阻断其他模块。
|
||||||
|
- 真实 TDX 指数和摘要响应可映射,未提供的方法返回 `unavailable`,不伪造行情。
|
||||||
- 加载、部分数据、过期和错误状态具有明确文案。
|
- 加载、部分数据、过期和错误状态具有明确文案。
|
||||||
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
- 桌面和窄窗口不出现阻断核心内容的溢出。
|
||||||
|
|
||||||
|
## 已执行证据
|
||||||
|
|
||||||
|
- 前端 Vitest、typecheck、build:通过。
|
||||||
|
- 后端 TDX 客户端、映射、服务、HTTP/OpenAPI 测试:通过。
|
||||||
|
- `curl` 冒烟:`/summary`、`/indices` 返回 `source=tdx,status=fresh`;`/breadth`、`/sectors`、`/funds` 返回结构化不可用状态。
|
||||||
|
- 浏览器验证:桌面默认视口和 390px 移动视口无横向溢出;综合/短线切换和分析引擎选择可用。
|
||||||
|
|
||||||
|
## 安全、性能与残余风险
|
||||||
|
|
||||||
|
- 安全:TDX 地址固定为本机回环桥接端口;请求超时、响应 1 MiB 上限、错误脱敏;只读行情,不接受用户输入拼接请求。
|
||||||
|
- 性能:五个模块并行请求,单次 TDX 请求受超时保护;本切片不引入数据库、缓存或消息负载,未进行持续压测。
|
||||||
|
- 残余风险:桥接层尚未提供宽度、板块和资金方法;详情页、依据抽屉和 AI 运行时需另立功能;市场数据授权和生产 7×24 稳定性未由本地冒烟证明。
|
||||||
|
|
||||||
|
## 安全验证
|
||||||
|
|
||||||
|
- 验证 TDX 只能使用回环地址和固定端口,错误响应不包含原始 URL、请求体或凭据。
|
||||||
|
- 验证前端只能通过统一 Axios API 文件访问后端,边界测试拒绝页面直连 Axios/fetch 和 Server 内部依赖。
|
||||||
|
- 验证 E2E 夹具使用无秘密的生产测试环境变量,不读取仓库内本地凭据或生产配置。
|
||||||
|
|
||||||
|
## 性能验证
|
||||||
|
|
||||||
|
- 验证五个模块并发加载、单模块失败隔离和请求超时行为。
|
||||||
|
- 验证响应体 1 MiB 上限和构建产物体积;浏览器桌面/移动端无横向溢出。
|
||||||
|
- 7×24 运行、峰值并发、TDX 断线重连和消息/缓存压力测试不适用于当前只读首屏,已列为后续采集与生产功能边界。
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export default [
|
|||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...vue.configs['flat/recommended'],
|
...vue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
// Vue 单文件组件的 script setup 使用 TypeScript,避免 ESLint 退回 Espree。
|
||||||
|
parser: tseslint.parser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}'],
|
files: ['**/*.{js,mjs,cjs,ts,tsx,vue}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ describe('市场概览服务', () => {
|
|||||||
|
|
||||||
const response = await service.getIndices();
|
const response = await service.getIndices();
|
||||||
|
|
||||||
expect(response).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } });
|
expect(response).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { items: expect.any(Array) },
|
||||||
|
});
|
||||||
expect(response.snapshotId).toMatch(/^20260818-/);
|
expect(response.snapshotId).toMatch(/^20260818-/);
|
||||||
expect(client.request).toHaveBeenCalledWith('get_market_data', expect.objectContaining({ count: 5 }));
|
expect(client.request).toHaveBeenCalledWith(
|
||||||
|
'get_market_data',
|
||||||
|
expect.objectContaining({ count: 5 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
|
it('对未验证的市场宽度能力返回不可用而不是伪造数据', async () => {
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export type MarketOverviewResponse<TData> = {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function createSnapshotMeta(): Pick<MarketOverviewResponse<never>, 'snapshotId' | 'asOf' | 'source'> {
|
function createSnapshotMeta(): Pick<
|
||||||
|
MarketOverviewResponse<never>,
|
||||||
|
'snapshotId' | 'asOf' | 'source'
|
||||||
|
> {
|
||||||
const asOf = new Date().toISOString();
|
const asOf = new Date().toISOString();
|
||||||
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
|
const snapshotId = `${asOf.slice(0, 10).replaceAll('-', '')}-${asOf.slice(11, 19).replaceAll(':', '')}`;
|
||||||
return { snapshotId, asOf, source: 'tdx' };
|
return { snapshotId, asOf, source: 'tdx' };
|
||||||
@@ -76,9 +79,15 @@ export class MarketOverviewService {
|
|||||||
const averageChange = changes.length
|
const averageChange = changes.length
|
||||||
? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100
|
? Math.round((changes.reduce((sum, value) => sum + value, 0) / changes.length) * 100) / 100
|
||||||
: null;
|
: null;
|
||||||
const marketStatus = averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱';
|
const marketStatus =
|
||||||
|
averageChange === null ? '指数数据不完整' : averageChange >= 0 ? '指数偏强' : '指数偏弱';
|
||||||
const summary: SummaryDataDto = {
|
const summary: SummaryDataDto = {
|
||||||
verdict: averageChange === null ? '市场状态待确认' : averageChange >= 0 ? '市场维持偏强震荡' : '市场维持偏弱震荡',
|
verdict:
|
||||||
|
averageChange === null
|
||||||
|
? '市场状态待确认'
|
||||||
|
: averageChange >= 0
|
||||||
|
? '市场维持偏强震荡'
|
||||||
|
: '市场维持偏弱震荡',
|
||||||
marketStatus,
|
marketStatus,
|
||||||
volumeStatus: '成交量口径待接入',
|
volumeStatus: '成交量口径待接入',
|
||||||
sentimentStatus: '市场情绪口径待接入',
|
sentimentStatus: '市场情绪口径待接入',
|
||||||
|
|||||||
@@ -12,19 +12,21 @@ function response(body: unknown, init?: ResponseInit): Response {
|
|||||||
|
|
||||||
describe('TDX 客户端', () => {
|
describe('TDX 客户端', () => {
|
||||||
it('发送固定 JSON 请求并返回业务结果', async () => {
|
it('发送固定 JSON 请求并返回业务结果', async () => {
|
||||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
const fetchImpl = vi
|
||||||
response({ id: 1, result: { ErrorId: '0', Value: {} } }),
|
.fn<typeof fetch>()
|
||||||
);
|
.mockResolvedValue(response({ id: 1, result: { ErrorId: '0', Value: {} } }));
|
||||||
const client = createTdxClient({
|
const client = createTdxClient({
|
||||||
baseUrl: 'http://127.0.0.1:17710',
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.request('get_market_data', { stock_list: ['600519.SH'] })).resolves.toEqual({
|
await expect(client.request('get_market_data', { stock_list: ['600519.SH'] })).resolves.toEqual(
|
||||||
ErrorId: '0',
|
{
|
||||||
Value: {},
|
ErrorId: '0',
|
||||||
});
|
Value: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(fetchImpl).toHaveBeenCalledWith(
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
'http://127.0.0.1:17710/',
|
'http://127.0.0.1:17710/',
|
||||||
@@ -42,9 +44,11 @@ describe('TDX 客户端', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('将 TDX 错误响应转换为脱敏协议错误', async () => {
|
it('将 TDX 错误响应转换为脱敏协议错误', async () => {
|
||||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
|
const fetchImpl = vi
|
||||||
response({ id: 1, error: { code: 'bad_request', message: '内部连接信息' } }),
|
.fn<typeof fetch>()
|
||||||
);
|
.mockResolvedValue(
|
||||||
|
response({ id: 1, error: { code: 'bad_request', message: '内部连接信息' } }),
|
||||||
|
);
|
||||||
const client = createTdxClient({
|
const client = createTdxClient({
|
||||||
baseUrl: 'http://127.0.0.1:17710',
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
@@ -60,7 +64,9 @@ describe('TDX 客户端', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('将 AbortError 转换为超时错误', async () => {
|
it('将 AbortError 转换为超时错误', async () => {
|
||||||
const fetchImpl = vi.fn<typeof fetch>().mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
const fetchImpl = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockRejectedValue(new DOMException('aborted', 'AbortError'));
|
||||||
const client = createTdxClient({
|
const client = createTdxClient({
|
||||||
baseUrl: 'http://127.0.0.1:17710',
|
baseUrl: 'http://127.0.0.1:17710',
|
||||||
timeoutMs: 50,
|
timeoutMs: 50,
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import request from 'supertest';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { MarketOverviewController } from '../src/market-overview/market-overview.controller';
|
import { MarketOverviewController } from '../src/market-overview/market-overview.controller';
|
||||||
import { MarketOverviewService, TDX_CLIENT_PORT } from '../src/market-overview/market-overview.service';
|
import {
|
||||||
|
MarketOverviewService,
|
||||||
|
TDX_CLIENT_PORT,
|
||||||
|
} from '../src/market-overview/market-overview.service';
|
||||||
|
|
||||||
describe('市场概览 HTTP API', () => {
|
describe('市场概览 HTTP API', () => {
|
||||||
let app: INestApplication | undefined;
|
let app: INestApplication | undefined;
|
||||||
@@ -57,9 +60,15 @@ describe('市场概览 HTTP API', () => {
|
|||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/v1/market-overview/indices').expect(200);
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/indices')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'fresh', data: { items: expect.any(Array) } });
|
expect(response.body).toMatchObject({
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: { items: expect.any(Array) },
|
||||||
|
});
|
||||||
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
expect(JSON.stringify(response.body)).not.toContain('127.0.0.1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +84,9 @@ describe('市场概览 HTTP API', () => {
|
|||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/v1/market-overview/sectors').expect(200);
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/market-overview/sectors')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
expect(response.body).toMatchObject({ source: 'tdx', status: 'unavailable', data: null });
|
||||||
expect(response.body.warnings[0]).toContain('板块排行');
|
expect(response.body.warnings[0]).toContain('板块排行');
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
MYSQL_HEALTH_PORT,
|
MYSQL_HEALTH_PORT,
|
||||||
REDIS_HEALTH_PORT,
|
REDIS_HEALTH_PORT,
|
||||||
} from '../apps/api/src/health.service';
|
} from '../apps/api/src/health.service';
|
||||||
import { MarketOverviewService, TDX_CLIENT_PORT } from '../apps/api/src/market-overview/market-overview.service';
|
import {
|
||||||
|
MarketOverviewService,
|
||||||
|
TDX_CLIENT_PORT,
|
||||||
|
} from '../apps/api/src/market-overview/market-overview.service';
|
||||||
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export async function runTestApi() {
|
|||||||
LOG_LEVEL: 'error',
|
LOG_LEVEL: 'error',
|
||||||
API_HOST: '127.0.0.1',
|
API_HOST: '127.0.0.1',
|
||||||
API_PORT: '3000',
|
API_PORT: '3000',
|
||||||
|
// API 配置要求始终声明本机 TDX 边界;E2E 不依赖 TDX 可达性,页面会验证不可用状态。
|
||||||
|
TDX_BASE_URL: 'http://127.0.0.1:17710',
|
||||||
|
TDX_TIMEOUT_MS: '5000',
|
||||||
REDIS_HOST: '127.0.0.1',
|
REDIS_HOST: '127.0.0.1',
|
||||||
REDIS_PORT: '6379',
|
REDIS_PORT: '6379',
|
||||||
REDIS_USERNAME: '',
|
REDIS_USERNAME: '',
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ import type {
|
|||||||
|
|
||||||
export function createMarketOverviewApi(requestClient: RequestClient = request) {
|
export function createMarketOverviewApi(requestClient: RequestClient = request) {
|
||||||
return {
|
return {
|
||||||
getSummary: () => requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
|
getSummary: () =>
|
||||||
getIndices: () => requestClient<IndicesResponse>({ method: 'get', url: '/api/v1/market-overview/indices' }),
|
requestClient<SummaryResponse>({ method: 'get', url: '/api/v1/market-overview/summary' }),
|
||||||
getBreadth: () => requestClient<BreadthResponse>({ method: 'get', url: '/api/v1/market-overview/breadth' }),
|
getIndices: () =>
|
||||||
getSectors: () => requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
|
requestClient<IndicesResponse>({ method: 'get', url: '/api/v1/market-overview/indices' }),
|
||||||
getFunds: () => requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
|
getBreadth: () =>
|
||||||
|
requestClient<BreadthResponse>({ method: 'get', url: '/api/v1/market-overview/breadth' }),
|
||||||
|
getSectors: () =>
|
||||||
|
requestClient<SectorsResponse>({ method: 'get', url: '/api/v1/market-overview/sectors' }),
|
||||||
|
getFunds: () =>
|
||||||
|
requestClient<FundsResponse>({ method: 'get', url: '/api/v1/market-overview/funds' }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -1,11 +1,32 @@
|
|||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import { createMemoryHistory } from 'vue-router';
|
import { createMemoryHistory } from 'vue-router';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import { createAppRouter } from './router';
|
import { createAppRouter } from './router';
|
||||||
|
|
||||||
|
// 应用外壳测试只验证导航和主区域,不应因页面挂载触发真实 Axios 请求。
|
||||||
|
vi.mock('@/api/market-overview', () => {
|
||||||
|
const response = {
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'unavailable',
|
||||||
|
data: null,
|
||||||
|
snapshotId: 'test',
|
||||||
|
asOf: '2026-08-18T00:00:00.000Z',
|
||||||
|
warnings: ['测试环境未连接 TDX'],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
getSummary: vi.fn().mockResolvedValue(response),
|
||||||
|
getIndices: vi.fn().mockResolvedValue(response),
|
||||||
|
getBreadth: vi.fn().mockResolvedValue(response),
|
||||||
|
getSectors: vi.fn().mockResolvedValue(response),
|
||||||
|
getFunds: vi.fn().mockResolvedValue(response),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('应用外壳', () => {
|
describe('应用外壳', () => {
|
||||||
it('提供可访问的应用标题和主内容区域', async () => {
|
it('提供可访问的应用标题和主内容区域', async () => {
|
||||||
const router = createAppRouter(createMemoryHistory());
|
const router = createAppRouter(createMemoryHistory());
|
||||||
@@ -21,6 +42,6 @@ describe('应用外壳', () => {
|
|||||||
expect(wrapper.get('header').attributes('aria-label')).toBe('应用导航');
|
expect(wrapper.get('header').attributes('aria-label')).toBe('应用导航');
|
||||||
expect(wrapper.get('main').attributes('id')).toBe('main-content');
|
expect(wrapper.get('main').attributes('id')).toBe('main-content');
|
||||||
expect(wrapper.text()).toContain('A 股市场分析工具');
|
expect(wrapper.text()).toContain('A 股市场分析工具');
|
||||||
expect(wrapper.text()).toContain('工程基线已就绪');
|
expect(wrapper.text()).toContain('市场综合概览');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -10,8 +10,14 @@ const appTitle = import.meta.env.VITE_APP_TITLE || 'A 股市场分析工具';
|
|||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="app-header" aria-label="应用导航">
|
<header class="app-header" aria-label="应用导航">
|
||||||
<span class="app-mark" aria-hidden="true">A</span>
|
<div class="app-identity">
|
||||||
<strong>{{ appTitle }}</strong>
|
<span class="app-mark" aria-hidden="true">A</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ appTitle }}</strong>
|
||||||
|
<small>市场复盘工作台</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="header-context">只读行情 · TDX</span>
|
||||||
</header>
|
</header>
|
||||||
<main id="main-content" class="app-main">
|
<main id="main-content" class="app-main">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import {
|
|||||||
type RouterHistory,
|
type RouterHistory,
|
||||||
type RouteRecordRaw,
|
type RouteRecordRaw,
|
||||||
} from 'vue-router';
|
} from 'vue-router';
|
||||||
import { h } from 'vue';
|
|
||||||
|
import MarketOverviewPage from '@/views/market-overview/MarketOverviewPage.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: {
|
component: MarketOverviewPage,
|
||||||
// 工程基线不引入运行时模板编译器;业务首页落地前用渲染函数提供可观测占位内容。
|
|
||||||
render: () => h('section', { 'aria-label': '应用就绪' }, [h('p', '工程基线已就绪')]),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+474
-1
@@ -44,7 +44,7 @@ textarea {
|
|||||||
.app-header {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -52,6 +52,29 @@ textarea {
|
|||||||
border-bottom: 1px solid #343b44;
|
border-bottom: 1px solid #343b44;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-identity strong,
|
||||||
|
.app-identity small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-identity small {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-context {
|
||||||
|
color: #aeb7c2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-mark {
|
.app-mark {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -69,10 +92,460 @@ textarea {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.market-overview-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px;
|
||||||
|
background: #e8edf1;
|
||||||
|
border: 1px solid #d5dce3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button,
|
||||||
|
.refresh-button,
|
||||||
|
.quiet-button,
|
||||||
|
.sector-list button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button {
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #4c5966;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button.active {
|
||||||
|
color: #17212b;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgb(28 40 52 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #65717d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions select {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 28px 0 9px;
|
||||||
|
color: #24303b;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccd4dc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: #167d82;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion,
|
||||||
|
.overview-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe1e6;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 3px 14px rgb(26 38 49 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion {
|
||||||
|
padding: 22px 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
color: #167d82;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading,
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1,
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #17212b;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1 {
|
||||||
|
font-size: clamp(24px, 3vw, 38px);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-copy {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #475563;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px 7px;
|
||||||
|
min-width: 142px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: #167d82;
|
||||||
|
background: #eff8f7;
|
||||||
|
border: 1px solid #cbe6e2;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status small {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #6d7a85;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status[data-status='unavailable'] {
|
||||||
|
color: #a45e1b;
|
||||||
|
background: #fff7ec;
|
||||||
|
border-color: #f0d7b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: currentcolor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 21px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts div {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts span,
|
||||||
|
.panel-meta,
|
||||||
|
.breadth-values span,
|
||||||
|
.column-label {
|
||||||
|
color: #7b8792;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts strong {
|
||||||
|
color: #2e3b47;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-warning {
|
||||||
|
margin: 13px 0 0;
|
||||||
|
color: #a45e1b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid-top {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid-bottom {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-panel {
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiet-button {
|
||||||
|
padding: 6px 9px;
|
||||||
|
color: #167d82;
|
||||||
|
background: #eff8f7;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drivers-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column + .driver-column {
|
||||||
|
padding-left: 17px;
|
||||||
|
border-left: 1px solid #e6eaee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label.positive,
|
||||||
|
.positive {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-label.negative,
|
||||||
|
.negative {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #485664;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column li::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.62em;
|
||||||
|
left: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: #aeb8c1;
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 13px;
|
||||||
|
color: #4c5966;
|
||||||
|
text-align: left;
|
||||||
|
background: #f8fafb;
|
||||||
|
border: 1px solid #e3e8ec;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item:hover {
|
||||||
|
border-color: #a5c9c7;
|
||||||
|
box-shadow: 0 2px 8px rgb(26 38 49 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item strong {
|
||||||
|
color: #1c2a35;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item small {
|
||||||
|
color: #7d8893;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item[data-direction='up'] small {
|
||||||
|
color: #c33f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-item[data-direction='down'] small {
|
||||||
|
color: #19846e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values div {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadth-values strong {
|
||||||
|
color: #2e3b47;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-placeholder,
|
||||||
|
.module-unavailable {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
min-height: 104px;
|
||||||
|
gap: 6px;
|
||||||
|
color: #8a959f;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-unavailable strong {
|
||||||
|
color: #a45e1b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-unavailable span {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sector-list button {
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: #4c5966;
|
||||||
|
background: #f3f7f7;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.overview-grid-top,
|
||||||
|
.overview-grid-bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.app-header,
|
.app-header,
|
||||||
.app-main {
|
.app-main {
|
||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-context {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-toolbar,
|
||||||
|
.conclusion-heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-switcher button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-conclusion {
|
||||||
|
padding: 18px 16px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-heading h1 {
|
||||||
|
font-size: 27px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-status {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts {
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-facts strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drivers-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driver-column + .driver-column {
|
||||||
|
padding-top: 14px;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px solid #e6eaee;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
|
||||||
|
import MarketBreadth from './components/MarketBreadth.vue';
|
||||||
|
import MarketConclusion from './components/MarketConclusion.vue';
|
||||||
|
import MarketDrivers from './components/MarketDrivers.vue';
|
||||||
|
import SectorOverview from './components/SectorOverview.vue';
|
||||||
|
import FundTrend from './components/FundTrend.vue';
|
||||||
|
import IndexOverview from './components/IndexOverview.vue';
|
||||||
|
import { useMarketOverview } from './useMarketOverview';
|
||||||
|
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
indices,
|
||||||
|
breadth,
|
||||||
|
sectors,
|
||||||
|
funds,
|
||||||
|
isLoading,
|
||||||
|
analysisHorizon,
|
||||||
|
analysisEngine,
|
||||||
|
refresh,
|
||||||
|
setAnalysisHorizon,
|
||||||
|
setAnalysisEngine,
|
||||||
|
} = useMarketOverview();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function openDetail(label: string) {
|
||||||
|
// 详情页尚未立项,先保留明确入口并避免伪造导航地址。
|
||||||
|
console.info(`市场概览详情入口:${label}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="market-overview-page">
|
||||||
|
<div class="overview-toolbar">
|
||||||
|
<div class="view-switcher" aria-label="分析视角">
|
||||||
|
<button
|
||||||
|
v-for="option in ['综合', '短线', '中长线']"
|
||||||
|
:key="option"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: analysisHorizon === option }"
|
||||||
|
@click="setAnalysisHorizon(option)"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label>
|
||||||
|
<span>分析引擎</span>
|
||||||
|
<select
|
||||||
|
:value="analysisEngine"
|
||||||
|
@change="setAnalysisEngine(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option>规则</option>
|
||||||
|
<option>AI</option>
|
||||||
|
<option>混合</option>
|
||||||
|
<option>对照</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="refresh-button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
title="重新读取 TDX 行情"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '读取中…' : '刷新行情' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MarketConclusion :response="summary.response" :loading="summary.status === 'loading'" />
|
||||||
|
|
||||||
|
<div class="overview-grid overview-grid-top">
|
||||||
|
<MarketDrivers :response="summary.response" />
|
||||||
|
<IndexOverview
|
||||||
|
:response="indices.response"
|
||||||
|
:loading="indices.status === 'loading'"
|
||||||
|
@select="openDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overview-grid overview-grid-bottom">
|
||||||
|
<MarketBreadth :response="breadth.response" :loading="breadth.status === 'loading'" />
|
||||||
|
<SectorOverview
|
||||||
|
:response="sectors.response"
|
||||||
|
:loading="sectors.status === 'loading'"
|
||||||
|
@select="openDetail"
|
||||||
|
/>
|
||||||
|
<FundTrend :response="funds.response" :loading="funds.status === 'loading'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FundsResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: FundsResponse | null; loading: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="funds-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">资金观察</span>
|
||||||
|
<h2 id="funds-title">资金趋势</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">数据口径</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取资金趋势…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="module-placeholder">暂无资金趋势数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IndicesResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: IndicesResponse | null; loading: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [code: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="indices-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">核心指数</span>
|
||||||
|
<h2 id="indices-title">主要市场温度</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">{{ response?.asOf ? response.asOf.slice(11, 19) : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取指数…</div>
|
||||||
|
<div v-else-if="!response?.data?.items.length" class="module-placeholder">
|
||||||
|
TDX 指数数据不可用
|
||||||
|
</div>
|
||||||
|
<div v-else class="index-grid">
|
||||||
|
<button
|
||||||
|
v-for="item in response.data.items"
|
||||||
|
:key="item.code"
|
||||||
|
type="button"
|
||||||
|
class="index-item"
|
||||||
|
:data-direction="item.direction"
|
||||||
|
:title="`进入${item.name}详细分析`"
|
||||||
|
@click="emit('select', item.code)"
|
||||||
|
>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<strong>{{ item.price.toFixed(2) }}</strong>
|
||||||
|
<small>{{
|
||||||
|
item.changePercent == null
|
||||||
|
? '—'
|
||||||
|
: `${item.changePercent > 0 ? '+' : ''}${item.changePercent.toFixed(2)}%`
|
||||||
|
}}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { BreadthResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: BreadthResponse | null; loading: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="breadth-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">市场宽度</span>
|
||||||
|
<h2 id="breadth-title">涨跌分布与量能</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">分钟级</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取市场宽度…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="response?.data" class="breadth-values">
|
||||||
|
<div>
|
||||||
|
<span>上涨</span><strong class="positive">{{ response.data.advancing }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>下跌</span><strong class="negative">{{ response.data.declining }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>平盘</span><strong>{{ response.data.unchanged }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="module-placeholder">暂无宽度数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SummaryResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
response: SummaryResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-conclusion" aria-labelledby="market-overview-title">
|
||||||
|
<div class="section-kicker">市场综合概览</div>
|
||||||
|
<div class="conclusion-heading">
|
||||||
|
<div>
|
||||||
|
<h1 id="market-overview-title">盘面先看结论,再看证据</h1>
|
||||||
|
<p class="conclusion-copy">
|
||||||
|
{{ loading ? '正在读取 TDX 行情…' : response?.data?.verdict || 'TDX 数据暂不可用' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="source-status" :data-status="response?.status || 'unavailable'">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span>{{ response?.source === 'tdx' ? 'TDX 行情' : '等待数据' }}</span>
|
||||||
|
<small>{{ response?.asOf ? `更新于 ${response.asOf}` : '尚未连接' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conclusion-facts">
|
||||||
|
<div>
|
||||||
|
<span>市场状态</span>
|
||||||
|
<strong>{{ response?.data?.marketStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>量能状态</span>
|
||||||
|
<strong>{{ response?.data?.volumeStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>情绪状态</span>
|
||||||
|
<strong>{{ response?.data?.sentimentStatus || '待确认' }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="response?.warnings.length" class="module-warning">{{ response.warnings[0] }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SummaryResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: SummaryResponse | null }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel drivers-panel" aria-labelledby="drivers-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">判断依据</span>
|
||||||
|
<h2 id="drivers-title">驱动与风险</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="quiet-button" title="查看当前结论依据">查看依据</button>
|
||||||
|
</div>
|
||||||
|
<div class="drivers-grid">
|
||||||
|
<div class="driver-column">
|
||||||
|
<span class="column-label positive">驱动</span>
|
||||||
|
<ul>
|
||||||
|
<li v-for="driver in response?.data?.drivers || ['等待 TDX 数据']" :key="driver">
|
||||||
|
{{ driver }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="driver-column">
|
||||||
|
<span class="column-label negative">风险</span>
|
||||||
|
<ul>
|
||||||
|
<li v-for="risk in response?.data?.risks || ['等待 TDX 数据']" :key="risk">{{ risk }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SectorsResponse } from '@/types/market-overview';
|
||||||
|
|
||||||
|
defineProps<{ response: SectorsResponse | null; loading: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [sector: string] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="overview-panel" aria-labelledby="sectors-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">板块扫描</span>
|
||||||
|
<h2 id="sectors-title">强弱分布</h2>
|
||||||
|
</div>
|
||||||
|
<span class="panel-meta">点击查看详情</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="module-placeholder">正在读取板块…</div>
|
||||||
|
<div v-else-if="response?.status === 'unavailable'" class="module-unavailable">
|
||||||
|
<strong>暂不可用</strong>
|
||||||
|
<span>{{ response.warnings[0] }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="response?.data?.items.length" class="sector-list">
|
||||||
|
<button
|
||||||
|
v-for="sector in response.data.items"
|
||||||
|
:key="sector"
|
||||||
|
type="button"
|
||||||
|
@click="emit('select', sector)"
|
||||||
|
>
|
||||||
|
{{ sector }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="module-placeholder">暂无板块数据</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,14 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import { useMarketOverview } from './useMarketOverview';
|
import { useMarketOverview } from './useMarketOverview';
|
||||||
|
|
||||||
const response = { source: 'tdx', status: 'fresh', data: null, snapshotId: '20260818-101500', asOf: '2026-08-18T10:15:00+08:00', warnings: [] } as const;
|
const response = {
|
||||||
|
source: 'tdx',
|
||||||
|
status: 'fresh',
|
||||||
|
data: null,
|
||||||
|
snapshotId: '20260818-101500',
|
||||||
|
asOf: '2026-08-18T10:15:00+08:00',
|
||||||
|
warnings: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
describe('市场概览状态', () => {
|
describe('市场概览状态', () => {
|
||||||
it('并行加载五个模块,一个模块失败不影响其他模块', async () => {
|
it('并行加载五个模块,一个模块失败不影响其他模块', async () => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import type {
|
|||||||
SummaryResponse,
|
SummaryResponse,
|
||||||
} from '@/types/market-overview';
|
} from '@/types/market-overview';
|
||||||
|
|
||||||
type MarketResponse = SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
type MarketResponse =
|
||||||
|
SummaryResponse | IndicesResponse | BreadthResponse | SectorsResponse | FundsResponse;
|
||||||
|
|
||||||
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
export interface OverviewModuleState<TResponse extends MarketResponse> {
|
||||||
status: OverviewModuleStatus;
|
status: OverviewModuleStatus;
|
||||||
@@ -37,7 +38,9 @@ function writePreference(key: string, value: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMarketOverview(api: ReturnType<typeof createMarketOverviewApi> = marketOverviewApi) {
|
export function useMarketOverview(
|
||||||
|
api: ReturnType<typeof createMarketOverviewApi> = marketOverviewApi,
|
||||||
|
) {
|
||||||
const summary = ref<OverviewModuleState<SummaryResponse>>(createState());
|
const summary = ref<OverviewModuleState<SummaryResponse>>(createState());
|
||||||
const indices = ref<OverviewModuleState<IndicesResponse>>(createState());
|
const indices = ref<OverviewModuleState<IndicesResponse>>(createState());
|
||||||
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
|
const breadth = ref<OverviewModuleState<BreadthResponse>>(createState());
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('应用外壳在桌面和移动视口均可正常呈现', async ({ page })
|
|||||||
|
|
||||||
await expect(page.locator('main')).toBeVisible();
|
await expect(page.locator('main')).toBeVisible();
|
||||||
await expect(page.getByText('A 股市场分析工具')).toBeVisible();
|
await expect(page.getByText('A 股市场分析工具')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: '盘面先看结论,再看证据' })).toBeVisible();
|
||||||
|
|
||||||
const hasHorizontalOverflow = await page.evaluate(
|
const hasHorizontalOverflow = await page.evaluate(
|
||||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
|||||||
Reference in New Issue
Block a user