feat: improve stock detail view and update docs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,6 +15,7 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
|
|||||||
153
README.md
153
README.md
@ -1,51 +1,33 @@
|
|||||||
# lhbfx
|
# lhbfx
|
||||||
|
|
||||||
`lhbfx` 是一个围绕 A 股龙虎榜数据构建的“顶级游资监控系统”,当前聚焦盘后分析、游资席位跟踪、关注池管理、预警提示与个股复盘。
|
`lhbfx` 是一个围绕 A 股龙虎榜数据构建的盘后游资监控系统。当前工程聚焦龙虎榜导入、游资席位匹配、关注池管理、个股复盘、风险预警、盘后自动更新以及 PDF/邮件日报。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
- `backend/`
|
```text
|
||||||
- FastAPI 服务
|
lhbfx/
|
||||||
- 龙虎榜导入、席位映射、预警生成脚本
|
├─ backend/ # FastAPI 后端、数据导入脚本、MySQL schema
|
||||||
- MySQL schema 与查询逻辑
|
│ ├─ config.example.yaml # 配置模板
|
||||||
- `frontend/`
|
│ ├─ config.yaml # 本地运行配置
|
||||||
- Vue 3 + TypeScript + Vite 单页应用
|
│ ├─ scripts/ # 命令行脚本入口
|
||||||
- 首页总控台、游资详情、个股详情、预警中心
|
│ └─ src/lhbfx/ # 后端应用源码
|
||||||
- `docs/`
|
├─ frontend/ # Vue 3 + TypeScript + Vite 前端
|
||||||
- 需求文档
|
│ ├─ public/
|
||||||
- 技术文档
|
│ └─ src/
|
||||||
- 历史说明与 UI 草稿
|
├─ docs/ # 需求、技术与 UI 文档
|
||||||
|
├─ logs/ # 本地运行日志,已忽略
|
||||||
|
└─ start-dev.ps1 # 一键启动前后端
|
||||||
|
```
|
||||||
|
|
||||||
## 当前核心能力
|
## 当前能力
|
||||||
|
|
||||||
- 首页支持“关注池与操作流水”联动展示。
|
- 首页总控台:按日期、游资、买卖方向筛选,联动关注池与候选关注股票。
|
||||||
- 待加入关注列表支持按买卖结构着色、净额重点突出、快速加入关注。
|
- 关注池:支持新增、取消关注,并按股票、日期、游资、席位合并展示流水。
|
||||||
- 关注池写入数据库,支持新增和删除。
|
- 个股详情:展示日 K、MA5、买卖点、游资参与概览、买卖力度趋势与预警弹层。
|
||||||
- 个股详情支持 K 线、MA5、买卖点与预警信息展示。
|
- 游资详情:展示游资档案、席位、历史参与股票与净额变化。
|
||||||
- 预警中心支持卖出预警、慢流出观察等风险信息查看。
|
- 预警中心:展示卖出预警、慢流出观察等风险信号。
|
||||||
- 已明确新增“每日 17:00 自动更新 + 邮件日报 + PDF 附件”需求,待后续实现。
|
- 盘后流程:支持导入指定交易日数据、重匹配游资、生成预警、生成 PDF 日报并可发送邮件。
|
||||||
|
- 后端调度:API 启动后会按工作日 17:00 之后的窗口尝试补齐当日数据。
|
||||||
## 最近界面与数据调整
|
|
||||||
|
|
||||||
- 个股详情页的“买卖明细”已改为“买卖力度趋势”图:
|
|
||||||
- 柱形图按日期展示买入和卖出,买入为正、卖出为负。
|
|
||||||
- 折线展示“当日净额”和“累计净额”。
|
|
||||||
- 明细仅在鼠标悬浮图表时显示。
|
|
||||||
- 个股详情页顶部新增“预警列表”按钮:
|
|
||||||
- 有预警时红色高亮提示。
|
|
||||||
- 点击后以弹层方式展示当前个股预警,不再挤占右侧图表区域。
|
|
||||||
- 个股详情、首页候选股、游资详情页的数据补全逻辑已增强:
|
|
||||||
- 优先读取数据库中的股票元数据。
|
|
||||||
- 外部快照失败时增加备用行情源兜底。
|
|
||||||
- 收盘后更新流程会同步补全行业、市值、流通市值等字段。
|
|
||||||
- 后端 API 启动时会自动检查最近龙虎榜数据:
|
|
||||||
- 如果数据库最新交易日已经是当天,则跳过。
|
|
||||||
- 如果发现最近工作日数据缺失,会尝试自动补齐并重新生成预警。
|
|
||||||
- 检查失败不会阻塞 API 启动,避免服务不可用。
|
|
||||||
- 后端服务内置收盘后调度:
|
|
||||||
- 工作日 17:00 之后会自动尝试执行收盘后更新。
|
|
||||||
- 如果 17:00 时数据源尚未准备好,会按固定间隔重试,直到当天数据补齐。
|
|
||||||
- 本地和服务器使用同一套调度逻辑,不再依赖本机 Codex 自动任务。
|
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
@ -55,37 +37,27 @@
|
|||||||
|
|
||||||
## 后端启动
|
## 后端启动
|
||||||
|
|
||||||
1. 复制配置文件:
|
1. 准备配置:
|
||||||
- 将 `backend/config.example.yaml` 复制为 `backend/config.yaml`
|
|
||||||
- 按实际数据库连接信息修改
|
```powershell
|
||||||
- 当前仓库已允许提交 `backend/config.yaml`,服务器部署默认读取该文件
|
Copy-Item backend\config.example.yaml backend\config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
按实际 MySQL、邮件等信息修改 `backend/config.yaml`。
|
||||||
|
|
||||||
2. 初始化数据库:
|
2. 初始化数据库:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python backend/scripts/init_db.py
|
python backend\scripts\init_db.py
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 启动 API:
|
3. 启动 API:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python backend/scripts/run_api.py
|
python backend\scripts\run_api.py
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 收盘后更新并发送邮件:
|
默认地址:`http://127.0.0.1:8000`。
|
||||||
|
|
||||||
```powershell
|
|
||||||
python backend/scripts/after_market_update.py --trade-date 2026-04-17 --send-email
|
|
||||||
```
|
|
||||||
|
|
||||||
默认约定:
|
|
||||||
|
|
||||||
- 按 `Asia/Shanghai` 取当天日期
|
|
||||||
- 预期定时为 A 股交易日 `17:00`
|
|
||||||
- 只有当日成功拉到新的龙虎榜数据,才会继续生成预警、PDF 并发邮件;否则自动跳过
|
|
||||||
|
|
||||||
默认地址:
|
|
||||||
|
|
||||||
- `http://127.0.0.1:8000`
|
|
||||||
|
|
||||||
## 前端启动
|
## 前端启动
|
||||||
|
|
||||||
@ -95,49 +67,78 @@ npm install
|
|||||||
npm run dev -- --host 127.0.0.1 --port 5173
|
npm run dev -- --host 127.0.0.1 --port 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
默认地址:
|
默认地址:`http://127.0.0.1:5173`,开发服务器会把 `/api` 代理到后端。
|
||||||
|
|
||||||
- `http://127.0.0.1:5173`
|
|
||||||
|
|
||||||
## 一键开发启动
|
## 一键开发启动
|
||||||
|
|
||||||
根目录提供了 `start-dev.ps1`:
|
在项目根目录运行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File .\start-dev.ps1
|
powershell -ExecutionPolicy Bypass -File .\start-dev.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会同时拉起:
|
脚本会同时启动:
|
||||||
|
|
||||||
- 后端:`127.0.0.1:8000`
|
- 后端:`http://127.0.0.1:8000`
|
||||||
- 前端:`127.0.0.1:5173`
|
- 前端:`http://127.0.0.1:5173`
|
||||||
|
- 日志:`logs/`
|
||||||
|
|
||||||
日志输出目录:
|
## 常用脚本
|
||||||
|
|
||||||
- `logs/`
|
```powershell
|
||||||
|
# 导入同花顺指定日期龙虎榜数据
|
||||||
|
python backend\scripts\import_ths_daily.py --trade-date 2026-04-17
|
||||||
|
|
||||||
## 常用后端接口
|
# 重跑游资席位匹配
|
||||||
|
python backend\scripts\rematch_traders.py
|
||||||
|
|
||||||
|
# 重新生成预警
|
||||||
|
python backend\scripts\generate_warnings.py
|
||||||
|
|
||||||
|
# 盘后更新,可选邮件发送
|
||||||
|
python backend\scripts\after_market_update.py --trade-date 2026-04-17 --send-email
|
||||||
|
|
||||||
|
# 生成日报,可选发送
|
||||||
|
python backend\scripts\daily_report.py --trade-date 2026-04-17 --send
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用 API
|
||||||
|
|
||||||
- `GET /api/summary`
|
- `GET /api/summary`
|
||||||
- `GET /api/pipeline/status`
|
- `GET /api/pipeline/status`
|
||||||
- `GET /api/traders`
|
- `GET /api/traders`
|
||||||
- `GET /api/traders/{trader_id}`
|
- `GET /api/traders/{trader_id}`
|
||||||
|
- `GET /api/stocks/search`
|
||||||
- `GET /api/stocks/{stock_code}`
|
- `GET /api/stocks/{stock_code}`
|
||||||
- `GET /api/actions`
|
- `GET /api/actions`
|
||||||
- `GET /api/watchlist`
|
- `GET /api/watchlist`
|
||||||
- `POST /api/watchlist`
|
- `POST /api/watchlist`
|
||||||
- `DELETE /api/watchlist/{stock_code}`
|
- `DELETE /api/watchlist/{stock_code}`
|
||||||
- `GET /api/warnings`
|
- `GET /api/warnings`
|
||||||
|
- `POST /api/pipeline/rematch-traders`
|
||||||
|
- `POST /api/pipeline/generate-warnings`
|
||||||
|
- `POST /api/pipeline/refresh-trade-date`
|
||||||
|
|
||||||
|
## 构建与检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 前端类型检查与生产构建
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 后端语法检查
|
||||||
|
cd ..
|
||||||
|
python -m compileall backend\src backend\scripts
|
||||||
|
```
|
||||||
|
|
||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
- 需求文档:`docs/需求文档.md`
|
- 需求文档:`docs/需求文档.md`
|
||||||
- 技术文档:`docs/技术文档.md`
|
- 技术文档:`docs/技术文档.md`
|
||||||
|
- UI 设计说明:`docs/UI设计说明.md`
|
||||||
- 历史需求梳理:`docs/需求梳理-今日游资操作优先.md`
|
- 历史需求梳理:`docs/需求梳理-今日游资操作优先.md`
|
||||||
- 历史 UI 说明:`docs/UI设计说明.md`
|
- 完整早期需求:`docs/顶级游资监控系统需求文档.md`
|
||||||
|
|
||||||
## 仓库说明
|
## 仓库说明
|
||||||
|
|
||||||
- 仓库已补充 `.gitignore` 与 `.gitattributes`
|
生成物和本地运行产物不进入版本库,包括 `logs/`、`frontend/dist/`、`frontend/node_modules/`、`__pycache__/`、`*.egg-info/`。提交前建议运行前端构建和后端编译检查。
|
||||||
- 本地敏感配置 `backend/config.yaml` 已忽略,不会推送
|
|
||||||
- 提交前建议使用 `backend/config.example.yaml` 作为共享配置模板
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from _bootstrap import add_src_to_path
|
|||||||
|
|
||||||
add_src_to_path()
|
add_src_to_path()
|
||||||
|
|
||||||
from lhbfx.after_market import SHANGHAI_TZ, default_trade_date, run_after_market_update
|
from lhbfx.after_market import default_trade_date, run_after_market_update
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from _bootstrap import add_src_to_path
|
from _bootstrap import add_src_to_path
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from .pipeline import (
|
|||||||
)
|
)
|
||||||
from .queries import (
|
from .queries import (
|
||||||
fetch_stock_detail,
|
fetch_stock_detail,
|
||||||
|
search_stocks,
|
||||||
fetch_summary,
|
fetch_summary,
|
||||||
fetch_trader_actions,
|
fetch_trader_actions,
|
||||||
fetch_trader_detail,
|
fetch_trader_detail,
|
||||||
@ -128,6 +129,12 @@ def api_trader_detail(trader_id: int):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stocks/search")
|
||||||
|
def api_stock_search(q: str, limit: int = 8):
|
||||||
|
safe_limit = min(max(limit, 1), 20)
|
||||||
|
return search_stocks(q, limit=safe_limit)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/stocks/{stock_code}")
|
@app.get("/api/stocks/{stock_code}")
|
||||||
def api_stock_detail(stock_code: str):
|
def api_stock_detail(stock_code: str):
|
||||||
data = fetch_stock_detail(stock_code)
|
data = fetch_stock_detail(stock_code)
|
||||||
|
|||||||
@ -176,6 +176,43 @@ def fetch_watchlist(include_archived: bool = False) -> list[dict[str, Any]]:
|
|||||||
return [_normalize_row(row) for row in cursor.fetchall()]
|
return [_normalize_row(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def search_stocks(query: str, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
keyword = query.strip()
|
||||||
|
if not keyword:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prefix = f"{keyword}%"
|
||||||
|
contains = f"%{keyword}%"
|
||||||
|
|
||||||
|
with db_cursor() as (_, cursor):
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
stock_code,
|
||||||
|
stock_name,
|
||||||
|
market,
|
||||||
|
industry
|
||||||
|
FROM stocks
|
||||||
|
WHERE stock_code LIKE %s
|
||||||
|
OR stock_name LIKE %s
|
||||||
|
OR stock_name LIKE %s
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN stock_code = %s THEN 0
|
||||||
|
WHEN stock_name = %s THEN 1
|
||||||
|
WHEN stock_code LIKE %s THEN 2
|
||||||
|
WHEN stock_name LIKE %s THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
updated_at DESC,
|
||||||
|
stock_code
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(prefix, prefix, contains, keyword, keyword, prefix, prefix, limit),
|
||||||
|
)
|
||||||
|
return [_normalize_row(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def upsert_watchlist_item(
|
def upsert_watchlist_item(
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
stock_name: str,
|
stock_name: str,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections import defaultdict
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .after_market import SHANGHAI_TZ, run_after_market_update
|
from .after_market import SHANGHAI_TZ, run_after_market_update
|
||||||
from .config import AppConfig, load_config
|
from .config import AppConfig, load_config
|
||||||
|
|||||||
372
docs/技术文档.md
372
docs/技术文档.md
@ -2,31 +2,27 @@
|
|||||||
|
|
||||||
## 1. 技术栈
|
## 1. 技术栈
|
||||||
|
|
||||||
### 1.1 前端
|
- 前端:Vue 3、TypeScript、Vite
|
||||||
|
- 后端:FastAPI、PyMySQL、PyYAML、Requests、BeautifulSoup、ReportLab
|
||||||
- Vue 3
|
- 数据库:MySQL 8+
|
||||||
- TypeScript
|
- 运行脚本:PowerShell、Python scripts
|
||||||
- Vite
|
|
||||||
|
|
||||||
### 1.2 后端
|
|
||||||
|
|
||||||
- FastAPI
|
|
||||||
- PyMySQL
|
|
||||||
- PyYAML
|
|
||||||
- Requests / BeautifulSoup
|
|
||||||
|
|
||||||
### 1.3 数据库
|
|
||||||
|
|
||||||
- MySQL
|
|
||||||
|
|
||||||
## 2. 工程结构
|
## 2. 工程结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
longhubang/
|
lhbfx/
|
||||||
├─ backend/
|
├─ backend/
|
||||||
│ ├─ config.example.yaml
|
│ ├─ config.example.yaml
|
||||||
|
│ ├─ config.yaml
|
||||||
│ ├─ pyproject.toml
|
│ ├─ pyproject.toml
|
||||||
│ ├─ scripts/
|
│ ├─ scripts/
|
||||||
|
│ │ ├─ init_db.py
|
||||||
|
│ │ ├─ run_api.py
|
||||||
|
│ │ ├─ import_ths_daily.py
|
||||||
|
│ │ ├─ rematch_traders.py
|
||||||
|
│ │ ├─ generate_warnings.py
|
||||||
|
│ │ ├─ after_market_update.py
|
||||||
|
│ │ └─ daily_report.py
|
||||||
│ └─ src/lhbfx/
|
│ └─ src/lhbfx/
|
||||||
├─ frontend/
|
├─ frontend/
|
||||||
│ ├─ package.json
|
│ ├─ package.json
|
||||||
@ -34,307 +30,115 @@ longhubang/
|
|||||||
│ └─ src/
|
│ └─ src/
|
||||||
├─ docs/
|
├─ docs/
|
||||||
├─ start-dev.ps1
|
├─ start-dev.ps1
|
||||||
├─ .gitignore
|
|
||||||
└─ README.md
|
└─ README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 后端架构
|
## 3. 后端架构
|
||||||
|
|
||||||
### 3.1 入口
|
### 3.1 应用入口
|
||||||
|
|
||||||
- `backend/scripts/run_api.py`
|
- `backend/scripts/run_api.py`:命令行启动入口。
|
||||||
- `backend/src/lhbfx/app.py`
|
- `backend/src/lhbfx/app.py`:FastAPI 应用、API 路由、静态前端挂载、启动时数据检查与调度启动。
|
||||||
|
|
||||||
`app.py` 提供 API 路由与前端静态文件挂载能力。
|
|
||||||
|
|
||||||
### 3.2 配置
|
### 3.2 配置
|
||||||
|
|
||||||
- 运行时配置文件:`backend/config.yaml`
|
- `backend/config.example.yaml`:共享配置模板。
|
||||||
- 示例配置文件:`backend/config.example.yaml`
|
- `backend/config.yaml`:本地运行配置,包含数据库、邮件、调度等信息。
|
||||||
|
- `backend/src/lhbfx/config.py`:配置模型与加载逻辑。
|
||||||
|
|
||||||
为避免泄露本地敏感信息,实际 `config.yaml` 已加入 `.gitignore`。
|
### 3.3 数据访问
|
||||||
|
|
||||||
### 3.3 数据层
|
- `backend/src/lhbfx/db.py`:MySQL 连接与 cursor 上下文。
|
||||||
|
- `backend/src/lhbfx/schema.sql`:表结构。
|
||||||
|
- `backend/src/lhbfx/queries.py`:面向 API 的查询与聚合,包含摘要、游资详情、个股详情、关注池、股票搜索、预警查询等。
|
||||||
|
|
||||||
数据库相关逻辑集中在:
|
### 3.4 数据导入与处理
|
||||||
|
|
||||||
- `backend/src/lhbfx/db.py`
|
- `backend/src/lhbfx/pipeline.py`:龙虎榜导入、游资席位匹配、预警生成、指定交易日刷新。
|
||||||
- `backend/src/lhbfx/schema.sql`
|
- `backend/src/lhbfx/sources/tonghuashun.py`:同花顺龙虎榜数据源。
|
||||||
- `backend/src/lhbfx/queries.py`
|
- `backend/src/lhbfx/sources/eastmoney.py`:东方财富行情、K 线与个股资料兜底数据源。
|
||||||
|
- `backend/src/lhbfx/sources/tencent.py`:腾讯行情快照兜底数据源。
|
||||||
|
- `backend/src/lhbfx/sources/sina.py`:新浪 K 线兜底数据源。
|
||||||
|
|
||||||
当前主要表:
|
### 3.5 盘后任务与日报
|
||||||
|
|
||||||
- `stocks`
|
- `backend/src/lhbfx/after_market.py`:盘后更新编排,负责导入、匹配、预警、日报与邮件。
|
||||||
- `lhb_overview`
|
- `backend/src/lhbfx/scheduler.py`:API 常驻调度,工作日 17:00 后尝试补齐当日数据。
|
||||||
- `lhb_detail_seats`
|
- `backend/src/lhbfx/reporting.py`:日报数据聚合与邮件正文。
|
||||||
- `warning_events`
|
- `backend/src/lhbfx/pdf_export.py`:PDF 日报生成。
|
||||||
- `watchlist_entries`
|
- `backend/src/lhbfx/mailer.py`:邮件发送。
|
||||||
- `traders`
|
|
||||||
- `trader_seats`
|
|
||||||
|
|
||||||
### 3.4 数据处理管线
|
|
||||||
|
|
||||||
`backend/src/lhbfx/pipeline.py` 负责:
|
|
||||||
|
|
||||||
- 龙虎榜日度导入
|
|
||||||
- 席位匹配
|
|
||||||
- 预警生成
|
|
||||||
- 管线状态统计
|
|
||||||
|
|
||||||
配套脚本位于 `backend/scripts/`:
|
|
||||||
|
|
||||||
- `init_db.py`
|
|
||||||
- `import_ths_daily.py`
|
|
||||||
- `import_ths_year.py`
|
|
||||||
- `generate_warnings.py`
|
|
||||||
- `rematch_traders.py`
|
|
||||||
|
|
||||||
### 3.5 首页关键接口
|
|
||||||
|
|
||||||
#### `/api/actions`
|
|
||||||
|
|
||||||
用途:
|
|
||||||
|
|
||||||
- 为首页左侧关注池流水和右侧候选区提供原始操作数据
|
|
||||||
|
|
||||||
当前返回字段包括:
|
|
||||||
|
|
||||||
- 股票代码、股票名称
|
|
||||||
- 游资名称
|
|
||||||
- 买入、卖出、净额
|
|
||||||
- 当前价格、涨跌幅
|
|
||||||
- 行业、市场、总市值、流通市值
|
|
||||||
- 操作方向
|
|
||||||
|
|
||||||
#### `/api/watchlist`
|
|
||||||
|
|
||||||
用途:
|
|
||||||
|
|
||||||
- 保存和读取用户关注池
|
|
||||||
|
|
||||||
行为:
|
|
||||||
|
|
||||||
- `GET /api/watchlist`:获取当前关注池
|
|
||||||
- `POST /api/watchlist`:加入关注
|
|
||||||
- `DELETE /api/watchlist/{stock_code}`:取消关注并删除记录
|
|
||||||
|
|
||||||
## 4. 前端架构
|
## 4. 前端架构
|
||||||
|
|
||||||
### 4.1 前端入口
|
### 4.1 入口与状态
|
||||||
|
|
||||||
- `frontend/src/main.ts`
|
- `frontend/src/main.ts`:Vue 应用入口。
|
||||||
- `frontend/src/App.vue`
|
- `frontend/src/App.vue`:页面路由状态、股票搜索、全局数据初始化。
|
||||||
|
- `frontend/src/composables/useDashboardData.ts`:统一封装 API 调用、选择状态、关注池派生数据与页面数据加载。
|
||||||
|
- `frontend/src/types.ts`:API 响应类型。
|
||||||
|
|
||||||
`App.vue` 负责页面切换、初始化数据和页面级事件绑定。
|
### 4.2 页面组件
|
||||||
|
|
||||||
### 4.2 数据管理
|
- `AppHero.vue`:顶部状态、导航与股票搜索。
|
||||||
|
- `HomeControlScreen.vue`:首页总控台,包含关注池流水与候选关注股票。
|
||||||
|
- `TraderDetailScreen.vue`:游资详情。
|
||||||
|
- `StockDetailScreen.vue`:个股详情容器、基础信息、预警入口、游资参与概览。
|
||||||
|
- `StockDetailKlinePanel.vue`:日 K、MA5、买卖点与预警标记。
|
||||||
|
- `StockActionTimelineChart.vue`:买卖力度趋势图。
|
||||||
|
- `WarningCenterScreen.vue`:预警中心。
|
||||||
|
- `home/CandidateWatchCard.vue`:候选关注股票卡片。
|
||||||
|
|
||||||
`frontend/src/composables/useDashboardData.ts` 是首页和多页面共享数据的核心数据层,负责:
|
### 4.3 工具模块
|
||||||
|
|
||||||
- 获取摘要数据
|
- `frontend/src/utils/format.ts`:金额、涨跌、日期和预警标签格式化。
|
||||||
- 获取预警数据
|
- `frontend/src/utils/stockDetailKline.ts`:个股 K 线面板的数据转换与坐标计算。
|
||||||
- 获取游资列表
|
|
||||||
- 获取首页操作流水
|
|
||||||
- 获取和维护关注池
|
|
||||||
|
|
||||||
### 4.3 页面组件
|
## 5. 主要数据流
|
||||||
|
|
||||||
- `HomeControlScreen.vue`:首页总控台
|
1. 用户启动后端,`app.py` 加载配置、创建 FastAPI 应用,并启动盘后调度。
|
||||||
- `TraderDetailScreen.vue`:游资详情,采用单列全宽股票列表,支持时间、名称、净额连续增大筛选,并展示行业、板块、总市值、净额和预警标签
|
2. 前端 `useDashboardData.initialize()` 拉取摘要、状态、游资、预警、关注池和操作流水。
|
||||||
- `StockDetailScreen.vue`:个股详情
|
3. 首页筛选条件变化后,前端重新请求 `/api/actions`,并在本地派生关注池流水、候选关注列表和指标。
|
||||||
- `WarningCenterScreen.vue`:预警中心
|
4. 用户选择个股时,前端请求 `/api/stocks/{stock_code}`,后端聚合数据库数据、K 线、行情快照、游资操作和预警。
|
||||||
|
5. 盘后任务导入龙虎榜数据后,执行席位匹配、预警生成、日报生成与邮件发送。
|
||||||
|
|
||||||
首页当前拆分重点组件:
|
## 6. API 概览
|
||||||
|
|
||||||
- `frontend/src/components/home/CandidateWatchCard.vue`
|
- `GET /api/summary`:系统摘要。
|
||||||
|
- `GET /api/pipeline/status`:导入与处理状态。
|
||||||
|
- `GET /api/traders`:游资列表。
|
||||||
|
- `GET /api/traders/{trader_id}`:游资详情。
|
||||||
|
- `GET /api/stocks/search`:股票搜索。
|
||||||
|
- `GET /api/stocks/{stock_code}`:个股详情。
|
||||||
|
- `GET /api/actions`:游资操作流水。
|
||||||
|
- `GET /api/watchlist`:关注池列表。
|
||||||
|
- `POST /api/watchlist`:加入关注池。
|
||||||
|
- `DELETE /api/watchlist/{stock_code}`:移出关注池。
|
||||||
|
- `GET /api/warnings`:预警列表。
|
||||||
|
- `POST /api/pipeline/rematch-traders`:重跑席位匹配。
|
||||||
|
- `POST /api/pipeline/generate-warnings`:重生成预警。
|
||||||
|
- `POST /api/pipeline/refresh-trade-date`:刷新指定交易日。
|
||||||
|
|
||||||
### 4.4 首页展示逻辑
|
## 7. 生成物与清理规则
|
||||||
|
|
||||||
#### 左侧关注池与操作流水
|
以下目录或文件属于生成物、本地依赖或运行产物,不应提交:
|
||||||
|
|
||||||
在前端中按以下维度合并:
|
|
||||||
|
|
||||||
- 股票
|
|
||||||
- 日期
|
|
||||||
- 游资
|
|
||||||
- 标准化席位名
|
|
||||||
|
|
||||||
用于解决同一席位同时出现在买入榜与卖出榜时被拆成两条的问题。
|
|
||||||
|
|
||||||
#### 右侧待加入关注
|
|
||||||
|
|
||||||
候选列表卡片设计目标:
|
|
||||||
|
|
||||||
- 固定高度
|
|
||||||
- 同屏显示更多
|
|
||||||
- 信息完整
|
|
||||||
- 净额优先
|
|
||||||
|
|
||||||
## 5. 启动与运行
|
|
||||||
|
|
||||||
### 5.1 一键启动
|
|
||||||
|
|
||||||
使用根目录脚本:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\start-dev.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 手动启动
|
|
||||||
|
|
||||||
后端:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python backend/scripts/run_api.py
|
|
||||||
```
|
|
||||||
|
|
||||||
前端:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev -- --host 127.0.0.1 --port 5173
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 仓库管理建议
|
|
||||||
|
|
||||||
### 6.1 已加入忽略项
|
|
||||||
|
|
||||||
- `frontend/node_modules/`
|
- `frontend/node_modules/`
|
||||||
- `frontend/dist/`
|
- `frontend/dist/`
|
||||||
- `logs/`
|
- `logs/`
|
||||||
- `output/`
|
- `__pycache__/`
|
||||||
- `.playwright-cli/`
|
- `*.pyc`
|
||||||
- `backend/config.yaml`
|
- `*.egg-info/`
|
||||||
|
|
||||||
### 6.2 行尾与文本规则
|
清理时优先删除这些生成物;源码删除必须先确认无引用、无入口、无文档约定。
|
||||||
|
|
||||||
仓库已添加 `.gitattributes`:
|
## 8. 验证命令
|
||||||
|
|
||||||
- 默认文本文件统一由 git 管理行尾
|
```powershell
|
||||||
- Windows 脚本保留 `crlf`
|
# 前端类型检查与构建
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
## 7. 测试与验证建议
|
# 后端语法检查
|
||||||
|
cd ..
|
||||||
当前建议至少执行三类验证:
|
python -m compileall backend\src backend\scripts
|
||||||
|
```
|
||||||
### 7.1 静态验证
|
|
||||||
|
|
||||||
- `npm run build`
|
|
||||||
- `python -m compileall backend/src`
|
|
||||||
|
|
||||||
### 7.2 接口验证
|
|
||||||
|
|
||||||
- `GET /api/actions`
|
|
||||||
- `GET /api/watchlist`
|
|
||||||
- `GET /api/stocks/{stock_code}`
|
|
||||||
|
|
||||||
### 7.3 页面验证
|
|
||||||
|
|
||||||
建议引入或固化以下检查:
|
|
||||||
|
|
||||||
- 首页强制刷新后布局是否完整
|
|
||||||
- 关注池流水是否正确合并
|
|
||||||
- 候选卡片是否在固定高度内完整显示
|
|
||||||
- 关键页面至少保留一份 Playwright 截图回归
|
|
||||||
|
|
||||||
## 8. 当前技术债
|
|
||||||
|
|
||||||
目前仍需持续处理的问题:
|
|
||||||
|
|
||||||
- 历史文档与部分配置存在中文乱码
|
|
||||||
- 部分来源数据原始字段编码不稳定
|
|
||||||
- 页面样式近期经历多轮快速调整,仍建议补视觉回归测试
|
|
||||||
|
|
||||||
## 9. 定时更新与邮件报送方案
|
|
||||||
|
|
||||||
为满足“每天下午 17:00 自动更新并发送盘后邮件”的新增需求,建议增加一个独立的调度与报送模块。
|
|
||||||
|
|
||||||
### 9.1 调度方式
|
|
||||||
|
|
||||||
建议采用以下任一方式:
|
|
||||||
|
|
||||||
- 服务器 `cron`
|
|
||||||
- Windows 任务计划程序
|
|
||||||
- 后续统一接入独立任务调度器
|
|
||||||
|
|
||||||
默认调度时间:
|
|
||||||
|
|
||||||
- 每天下午 `17:00`
|
|
||||||
|
|
||||||
### 9.2 推荐执行流程
|
|
||||||
|
|
||||||
每日任务执行顺序建议如下:
|
|
||||||
|
|
||||||
1. 拉取当日龙虎榜与行情数据
|
|
||||||
2. 更新数据库
|
|
||||||
3. 重新生成预警数据
|
|
||||||
4. 统计关注池情况
|
|
||||||
5. 统计待加入关注候选列表
|
|
||||||
6. 生成 PDF 日报
|
|
||||||
7. 发送邮件正文与附件
|
|
||||||
8. 记录执行日志
|
|
||||||
|
|
||||||
### 9.3 建议新增模块
|
|
||||||
|
|
||||||
建议新增以下能力:
|
|
||||||
|
|
||||||
- `backend/scripts/daily_report.py`
|
|
||||||
- 串联数据更新、统计、PDF 生成、邮件发送
|
|
||||||
- `backend/src/lhbfx/reporting.py`
|
|
||||||
- 负责报表数据整理
|
|
||||||
- `backend/src/lhbfx/mailer.py`
|
|
||||||
- 负责 SMTP 或邮件服务发送
|
|
||||||
- `backend/src/lhbfx/pdf_export.py`
|
|
||||||
- 负责 PDF 生成
|
|
||||||
|
|
||||||
### 9.4 配置项建议
|
|
||||||
|
|
||||||
建议在配置文件中新增:
|
|
||||||
|
|
||||||
- 是否启用邮件报送
|
|
||||||
- SMTP 主机
|
|
||||||
- SMTP 端口
|
|
||||||
- 发件人账号
|
|
||||||
- 发件人密码或授权码
|
|
||||||
- 收件人列表
|
|
||||||
- 抄送列表
|
|
||||||
- 日报输出目录
|
|
||||||
- 调度时间
|
|
||||||
|
|
||||||
### 9.5 PDF 生成建议
|
|
||||||
|
|
||||||
PDF 附件可以通过以下方式生成:
|
|
||||||
|
|
||||||
- 基于 HTML 模板渲染后导出 PDF
|
|
||||||
- 或直接使用 Python PDF 库生成结构化报告
|
|
||||||
|
|
||||||
推荐内容结构:
|
|
||||||
|
|
||||||
1. 标题页
|
|
||||||
2. 数据更新时间
|
|
||||||
3. 关注池总览
|
|
||||||
4. 关注池流水摘要
|
|
||||||
5. 今日待加入关注列表
|
|
||||||
6. 风险与预警摘要
|
|
||||||
|
|
||||||
### 9.6 邮件正文建议
|
|
||||||
|
|
||||||
正文采用简洁摘要形式,便于手机查看:
|
|
||||||
|
|
||||||
- 今日关注池概况
|
|
||||||
- 今日关注池重点动作
|
|
||||||
- 今日待加入关注候选
|
|
||||||
- 风险提示
|
|
||||||
- 附件说明
|
|
||||||
|
|
||||||
### 9.7 测试建议
|
|
||||||
|
|
||||||
新增该需求后,应补充以下验证:
|
|
||||||
|
|
||||||
- 17:00 定时任务是否被正确触发
|
|
||||||
- 更新失败时是否生成错误日志
|
|
||||||
- PDF 是否成功生成
|
|
||||||
- 邮件正文是否包含关键摘要
|
|
||||||
- 附件是否能正常打开
|
|
||||||
- 多收件人场景是否发送成功
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import StockDetailScreen from './components/StockDetailScreen.vue'
|
|||||||
import TraderDetailScreen from './components/TraderDetailScreen.vue'
|
import TraderDetailScreen from './components/TraderDetailScreen.vue'
|
||||||
import WarningCenterScreen from './components/WarningCenterScreen.vue'
|
import WarningCenterScreen from './components/WarningCenterScreen.vue'
|
||||||
import { useDashboardData } from './composables/useDashboardData'
|
import { useDashboardData } from './composables/useDashboardData'
|
||||||
import type { WarningItem } from './types'
|
import type { StockSearchItem, WarningItem } from './types'
|
||||||
|
|
||||||
const dashboard = useDashboardData()
|
const dashboard = useDashboardData()
|
||||||
|
|
||||||
@ -15,6 +15,11 @@ type PageKey = 'home' | 'trader' | 'stock' | 'warning'
|
|||||||
|
|
||||||
const currentPage = shallowRef<PageKey>('home')
|
const currentPage = shallowRef<PageKey>('home')
|
||||||
const selectedTraderId = computed(() => dashboard.selectedTraderId.value)
|
const selectedTraderId = computed(() => dashboard.selectedTraderId.value)
|
||||||
|
const stockSearchQuery = shallowRef('')
|
||||||
|
const stockSearchResults = shallowRef<StockSearchItem[]>([])
|
||||||
|
const stockSearchLoading = shallowRef(false)
|
||||||
|
let stockSearchTimer: ReturnType<typeof window.setTimeout> | null = null
|
||||||
|
let latestStockSearchToken = 0
|
||||||
|
|
||||||
const navItems: Array<{ key: PageKey; label: string }> = [
|
const navItems: Array<{ key: PageKey; label: string }> = [
|
||||||
{ key: 'home', label: '首页总控台' },
|
{ key: 'home', label: '首页总控台' },
|
||||||
@ -64,6 +69,12 @@ async function handleSelectStock(stockCode: string) {
|
|||||||
navigate('stock')
|
navigate('stock')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSearchSelectStock(stock: Pick<StockSearchItem, 'stock_code' | 'stock_name'>) {
|
||||||
|
stockSearchQuery.value = stock.stock_name
|
||||||
|
stockSearchResults.value = []
|
||||||
|
await handleSelectStock(stock.stock_code)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSelectWarningInCenter(warning: WarningItem) {
|
async function handleSelectWarningInCenter(warning: WarningItem) {
|
||||||
await dashboard.selectWarning(warning)
|
await dashboard.selectWarning(warning)
|
||||||
}
|
}
|
||||||
@ -105,11 +116,47 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('hashchange', syncPageFromHash)
|
window.removeEventListener('hashchange', syncPageFromHash)
|
||||||
|
if (stockSearchTimer !== null) {
|
||||||
|
window.clearTimeout(stockSearchTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(currentPage, (page) => {
|
watch(currentPage, (page) => {
|
||||||
void ensurePageData(page)
|
void ensurePageData(page)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(stockSearchQuery, (nextQuery) => {
|
||||||
|
const keyword = nextQuery.trim()
|
||||||
|
if (stockSearchTimer !== null) {
|
||||||
|
window.clearTimeout(stockSearchTimer)
|
||||||
|
stockSearchTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
stockSearchResults.value = []
|
||||||
|
stockSearchLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stockSearchTimer = window.setTimeout(() => {
|
||||||
|
const currentToken = ++latestStockSearchToken
|
||||||
|
stockSearchLoading.value = true
|
||||||
|
void dashboard
|
||||||
|
.searchStocks(keyword, 8)
|
||||||
|
.then((results) => {
|
||||||
|
if (currentToken !== latestStockSearchToken) return
|
||||||
|
stockSearchResults.value = results
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (currentToken !== latestStockSearchToken) return
|
||||||
|
stockSearchResults.value = []
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (currentToken !== latestStockSearchToken) return
|
||||||
|
stockSearchLoading.value = false
|
||||||
|
})
|
||||||
|
}, 180)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -123,7 +170,12 @@ watch(currentPage, (page) => {
|
|||||||
:status="dashboard.status.value"
|
:status="dashboard.status.value"
|
||||||
:nav-items="navItems"
|
:nav-items="navItems"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
:search-query="stockSearchQuery"
|
||||||
|
:search-results="stockSearchResults"
|
||||||
|
:search-loading="stockSearchLoading"
|
||||||
@navigate="navigate"
|
@navigate="navigate"
|
||||||
|
@update-search-query="stockSearchQuery = $event"
|
||||||
|
@select-stock="handleSearchSelectStock"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="dashboard.isBooting.value" class="loading-state">
|
<div v-if="dashboard.isBooting.value" class="loading-state">
|
||||||
|
|||||||
@ -1,24 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, shallowRef } from 'vue'
|
||||||
|
|
||||||
import type { PipelineStatus, Summary } from '../types'
|
import type { PipelineStatus, StockSearchItem, Summary } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
summary: Summary | null
|
summary: Summary | null
|
||||||
status: PipelineStatus | null
|
status: PipelineStatus | null
|
||||||
navItems: Array<{ key: 'home' | 'trader' | 'stock' | 'warning'; label: string }>
|
navItems: Array<{ key: 'home' | 'trader' | 'stock' | 'warning'; label: string }>
|
||||||
currentPage: 'home' | 'trader' | 'stock' | 'warning'
|
currentPage: 'home' | 'trader' | 'stock' | 'warning'
|
||||||
|
searchQuery: string
|
||||||
|
searchResults: StockSearchItem[]
|
||||||
|
searchLoading: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
navigate: [page: 'home' | 'trader' | 'stock' | 'warning']
|
navigate: [page: 'home' | 'trader' | 'stock' | 'warning']
|
||||||
|
updateSearchQuery: [value: string]
|
||||||
|
selectStock: [stock: StockSearchItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const searchFocused = shallowRef(false)
|
||||||
|
|
||||||
const statusBadges = computed(() => [
|
const statusBadges = computed(() => [
|
||||||
`最新交易日 ${props.status?.latest_trade_date ?? '-'}`,
|
`最新交易日 ${props.status?.latest_trade_date ?? '-'}`,
|
||||||
`导入交易日 ${props.summary?.imported_days ?? 0}`,
|
`导入交易日 ${props.summary?.imported_days ?? 0}`,
|
||||||
`目标游资 ${props.summary?.trader_total ?? 0}`,
|
`目标游资 ${props.summary?.trader_total ?? 0}`,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const showSearchPanel = computed(() => {
|
||||||
|
if (!searchFocused.value) return false
|
||||||
|
if (props.searchLoading) return true
|
||||||
|
return props.searchQuery.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
searchFocused.value = false
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectStock(stock: StockSearchItem) {
|
||||||
|
searchFocused.value = false
|
||||||
|
emit('selectStock', stock)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -43,6 +67,41 @@ const statusBadges = computed(() => [
|
|||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="hero-search" :class="{ open: showSearchPanel }">
|
||||||
|
<span class="hero-search-icon" aria-hidden="true">⌕</span>
|
||||||
|
<input
|
||||||
|
:value="searchQuery"
|
||||||
|
class="hero-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索个股"
|
||||||
|
@focus="searchFocused = true"
|
||||||
|
@blur="handleBlur"
|
||||||
|
@input="emit('updateSearchQuery', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="showSearchPanel" class="hero-search-panel">
|
||||||
|
<div v-if="searchLoading" class="hero-search-empty">搜索中...</div>
|
||||||
|
|
||||||
|
<template v-else-if="searchResults.length">
|
||||||
|
<button
|
||||||
|
v-for="stock in searchResults"
|
||||||
|
:key="stock.stock_code"
|
||||||
|
class="hero-search-item"
|
||||||
|
type="button"
|
||||||
|
@mousedown.prevent="handleSelectStock(stock)"
|
||||||
|
>
|
||||||
|
<div class="hero-search-main">
|
||||||
|
<strong>{{ stock.stock_name }}</strong>
|
||||||
|
<span>{{ stock.stock_code }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="hero-search-meta">{{ stock.industry || stock.market || 'A股' }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="hero-search-empty">没有匹配到股票</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -109,39 +168,165 @@ const statusBadges = computed(() => [
|
|||||||
|
|
||||||
.hero-nav {
|
.hero-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-nav-item,
|
||||||
|
.hero-search-input {
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.028);
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-nav-item {
|
.hero-nav-item {
|
||||||
padding: 6px 9px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease, color 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-nav-item:hover {
|
||||||
|
border-color: rgba(240, 192, 113, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-nav-item.active {
|
.hero-nav-item.active {
|
||||||
color: #090d14;
|
color: #090d14;
|
||||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
|
box-shadow: 0 8px 18px rgba(212, 163, 92, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search {
|
||||||
|
position: relative;
|
||||||
|
width: 272px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(240, 192, 113, 0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 12px 0 28px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-input::placeholder {
|
||||||
|
color: rgba(147, 162, 181, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-input:focus {
|
||||||
|
border-color: rgba(240, 192, 113, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(240, 192, 113, 0.08),
|
||||||
|
0 10px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
left: 0;
|
||||||
|
width: 272px;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(14, 20, 30, 0.98), rgba(8, 12, 18, 0.98));
|
||||||
|
box-shadow:
|
||||||
|
0 22px 40px rgba(0, 0, 0, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-item:hover {
|
||||||
|
border-color: rgba(240, 192, 113, 0.16);
|
||||||
|
background: rgba(240, 192, 113, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-main strong {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-main span,
|
||||||
|
.hero-search-meta,
|
||||||
|
.hero-search-empty {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-main span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-meta {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-search-empty {
|
||||||
|
padding: 6px 4px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badges {
|
.hero-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge {
|
.hero-badge {
|
||||||
padding: 6px 10px;
|
padding: 5px 9px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -516,6 +516,7 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
.watch-panel-list {
|
.watch-panel-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
grid-auto-rows: max-content;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
@ -525,7 +526,9 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
min-height: 176px;
|
min-height: 0;
|
||||||
|
align-content: start;
|
||||||
|
overflow: hidden;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -542,6 +545,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
.watch-stock-main {
|
.watch-stock-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watch-stock-title {
|
.watch-stock-title {
|
||||||
@ -568,6 +573,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex: none;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watch-flag {
|
.watch-flag {
|
||||||
@ -583,7 +590,9 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.watch-action-list {
|
.watch-action-list {
|
||||||
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
@ -594,6 +603,8 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(4, 8, 12, 0.38);
|
background: rgba(4, 8, 12, 0.38);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amounts {
|
.amounts {
|
||||||
@ -619,7 +630,7 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
grid-auto-rows: minmax(124px, auto);
|
grid-auto-rows: minmax(136px, auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy {
|
.buy {
|
||||||
@ -651,13 +662,6 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-empty,
|
|
||||||
.empty-state {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.watch-panel-list::-webkit-scrollbar,
|
.watch-panel-list::-webkit-scrollbar,
|
||||||
.candidate-list::-webkit-scrollbar {
|
.candidate-list::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@ -669,6 +673,13 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-empty,
|
||||||
|
.empty-state {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1320px) {
|
@media (max-width: 1320px) {
|
||||||
.filter-bar,
|
.filter-bar,
|
||||||
.metric-grid,
|
.metric-grid,
|
||||||
@ -696,5 +707,10 @@ function updateDateRange(field: 'from' | 'to', value: string) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.watch-stock-tools {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -73,10 +73,10 @@ const aggregatedRows = computed<AggregatedActionRow[]>(() => {
|
|||||||
const chartModel = computed(() => {
|
const chartModel = computed(() => {
|
||||||
const rows = aggregatedRows.value
|
const rows = aggregatedRows.value
|
||||||
const measuredWidth = containerWidth.value || 0
|
const measuredWidth = containerWidth.value || 0
|
||||||
const width = Math.max(measuredWidth, 320, rows.length * 54)
|
const width = Math.max(measuredWidth, 320)
|
||||||
const height = 260
|
const height = 320
|
||||||
const left = 56
|
const left = 56
|
||||||
const right = 64
|
const right = 24
|
||||||
const top = 18
|
const top = 18
|
||||||
const bottom = 34
|
const bottom = 34
|
||||||
const innerWidth = width - left - right
|
const innerWidth = width - left - right
|
||||||
@ -90,9 +90,6 @@ const chartModel = computed(() => {
|
|||||||
top,
|
top,
|
||||||
bottom,
|
bottom,
|
||||||
zeroY: top + innerHeight / 2,
|
zeroY: top + innerHeight / 2,
|
||||||
stepX: 0,
|
|
||||||
buyBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
|
||||||
sellBars: [] as Array<{ x: number; y: number; height: number; title: string }>,
|
|
||||||
netPoints: [] as Array<{ x: number; y: number }>,
|
netPoints: [] as Array<{ x: number; y: number }>,
|
||||||
cumulativePoints: [] as Array<{ x: number; y: number }>,
|
cumulativePoints: [] as Array<{ x: number; y: number }>,
|
||||||
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
|
labels: [] as Array<{ x: number; label: string; visible: boolean }>,
|
||||||
@ -100,25 +97,15 @@ const chartModel = computed(() => {
|
|||||||
hoverColumns: [] as Array<{ x: number; width: number }>,
|
hoverColumns: [] as Array<{ x: number; width: number }>,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rows.length) {
|
if (!rows.length) return emptyModel
|
||||||
return emptyModel
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueMax = Math.max(
|
const valueMax = Math.max(
|
||||||
...rows.flatMap((row) => [
|
...rows.flatMap((row) => [Math.abs(row.netTotalWan), Math.abs(row.cumulativeNetWan)]),
|
||||||
Math.abs(row.buyTotalWan),
|
|
||||||
Math.abs(row.sellTotalWan),
|
|
||||||
Math.abs(row.netTotalWan),
|
|
||||||
Math.abs(row.cumulativeNetWan),
|
|
||||||
]),
|
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
const zeroY = top + innerHeight / 2
|
const zeroY = top + innerHeight / 2
|
||||||
const yOfValue = (value: number) => zeroY - (value / valueMax) * (innerHeight / 2)
|
const yOfValue = (value: number) => zeroY - (value / valueMax) * (innerHeight / 2)
|
||||||
|
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / Math.max(rows.length - 1, 1)
|
||||||
const stepX = rows.length === 1 ? innerWidth / 2 : innerWidth / (rows.length - 1)
|
|
||||||
const groupWidth = Math.max(20, Math.min(28, stepX * 0.58))
|
|
||||||
const barWidth = Math.max(7, groupWidth / 2 - 2)
|
|
||||||
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
|
const labelStep = Math.max(1, Math.ceil(rows.length / 6))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -129,28 +116,6 @@ const chartModel = computed(() => {
|
|||||||
top,
|
top,
|
||||||
bottom,
|
bottom,
|
||||||
zeroY,
|
zeroY,
|
||||||
stepX,
|
|
||||||
buyBars: rows.map((row, index) => {
|
|
||||||
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
|
||||||
const y = yOfValue(row.buyTotalWan)
|
|
||||||
return {
|
|
||||||
x: x - barWidth - 2,
|
|
||||||
y,
|
|
||||||
height: Math.max(2, zeroY - y),
|
|
||||||
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
sellBars: rows.map((row, index) => {
|
|
||||||
const x = left + (rows.length === 1 ? innerWidth / 2 : index * stepX)
|
|
||||||
const y = zeroY
|
|
||||||
const sellY = yOfValue(-row.sellTotalWan)
|
|
||||||
return {
|
|
||||||
x: x + 2,
|
|
||||||
y,
|
|
||||||
height: Math.max(2, sellY - zeroY),
|
|
||||||
title: `${row.trade_date}\n买入 ${formatWanAmount(row.buyTotalWan)}\n卖出 ${formatWanAmount(row.sellTotalWan)}\n净额 ${formatSignedWanAmount(row.netTotalWan)}`,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
netPoints: rows.map((row, index) => ({
|
netPoints: rows.map((row, index) => ({
|
||||||
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
x: left + (rows.length === 1 ? innerWidth / 2 : index * stepX),
|
||||||
y: yOfValue(row.netTotalWan),
|
y: yOfValue(row.netTotalWan),
|
||||||
@ -181,8 +146,7 @@ const cumulativeLinePoints = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const activeRow = computed(() => {
|
const activeRow = computed(() => {
|
||||||
if (!aggregatedRows.value.length) return null
|
if (!aggregatedRows.value.length || hoveredIndex.value === null) return null
|
||||||
if (hoveredIndex.value === null) return null
|
|
||||||
return aggregatedRows.value[hoveredIndex.value] ?? null
|
return aggregatedRows.value[hoveredIndex.value] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -191,8 +155,8 @@ const activeTooltip = computed(() => {
|
|||||||
const point = chartModel.value.netPoints[hoveredIndex.value]
|
const point = chartModel.value.netPoints[hoveredIndex.value]
|
||||||
if (!point) return null
|
if (!point) return null
|
||||||
|
|
||||||
const tooltipWidth = 168
|
const tooltipWidth = 196
|
||||||
const tooltipHeight = 90
|
const tooltipHeight = 106
|
||||||
const x = Math.min(
|
const x = Math.min(
|
||||||
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
|
Math.max(point.x - tooltipWidth / 2, chartModel.value.left + 6),
|
||||||
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
|
chartModel.value.width - chartModel.value.right - tooltipWidth - 6,
|
||||||
@ -245,17 +209,9 @@ onUnmounted(() => {
|
|||||||
<div class="action-chart-head">
|
<div class="action-chart-head">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="action-chart-title">买卖力度趋势</h4>
|
<h4 class="action-chart-title">买卖力度趋势</h4>
|
||||||
<p class="action-chart-note">柱形图显示买卖,折线显示单日净额和累计净额。</p>
|
<p class="action-chart-note">仅保留当日净额和累计净额,减少干扰,便于判断承接与延续。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-chart-legend">
|
<div class="action-chart-legend">
|
||||||
<span class="legend-chip">
|
|
||||||
<span class="legend-swatch bar buy" />
|
|
||||||
买入
|
|
||||||
</span>
|
|
||||||
<span class="legend-chip">
|
|
||||||
<span class="legend-swatch bar sell" />
|
|
||||||
卖出
|
|
||||||
</span>
|
|
||||||
<span class="legend-chip">
|
<span class="legend-chip">
|
||||||
<span class="legend-swatch line net" />
|
<span class="legend-swatch line net" />
|
||||||
当日净额
|
当日净额
|
||||||
@ -311,32 +267,6 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g v-for="(bar, index) in chartModel.buyBars" :key="`buy-${index}`">
|
|
||||||
<rect
|
|
||||||
:x="bar.x"
|
|
||||||
:y="bar.y"
|
|
||||||
width="10"
|
|
||||||
:height="bar.height"
|
|
||||||
rx="2"
|
|
||||||
fill="#ff7b7b"
|
|
||||||
>
|
|
||||||
<title>{{ bar.title }}</title>
|
|
||||||
</rect>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g v-for="(bar, index) in chartModel.sellBars" :key="`sell-${index}`">
|
|
||||||
<rect
|
|
||||||
:x="bar.x"
|
|
||||||
:y="bar.y"
|
|
||||||
width="10"
|
|
||||||
:height="bar.height"
|
|
||||||
rx="2"
|
|
||||||
fill="#4ca8ff"
|
|
||||||
>
|
|
||||||
<title>{{ bar.title }}</title>
|
|
||||||
</rect>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<polyline
|
<polyline
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#f0c96a"
|
stroke="#f0c96a"
|
||||||
@ -351,10 +281,10 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
|
<g v-for="(point, index) in chartModel.netPoints" :key="`net-point-${index}`">
|
||||||
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#7de1b2" />
|
<circle :cx="point.x" :cy="point.y" r="3.6" fill="#7de1b2" />
|
||||||
</g>
|
</g>
|
||||||
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
|
<g v-for="(point, index) in chartModel.cumulativePoints" :key="`cumulative-point-${index}`">
|
||||||
<circle :cx="point.x" :cy="point.y" r="3.2" fill="#f0c96a" />
|
<circle :cx="point.x" :cy="point.y" r="3.4" fill="#f0c96a" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g v-if="activeTooltip && activeRow">
|
<g v-if="activeTooltip && activeRow">
|
||||||
@ -370,18 +300,21 @@ onUnmounted(() => {
|
|||||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 18" fill="#ffffff" font-size="11" font-weight="700">
|
||||||
{{ activeRow.trade_date }}
|
{{ activeRow.trade_date }}
|
||||||
</text>
|
</text>
|
||||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#ff7b7b" font-size="11">
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 36" fill="#7de1b2" font-size="11">
|
||||||
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
|
|
||||||
</text>
|
|
||||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#4ca8ff" font-size="11">
|
|
||||||
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
|
|
||||||
</text>
|
|
||||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#7de1b2" font-size="11">
|
|
||||||
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
|
当日净额 {{ formatSignedWanAmount(activeRow.netTotalWan) }}
|
||||||
</text>
|
</text>
|
||||||
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#f0c96a" font-size="11">
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 52" fill="#f0c96a" font-size="11">
|
||||||
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
|
累计净额 {{ formatSignedWanAmount(activeRow.cumulativeNetWan) }}
|
||||||
</text>
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 68" fill="#ff9b9b" font-size="11">
|
||||||
|
买入 {{ formatWanAmount(activeRow.buyTotalWan) }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 84" fill="#7fb5ff" font-size="11">
|
||||||
|
卖出 {{ formatWanAmount(activeRow.sellTotalWan) }}
|
||||||
|
</text>
|
||||||
|
<text :x="activeTooltip.x + 12" :y="activeTooltip.y + 100" fill="#93a2b5" font-size="10">
|
||||||
|
{{ activeRow.traderCount }} 个游资参与
|
||||||
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
|
<g v-for="(label, index) in chartModel.labels" :key="`label-${index}`">
|
||||||
@ -407,14 +340,14 @@ onUnmounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.action-chart-panel {
|
.action-chart-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-head {
|
.action-chart-head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-title {
|
.action-chart-title {
|
||||||
@ -423,23 +356,23 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-note {
|
.action-chart-note {
|
||||||
margin: 6px 0 0;
|
margin: 4px 0 0;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 1.55;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-legend {
|
.action-chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-chip {
|
.legend-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 5px 9px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
@ -456,18 +389,6 @@ onUnmounted(() => {
|
|||||||
height: 10px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-swatch.bar {
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-swatch.buy {
|
|
||||||
background: #ff7b7b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-swatch.sell {
|
|
||||||
background: #4ca8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-swatch.line::before {
|
.legend-swatch.line::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 18px;
|
width: 18px;
|
||||||
@ -490,15 +411,15 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-scroll {
|
.action-chart-scroll {
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
overflow-y: hidden;
|
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
min-height: 228px;
|
min-height: 304px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-svg {
|
.action-chart-svg {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 228px;
|
min-height: 304px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-chart-empty {
|
.action-chart-empty {
|
||||||
|
|||||||
567
frontend/src/components/StockDetailKlinePanel.vue
Normal file
567
frontend/src/components/StockDetailKlinePanel.vue
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, shallowRef, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { MarketDailyRow, TraderAction } from '../types'
|
||||||
|
import { formatSignedWanAmount, formatWanAmount } from '../utils/format'
|
||||||
|
import {
|
||||||
|
buildActionSummaryMap,
|
||||||
|
buildKlineChartModel,
|
||||||
|
normalizeMarketRows,
|
||||||
|
} from '../utils/stockDetailKline'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
marketDaily: MarketDailyRow[]
|
||||||
|
traderActions: TraderAction[]
|
||||||
|
zoomLevel: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:zoomLevel': [value: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const zoomOptions = [20, 40, 60, 90, 120, 180, 9999] as const
|
||||||
|
const selectedIndex = shallowRef<number | null>(null)
|
||||||
|
|
||||||
|
const normalizedRows = computed(() => normalizeMarketRows(props.marketDaily ?? []))
|
||||||
|
const visibleRows = computed(() => {
|
||||||
|
if (props.zoomLevel >= normalizedRows.value.length) return normalizedRows.value
|
||||||
|
return normalizedRows.value.slice(-props.zoomLevel)
|
||||||
|
})
|
||||||
|
const actionSummaryMap = computed(() => buildActionSummaryMap(props.traderActions ?? []))
|
||||||
|
const chartModel = computed(() => buildKlineChartModel(visibleRows.value, actionSummaryMap.value))
|
||||||
|
const ma5Points = computed(() => chartModel.value.ma5.map((point) => `${point.x},${point.y}`).join(' '))
|
||||||
|
|
||||||
|
const selectedCandle = computed(() => {
|
||||||
|
if (selectedIndex.value === null) return null
|
||||||
|
return chartModel.value.candles[selectedIndex.value] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedSummary = computed(() => selectedCandle.value?.actionSummary ?? null)
|
||||||
|
const selectedMarker = computed(() => {
|
||||||
|
const candle = selectedCandle.value
|
||||||
|
if (!candle) return null
|
||||||
|
return chartModel.value.markers.find((marker) => marker.tradeDate === candle.trade_date) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(chartModel, (model) => {
|
||||||
|
if (!model.candles.length) {
|
||||||
|
selectedIndex.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex.value !== null && model.candles[selectedIndex.value]) return
|
||||||
|
|
||||||
|
const lastActionIndex = [...model.candles]
|
||||||
|
.map((candle, index) => ({ candle, index }))
|
||||||
|
.reverse()
|
||||||
|
.find((entry) => entry.candle.actionSummary)?.index
|
||||||
|
|
||||||
|
selectedIndex.value = lastActionIndex ?? model.candles.length - 1
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function handleWheelZoom(event: WheelEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const currentIndex = zoomOptions.findIndex((option) => option === props.zoomLevel)
|
||||||
|
const safeIndex = currentIndex === -1 ? 2 : currentIndex
|
||||||
|
const nextIndex =
|
||||||
|
event.deltaY < 0
|
||||||
|
? Math.max(0, safeIndex - 1)
|
||||||
|
: Math.min(zoomOptions.length - 1, safeIndex + 1)
|
||||||
|
|
||||||
|
emit('update:zoomLevel', zoomOptions[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCandle(index: number) {
|
||||||
|
selectedIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIndex.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="kline-panel">
|
||||||
|
<div v-if="chartModel.candles.length" class="chart-shell">
|
||||||
|
<div class="chart-stage">
|
||||||
|
<svg
|
||||||
|
:viewBox="`0 0 ${chartModel.width} ${chartModel.height}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
@wheel.prevent="handleWheelZoom"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="kline-bg" x1="0%" x2="0%" y1="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="rgba(255,255,255,0.04)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(255,255,255,0.01)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
:x="chartModel.left"
|
||||||
|
:y="chartModel.top"
|
||||||
|
:width="chartModel.width - chartModel.left - chartModel.right"
|
||||||
|
:height="chartModel.innerHeight"
|
||||||
|
rx="18"
|
||||||
|
fill="url(#kline-bg)"
|
||||||
|
stroke="rgba(255,255,255,0.04)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g opacity="0.1" stroke="#ffffff">
|
||||||
|
<line
|
||||||
|
v-for="tick in chartModel.axis"
|
||||||
|
:key="tick.label"
|
||||||
|
:x1="chartModel.left"
|
||||||
|
:y1="tick.y"
|
||||||
|
:x2="chartModel.width - chartModel.right"
|
||||||
|
:y2="tick.y"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="tick in chartModel.axis" :key="`axis-${tick.label}`">
|
||||||
|
<text x="4" :y="tick.y + 4" fill="#93a2b5" font-size="10">{{ tick.label }}</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-if="selectedCandle">
|
||||||
|
<rect
|
||||||
|
:x="selectedCandle.x - chartModel.hoverBandWidth / 2"
|
||||||
|
:y="chartModel.top"
|
||||||
|
:width="chartModel.hoverBandWidth"
|
||||||
|
:height="chartModel.innerHeight"
|
||||||
|
fill="rgba(240, 192, 113, 0.08)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
:x1="selectedCandle.x"
|
||||||
|
:y1="chartModel.top"
|
||||||
|
:x2="selectedCandle.x"
|
||||||
|
:y2="chartModel.height - chartModel.bottom"
|
||||||
|
stroke="rgba(240, 192, 113, 0.18)"
|
||||||
|
stroke-dasharray="4 4"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<polyline fill="none" stroke="#5ab8ff" stroke-width="2.2" :points="ma5Points" />
|
||||||
|
|
||||||
|
<g v-for="(candle, index) in chartModel.candles" :key="`${candle.trade_date}-${index}`">
|
||||||
|
<rect
|
||||||
|
:x="candle.x - chartModel.hoverBandWidth / 2"
|
||||||
|
:y="chartModel.top"
|
||||||
|
:width="chartModel.hoverBandWidth"
|
||||||
|
:height="chartModel.innerHeight"
|
||||||
|
fill="transparent"
|
||||||
|
class="hit-band"
|
||||||
|
:class="{ active: selectedIndex === index }"
|
||||||
|
@click="selectCandle(index)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<line
|
||||||
|
:x1="candle.x"
|
||||||
|
:y1="candle.wickTop"
|
||||||
|
:x2="candle.x"
|
||||||
|
:y2="candle.wickBottom"
|
||||||
|
:stroke="candle.direction === 'rise' ? '#ff5d5d' : '#2dbd7b'"
|
||||||
|
stroke-width="1.3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
:x="candle.x - candle.bodyWidth / 2"
|
||||||
|
:y="candle.bodyTop"
|
||||||
|
:width="candle.bodyWidth"
|
||||||
|
:height="candle.bodyHeight"
|
||||||
|
rx="1.8"
|
||||||
|
:fill="candle.direction === 'rise' ? '#ff5d5d' : '#2dbd7b'"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g
|
||||||
|
v-for="marker in chartModel.markers"
|
||||||
|
:key="`${marker.tradeDate}-${marker.label}`"
|
||||||
|
class="marker-group"
|
||||||
|
:class="{ active: selectedIndex === marker.candleIndex }"
|
||||||
|
@click="selectCandle(marker.candleIndex)"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
:x1="marker.x"
|
||||||
|
:y1="marker.y + 12"
|
||||||
|
:x2="marker.x"
|
||||||
|
:y2="marker.y + 22"
|
||||||
|
:stroke="marker.dominantSide === 'buy' ? '#ff5d5d' : marker.dominantSide === 'sell' ? '#5ab8ff' : '#f0c071'"
|
||||||
|
stroke-width="1.1"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
:x="marker.x - marker.width / 2"
|
||||||
|
:y="marker.y - 10"
|
||||||
|
:width="marker.width"
|
||||||
|
height="20"
|
||||||
|
rx="10"
|
||||||
|
fill="rgba(8, 12, 18, 0.94)"
|
||||||
|
:stroke="marker.dominantSide === 'buy' ? '#ff5d5d' : marker.dominantSide === 'sell' ? '#5ab8ff' : '#f0c071'"
|
||||||
|
/>
|
||||||
|
<circle v-if="marker.hasBuy" :cx="marker.x - marker.width / 2 + 6" :cy="marker.y" r="2.6" fill="#ff5d5d" />
|
||||||
|
<circle v-if="marker.hasSell" :cx="marker.x + marker.width / 2 - 6" :cy="marker.y" r="2.6" fill="#5ab8ff" />
|
||||||
|
<text :x="marker.x" :y="marker.y + 4" text-anchor="middle" fill="#f5efe4" font-size="9.5" font-weight="700">
|
||||||
|
{{ marker.label }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g v-for="label in chartModel.labels" :key="label.label">
|
||||||
|
<text
|
||||||
|
v-if="label.visible"
|
||||||
|
:x="label.x"
|
||||||
|
:y="chartModel.height - 10"
|
||||||
|
text-anchor="middle"
|
||||||
|
fill="#93a2b5"
|
||||||
|
font-size="10"
|
||||||
|
>
|
||||||
|
{{ label.label }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="detail-card">
|
||||||
|
<template v-if="selectedCandle">
|
||||||
|
<div class="detail-head">
|
||||||
|
<div>
|
||||||
|
<h4>{{ selectedCandle.trade_date }}</h4>
|
||||||
|
<p v-if="selectedSummary" class="detail-caption">
|
||||||
|
{{ selectedSummary.traderCount }} 个游资 / {{ selectedSummary.seatCount }} 个营业部
|
||||||
|
</p>
|
||||||
|
<p v-else class="detail-caption">当日没有匹配到游资操作</p>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<span v-if="selectedMarker" class="detail-badge">{{ selectedMarker.label }} 个游资</span>
|
||||||
|
<button class="detail-close" type="button" @click="clearSelection">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<span>开 {{ selectedCandle.open }}</span>
|
||||||
|
<span>收 {{ selectedCandle.close }}</span>
|
||||||
|
<span>高 {{ selectedCandle.high }}</span>
|
||||||
|
<span>低 {{ selectedCandle.low }}</span>
|
||||||
|
<span>涨跌 {{ selectedCandle.pct_chg }}</span>
|
||||||
|
<span>成交额 {{ selectedCandle.amount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSummary" class="summary-strip">
|
||||||
|
<span class="buy">买入 {{ formatWanAmount(selectedSummary.totalBuyWan) }}</span>
|
||||||
|
<span class="sell">卖出 {{ formatWanAmount(selectedSummary.totalSellWan) }}</span>
|
||||||
|
<span class="net" :class="selectedSummary.totalNetWan > 0 ? 'rise' : selectedSummary.totalNetWan < 0 ? 'fall' : 'flat'">
|
||||||
|
净额 {{ formatSignedWanAmount(selectedSummary.totalNetWan) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section-head">
|
||||||
|
<strong>营业部明细</strong>
|
||||||
|
<span v-if="selectedSummary">{{ selectedSummary.allActions.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedSummary?.allActions.length" class="detail-list">
|
||||||
|
<article
|
||||||
|
v-for="action in selectedSummary.allActions"
|
||||||
|
:key="`${action.tradeDate}-${action.seatName}-${action.tableTitle}`"
|
||||||
|
class="detail-item"
|
||||||
|
:class="action.netAmountWan > 0 ? 'positive' : action.netAmountWan < 0 ? 'negative' : 'neutral'"
|
||||||
|
>
|
||||||
|
<div class="item-top">
|
||||||
|
<strong>{{ action.matchedTraderName }}</strong>
|
||||||
|
<span class="net" :class="action.netAmountWan > 0 ? 'rise' : action.netAmountWan < 0 ? 'fall' : 'flat'">
|
||||||
|
{{ formatSignedWanAmount(action.netAmountWan) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="seat-name">{{ action.seatName || '-' }}</p>
|
||||||
|
<div class="item-amounts">
|
||||||
|
<span class="buy">买 {{ formatWanAmount(action.buyAmountWan) }}</span>
|
||||||
|
<span class="sell">卖 {{ formatWanAmount(action.sellAmountWan) }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="detail-empty">
|
||||||
|
<h4>点击查看明细</h4>
|
||||||
|
<p>点击K线或数字标记后,右侧会锁定显示当日游资和营业部明细。列表会在面板内部滚动,不会溢出当前区域。</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
当前没有可展示的日K数据。
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kline-panel {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 400px;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-stage {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px 10px 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-shell svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-band,
|
||||||
|
.marker-group {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-band.active {
|
||||||
|
fill: rgba(240, 192, 113, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-group.active rect {
|
||||||
|
stroke-width: 1.6;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(240, 192, 113, 0.16));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(240, 192, 113, 0.1);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(18, 26, 36, 0.98), rgba(8, 12, 18, 0.98)),
|
||||||
|
radial-gradient(circle at top right, rgba(240, 192, 113, 0.08), transparent 32%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||||
|
0 20px 36px rgba(0, 0, 0, 0.16);
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head h4,
|
||||||
|
.detail-empty h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-caption {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-badge {
|
||||||
|
min-width: 72px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(240, 192, 113, 0.12);
|
||||||
|
border: 1px solid rgba(240, 192, 113, 0.22);
|
||||||
|
color: var(--color-gold-soft);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-close {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-close:hover {
|
||||||
|
border-color: rgba(240, 192, 113, 0.16);
|
||||||
|
background: rgba(240, 192, 113, 0.08);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 2px 0 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.positive::before {
|
||||||
|
background: rgba(255, 93, 93, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.negative::before {
|
||||||
|
background: rgba(90, 184, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.neutral::before {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-name {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-amounts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy,
|
||||||
|
.rise {
|
||||||
|
color: var(--color-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sell {
|
||||||
|
color: var(--color-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fall {
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 24px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1260px) {
|
||||||
|
.chart-shell {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
grid-template-rows: auto auto auto auto minmax(180px, 1fr);
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,427 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import type { ActionItem, WatchlistItem } from '../../types'
|
|
||||||
import {
|
|
||||||
formatSignedWanAmount,
|
|
||||||
formatWanAmount,
|
|
||||||
numberFromText,
|
|
||||||
priceTone,
|
|
||||||
} from '../../utils/format'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
actions: ActionItem[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
selectStock: [stockCode: string]
|
|
||||||
followStock: [payload: Pick<WatchlistItem, 'stock_code' | 'stock_name' | 'source_trade_date' | 'source_trader_name'>]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function candidateScore(action: ActionItem): number {
|
|
||||||
const buy = numberFromText(action.buy_amount_wan) ?? 0
|
|
||||||
const sell = numberFromText(action.sell_amount_wan) ?? 0
|
|
||||||
const net = numberFromText(action.net_amount_wan) ?? 0
|
|
||||||
|
|
||||||
return buy + Math.max(net, 0) * 1.4 - Math.max(-net, 0) * 0.35 - sell * 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
function analysisNote(action: ActionItem): string {
|
|
||||||
const buy = numberFromText(action.buy_amount_wan) ?? 0
|
|
||||||
const sell = numberFromText(action.sell_amount_wan) ?? 0
|
|
||||||
const net = numberFromText(action.net_amount_wan) ?? 0
|
|
||||||
|
|
||||||
if (buy > 0 && sell === 0) return '单边买入,动作更干净,适合优先跟踪。'
|
|
||||||
if (net > 0) return '净额为正,承接更强,可以先放进重点观察序列。'
|
|
||||||
if (sell > buy) return '分歧偏大,建议先结合盘口和次日承接再决定是否加入。'
|
|
||||||
return '买卖都有动作,建议继续跟踪后续是否出现一致性。'
|
|
||||||
}
|
|
||||||
|
|
||||||
const prioritizedActions = computed(() => {
|
|
||||||
return [...props.actions].sort((left, right) => {
|
|
||||||
const dateDiff = right.trade_date.localeCompare(left.trade_date)
|
|
||||||
if (dateDiff !== 0) return dateDiff
|
|
||||||
|
|
||||||
const scoreDiff = candidateScore(right) - candidateScore(left)
|
|
||||||
if (scoreDiff !== 0) return scoreDiff
|
|
||||||
|
|
||||||
return right.stock_code.localeCompare(left.stock_code)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const featuredCandidate = computed(() => prioritizedActions.value[0] ?? null)
|
|
||||||
const secondaryCandidates = computed(() => prioritizedActions.value.slice(1, 7))
|
|
||||||
const latestTradeDate = computed(() => prioritizedActions.value[0]?.trade_date ?? '')
|
|
||||||
|
|
||||||
function followAction(action: ActionItem) {
|
|
||||||
emit('followStock', {
|
|
||||||
stock_code: action.stock_code,
|
|
||||||
stock_name: action.stock_name,
|
|
||||||
source_trade_date: action.trade_date,
|
|
||||||
source_trader_name: action.trader_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<article class="card-panel focus-panel">
|
|
||||||
<div class="section-head">
|
|
||||||
<div>
|
|
||||||
<h3 class="section-title">待加入关注 · 重点分析</h3>
|
|
||||||
<p class="section-subtitle">按最近交易日与资金强度倒序排列,先看最值得持续跟踪的候选。</p>
|
|
||||||
</div>
|
|
||||||
<div class="head-pills">
|
|
||||||
<span class="pill active">{{ actions.length }} 个候选</span>
|
|
||||||
<span v-if="latestTradeDate" class="pill">最新 {{ latestTradeDate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="featuredCandidate" class="focus-stack">
|
|
||||||
<section class="featured-card" :class="priceTone(String(featuredCandidate.net_amount_wan ?? ''))">
|
|
||||||
<div class="featured-top">
|
|
||||||
<div class="featured-title">
|
|
||||||
<span class="rank-badge">TOP 1</span>
|
|
||||||
<strong>{{ featuredCandidate.stock_name }}</strong>
|
|
||||||
<span>{{ featuredCandidate.stock_code }} / {{ featuredCandidate.trader_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="featured-actions">
|
|
||||||
<button class="ghost-button" type="button" @click="emit('selectStock', featuredCandidate.stock_code)">
|
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
<button class="follow-button" type="button" @click="followAction(featuredCandidate)">
|
|
||||||
加入关注
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="featured-grid">
|
|
||||||
<div class="featured-metric">
|
|
||||||
<span>买入</span>
|
|
||||||
<strong class="buy">¥ {{ formatWanAmount(featuredCandidate.buy_amount_wan) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="featured-metric">
|
|
||||||
<span>卖出</span>
|
|
||||||
<strong class="sell">¥ {{ formatWanAmount(featuredCandidate.sell_amount_wan) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="featured-metric">
|
|
||||||
<span>净额</span>
|
|
||||||
<strong class="net" :class="priceTone(String(featuredCandidate.net_amount_wan ?? ''))">
|
|
||||||
{{ formatSignedWanAmount(featuredCandidate.net_amount_wan) }}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
<div class="featured-metric">
|
|
||||||
<span>现价 / 涨跌</span>
|
|
||||||
<strong :class="priceTone(String(featuredCandidate.pct_chg ?? ''))">
|
|
||||||
{{ featuredCandidate.current_price ?? '-' }} / {{ featuredCandidate.pct_chg ?? '-' }}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="featured-note">
|
|
||||||
{{ featuredCandidate.trade_date }} · {{ analysisNote(featuredCandidate) }}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-if="secondaryCandidates.length" class="secondary-grid">
|
|
||||||
<article
|
|
||||||
v-for="(action, index) in secondaryCandidates"
|
|
||||||
:key="`${action.trade_date}-${action.stock_code}-${action.trader_name}`"
|
|
||||||
class="candidate-card"
|
|
||||||
>
|
|
||||||
<div class="candidate-top">
|
|
||||||
<div class="candidate-title">
|
|
||||||
<span class="rank-badge subtle">TOP {{ index + 2 }}</span>
|
|
||||||
<button class="stock-button" type="button" @click="emit('selectStock', action.stock_code)">
|
|
||||||
{{ action.stock_name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="follow-button compact" type="button" @click="followAction(action)">
|
|
||||||
加入
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="candidate-meta">{{ action.stock_code }} · {{ action.trade_date }} · {{ action.trader_name }}</p>
|
|
||||||
|
|
||||||
<div class="candidate-metrics">
|
|
||||||
<span class="buy">买 {{ formatWanAmount(action.buy_amount_wan) }}</span>
|
|
||||||
<span class="sell">卖 {{ formatWanAmount(action.sell_amount_wan) }}</span>
|
|
||||||
<span class="net" :class="priceTone(String(action.net_amount_wan ?? ''))">
|
|
||||||
净 {{ formatSignedWanAmount(action.net_amount_wan) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="candidate-note">{{ analysisNote(action) }}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="empty-state">当前筛选条件下没有新的候选股,调整日期或游资筛选后再看。</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.focus-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-subtitle,
|
|
||||||
.candidate-meta,
|
|
||||||
.candidate-note,
|
|
||||||
.featured-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-subtitle {
|
|
||||||
margin-top: 6px;
|
|
||||||
max-width: 560px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill.active {
|
|
||||||
color: #090d14;
|
|
||||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.focus-stack,
|
|
||||||
.secondary-grid,
|
|
||||||
.candidate-metrics {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-card,
|
|
||||||
.candidate-card {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-card {
|
|
||||||
padding: 20px;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top right, rgba(212, 163, 92, 0.18), transparent 32%),
|
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(7, 10, 16, 0.22));
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-card.rise {
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-card.fall {
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(45, 189, 123, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-top,
|
|
||||||
.candidate-top {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-title,
|
|
||||||
.candidate-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-title strong {
|
|
||||||
font-size: 28px;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-title span:last-child,
|
|
||||||
.candidate-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-metric,
|
|
||||||
.candidate-card {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-metric {
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
background: rgba(4, 7, 12, 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-metric span {
|
|
||||||
display: block;
|
|
||||||
color: var(--color-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-metric strong {
|
|
||||||
display: block;
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.featured-note {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-card {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-button,
|
|
||||||
.ghost-button,
|
|
||||||
.follow-button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-button {
|
|
||||||
padding: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-align: left;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-metrics {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.candidate-note {
|
|
||||||
margin-top: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: fit-content;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(240, 192, 113, 0.14);
|
|
||||||
color: var(--color-gold-soft);
|
|
||||||
border: 1px solid rgba(240, 192, 113, 0.18);
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank-badge.subtle {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--color-muted);
|
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.follow-button,
|
|
||||||
.ghost-button {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.follow-button {
|
|
||||||
color: #090d14;
|
|
||||||
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.follow-button.compact {
|
|
||||||
padding: 7px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buy {
|
|
||||||
color: var(--color-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sell {
|
|
||||||
color: var(--color-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.net.rise {
|
|
||||||
color: var(--color-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.net.fall {
|
|
||||||
color: var(--color-green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.net.flat {
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 18px;
|
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 18px;
|
|
||||||
color: var(--color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.featured-grid,
|
|
||||||
.secondary-grid,
|
|
||||||
.candidate-metrics {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head,
|
|
||||||
.featured-top,
|
|
||||||
.candidate-top {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -23,6 +23,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const buyAmount = computed(() => numberFromText(props.action.buy_amount_wan) ?? 0)
|
const buyAmount = computed(() => numberFromText(props.action.buy_amount_wan) ?? 0)
|
||||||
const sellAmount = computed(() => numberFromText(props.action.sell_amount_wan) ?? 0)
|
const sellAmount = computed(() => numberFromText(props.action.sell_amount_wan) ?? 0)
|
||||||
|
const participantTraders = computed(() => {
|
||||||
|
const source = props.action.participant_traders?.length
|
||||||
|
? props.action.participant_traders
|
||||||
|
: props.action.trader_name.split(' / ')
|
||||||
|
|
||||||
|
return [...new Set(source.map((item) => item.trim()).filter(Boolean))]
|
||||||
|
})
|
||||||
|
const participantCount = computed(() => props.action.participant_trader_count ?? participantTraders.value.length)
|
||||||
|
const isMultiTraderFocus = computed(() => participantCount.value >= 2)
|
||||||
|
const participantLabel = computed(() => {
|
||||||
|
if (!participantTraders.value.length) return '游资待确认'
|
||||||
|
return participantTraders.value.join(' / ')
|
||||||
|
})
|
||||||
|
|
||||||
function inferMarketLabel(stockCode: string): string {
|
function inferMarketLabel(stockCode: string): string {
|
||||||
return stockCode.startsWith('6') || stockCode.startsWith('688') ? '沪A' : '深A'
|
return stockCode.startsWith('6') || stockCode.startsWith('688') ? '沪A' : '深A'
|
||||||
@ -74,12 +87,16 @@ function followCandidate() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="candidate-card" :class="candidateTone">
|
<article class="candidate-card" :class="[candidateTone, { 'multi-trader-focus': isMultiTraderFocus }]">
|
||||||
<div class="candidate-shell">
|
<div class="candidate-shell">
|
||||||
<div class="candidate-row top-row">
|
<div class="candidate-row top-row">
|
||||||
<button class="candidate-main" type="button" @click="emit('selectStock', action.stock_code)">
|
<button class="candidate-main" type="button" @click="emit('selectStock', action.stock_code)">
|
||||||
<strong class="stock-name">{{ action.stock_name }}</strong>
|
<strong class="stock-name">{{ action.stock_name }}</strong>
|
||||||
<span class="flow-pill">{{ flowSummary }}</span>
|
<span class="flow-pill">{{ flowSummary }}</span>
|
||||||
|
<span v-if="isMultiTraderFocus" class="focus-wrap">
|
||||||
|
<span class="focus-pill">重点游资</span>
|
||||||
|
<span class="focus-tooltip">{{ participantCount }}个游资参与 · {{ participantLabel }}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="follow-button" type="button" @click="followCandidate">
|
<button class="follow-button" type="button" @click="followCandidate">
|
||||||
@ -88,7 +105,7 @@ function followCandidate() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="candidate-row mid-row">
|
<div class="candidate-row mid-row">
|
||||||
<span class="candidate-code">{{ action.stock_code }} · {{ action.trade_date }} · {{ action.trader_name }}</span>
|
<span class="candidate-code">{{ action.stock_code }} · {{ action.trade_date }}</span>
|
||||||
<div class="chip-row" v-if="detailChips.length">
|
<div class="chip-row" v-if="detailChips.length">
|
||||||
<span v-for="chip in detailChips" :key="chip" class="chip">{{ chip }}</span>
|
<span v-for="chip in detailChips" :key="chip" class="chip">{{ chip }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -134,7 +151,7 @@ function followCandidate() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.candidate-card {
|
.candidate-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@ -149,6 +166,13 @@ function followCandidate() {
|
|||||||
background: var(--tone-gradient);
|
background: var(--tone-gradient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.candidate-card.multi-trader-focus {
|
||||||
|
border-color: rgba(240, 192, 113, 0.46);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(240, 192, 113, 0.12),
|
||||||
|
0 10px 24px rgba(240, 192, 113, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.candidate-card.buy-only {
|
.candidate-card.buy-only {
|
||||||
--tone-main: #ff3048;
|
--tone-main: #ff3048;
|
||||||
--tone-soft: rgba(255, 48, 72, 0.18);
|
--tone-soft: rgba(255, 48, 72, 0.18);
|
||||||
@ -185,6 +209,7 @@ function followCandidate() {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
border-radius: 18px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top right, var(--tone-soft), transparent 34%),
|
radial-gradient(circle at top right, var(--tone-soft), transparent 34%),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(7, 11, 17, 0.16));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(7, 11, 17, 0.16));
|
||||||
@ -228,6 +253,7 @@ function followCandidate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flow-pill,
|
.flow-pill,
|
||||||
|
.focus-pill,
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -249,6 +275,47 @@ function followCandidate() {
|
|||||||
border-color: rgba(255, 255, 255, 0.04);
|
border-color: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focus-pill {
|
||||||
|
flex: none;
|
||||||
|
color: #090d14;
|
||||||
|
background: linear-gradient(180deg, var(--color-gold-soft), var(--color-gold));
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
z-index: 8;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(240, 192, 113, 0.24);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: rgba(8, 12, 18, 0.96);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: normal;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition: opacity 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-wrap:hover .focus-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
.chip-row {
|
.chip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
ActionsResponse,
|
ActionsResponse,
|
||||||
PipelineStatus,
|
PipelineStatus,
|
||||||
StockDetail,
|
StockDetail,
|
||||||
|
StockSearchItem,
|
||||||
Summary,
|
Summary,
|
||||||
TraderDetail,
|
TraderDetail,
|
||||||
TraderListItem,
|
TraderListItem,
|
||||||
@ -82,22 +83,30 @@ export function useDashboardData() {
|
|||||||
const groups = new Map<string, ActionItem>()
|
const groups = new Map<string, ActionItem>()
|
||||||
|
|
||||||
for (const item of rows) {
|
for (const item of rows) {
|
||||||
const existing = groups.get(item.stock_code)
|
const groupKey = `${item.stock_code}::${item.trade_date}`
|
||||||
|
const existing = groups.get(groupKey)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
groups.set(item.stock_code, { ...item })
|
groups.set(groupKey, {
|
||||||
|
...item,
|
||||||
|
participant_traders: item.trader_name ? [item.trader_name] : [],
|
||||||
|
participant_trader_count: item.trader_name ? 1 : 0,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextBuy = (numberFromText(existing.buy_amount_wan) ?? 0) + (numberFromText(item.buy_amount_wan) ?? 0)
|
const nextBuy = (numberFromText(existing.buy_amount_wan) ?? 0) + (numberFromText(item.buy_amount_wan) ?? 0)
|
||||||
const nextSell = (numberFromText(existing.sell_amount_wan) ?? 0) + (numberFromText(item.sell_amount_wan) ?? 0)
|
const nextSell = (numberFromText(existing.sell_amount_wan) ?? 0) + (numberFromText(item.sell_amount_wan) ?? 0)
|
||||||
const nextNet = nextBuy - nextSell
|
const nextNet = nextBuy - nextSell
|
||||||
const traderNames = new Set([existing.trader_name, item.trader_name].filter(Boolean))
|
const traderNames = new Set([...(existing.participant_traders ?? [existing.trader_name]), item.trader_name].filter(Boolean))
|
||||||
const tableTitles = new Set([existing.table_title, item.table_title].filter(Boolean))
|
const tableTitles = new Set([existing.table_title, item.table_title].filter(Boolean))
|
||||||
const seatNames = new Set([existing.seat_name, item.seat_name].filter(Boolean))
|
const seatNames = new Set([existing.seat_name, item.seat_name].filter(Boolean))
|
||||||
|
const participantTraders = [...traderNames]
|
||||||
|
|
||||||
groups.set(item.stock_code, {
|
groups.set(groupKey, {
|
||||||
...existing,
|
...existing,
|
||||||
trader_name: [...traderNames].join(' / '),
|
trader_name: participantTraders.join(' / '),
|
||||||
|
participant_traders: participantTraders,
|
||||||
|
participant_trader_count: participantTraders.length,
|
||||||
table_title: [...tableTitles].join(' / '),
|
table_title: [...tableTitles].join(' / '),
|
||||||
seat_name: seatNames.size > 1 ? `${seatNames.size}个席位` : existing.seat_name,
|
seat_name: seatNames.size > 1 ? `${seatNames.size}个席位` : existing.seat_name,
|
||||||
buy_amount_wan: nextBuy.toFixed(2),
|
buy_amount_wan: nextBuy.toFixed(2),
|
||||||
@ -107,7 +116,15 @@ export function useDashboardData() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()]
|
return [...groups.values()].sort((left, right) => {
|
||||||
|
const traderDiff = (right.participant_trader_count ?? 0) - (left.participant_trader_count ?? 0)
|
||||||
|
if (traderDiff !== 0) return traderDiff
|
||||||
|
|
||||||
|
const netDiff = (numberFromText(right.net_amount_wan) ?? 0) - (numberFromText(left.net_amount_wan) ?? 0)
|
||||||
|
if (netDiff !== 0) return netDiff
|
||||||
|
|
||||||
|
return right.trade_date.localeCompare(left.trade_date)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredActions = computed(() => {
|
const filteredActions = computed(() => {
|
||||||
@ -226,6 +243,17 @@ export function useDashboardData() {
|
|||||||
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchStocks(query: string, limit = 8) {
|
||||||
|
const keyword = query.trim()
|
||||||
|
if (!keyword) return [] as StockSearchItem[]
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: keyword,
|
||||||
|
limit: String(limit),
|
||||||
|
})
|
||||||
|
return api<StockSearchItem[]>(`/api/stocks/search?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
async function selectWarning(item: WarningItem) {
|
async function selectWarning(item: WarningItem) {
|
||||||
selectedWarningCode.value = item.stock_code
|
selectedWarningCode.value = item.stock_code
|
||||||
await selectStock(item.stock_code)
|
await selectStock(item.stock_code)
|
||||||
@ -316,6 +344,7 @@ export function useDashboardData() {
|
|||||||
loadWatchlist,
|
loadWatchlist,
|
||||||
selectTrader,
|
selectTrader,
|
||||||
selectStock,
|
selectStock,
|
||||||
|
searchStocks,
|
||||||
selectWarning,
|
selectWarning,
|
||||||
selectTradeDateRange,
|
selectTradeDateRange,
|
||||||
isWatched,
|
isWatched,
|
||||||
|
|||||||
@ -20,6 +20,13 @@ export interface WarningItem {
|
|||||||
created_at?: string
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StockSearchItem {
|
||||||
|
stock_code: string
|
||||||
|
stock_name: string
|
||||||
|
market?: string | null
|
||||||
|
industry?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
trade_date: string
|
trade_date: string
|
||||||
stock_code: string
|
stock_code: string
|
||||||
@ -38,6 +45,8 @@ export interface ActionItem {
|
|||||||
total_market_value?: number | null
|
total_market_value?: number | null
|
||||||
circulating_market_value?: number | null
|
circulating_market_value?: number | null
|
||||||
action_side: 'buy' | 'sell' | 'net_buy' | 'net_sell'
|
action_side: 'buy' | 'sell' | 'net_buy' | 'net_sell'
|
||||||
|
participant_traders?: string[]
|
||||||
|
participant_trader_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsResponse {
|
export interface ActionsResponse {
|
||||||
|
|||||||
@ -42,6 +42,10 @@ export function compactMoney(value: string | number | null | undefined): string
|
|||||||
return `${parsed.toFixed(0)}`
|
return `${parsed.toFixed(0)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimDecimalText(value: string): string {
|
||||||
|
return value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(value: string | null | undefined): string {
|
export function formatDate(value: string | null | undefined): string {
|
||||||
return value || '-'
|
return value || '-'
|
||||||
}
|
}
|
||||||
@ -51,7 +55,8 @@ export function formatWanAmount(value: string | number | null | undefined): stri
|
|||||||
if (parsed === null) return '-'
|
if (parsed === null) return '-'
|
||||||
|
|
||||||
if (Math.abs(parsed) >= 10000) {
|
if (Math.abs(parsed) >= 10000) {
|
||||||
return `${(parsed / 10000).toFixed(Math.abs(parsed) >= 100000 ? 1 : 0)}亿`
|
const decimals = Math.abs(parsed) >= 100000 ? 1 : 2
|
||||||
|
return `${trimDecimalText((parsed / 10000).toFixed(decimals))}亿`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(parsed) >= 1) {
|
if (Math.abs(parsed) >= 1) {
|
||||||
|
|||||||
272
frontend/src/utils/stockDetailKline.ts
Normal file
272
frontend/src/utils/stockDetailKline.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import type { MarketDailyRow, TraderAction } from '../types'
|
||||||
|
|
||||||
|
import { numberFromText } from './format'
|
||||||
|
|
||||||
|
export type KlineActionDetail = {
|
||||||
|
tradeDate: string
|
||||||
|
matchedTraderName: string
|
||||||
|
seatName: string
|
||||||
|
tableTitle: string
|
||||||
|
buyAmountWan: number
|
||||||
|
sellAmountWan: number
|
||||||
|
netAmountWan: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KlineActionSummary = {
|
||||||
|
tradeDate: string
|
||||||
|
traderCount: number
|
||||||
|
seatCount: number
|
||||||
|
totalBuyWan: number
|
||||||
|
totalSellWan: number
|
||||||
|
totalNetWan: number
|
||||||
|
hasBuy: boolean
|
||||||
|
hasSell: boolean
|
||||||
|
dominantSide: 'buy' | 'sell' | 'mixed'
|
||||||
|
allActions: KlineActionDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KlineCandle = MarketDailyRow & {
|
||||||
|
openNum: number
|
||||||
|
closeNum: number
|
||||||
|
highNum: number
|
||||||
|
lowNum: number
|
||||||
|
x: number
|
||||||
|
wickTop: number
|
||||||
|
wickBottom: number
|
||||||
|
bodyTop: number
|
||||||
|
bodyBottom: number
|
||||||
|
bodyHeight: number
|
||||||
|
bodyWidth: number
|
||||||
|
direction: 'rise' | 'fall'
|
||||||
|
actionSummary: KlineActionSummary | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KlineMarker = {
|
||||||
|
tradeDate: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
label: string
|
||||||
|
width: number
|
||||||
|
candleIndex: number
|
||||||
|
hasBuy: boolean
|
||||||
|
hasSell: boolean
|
||||||
|
dominantSide: 'buy' | 'sell' | 'mixed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KlineChartModel = {
|
||||||
|
candles: KlineCandle[]
|
||||||
|
markers: KlineMarker[]
|
||||||
|
axis: Array<{ y: number; label: string }>
|
||||||
|
labels: Array<{ x: number; label: string; visible: boolean }>
|
||||||
|
ma5: Array<{ x: number; y: number }>
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
top: number
|
||||||
|
bottom: number
|
||||||
|
innerHeight: number
|
||||||
|
hoverBandWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_WIDTH = 1120
|
||||||
|
const CHART_HEIGHT = 360
|
||||||
|
const PADDING_LEFT = 52
|
||||||
|
const PADDING_RIGHT = 16
|
||||||
|
const PADDING_TOP = 18
|
||||||
|
const PADDING_BOTTOM = 34
|
||||||
|
|
||||||
|
export function normalizeMarketRows(rows: MarketDailyRow[]): Array<
|
||||||
|
MarketDailyRow & {
|
||||||
|
openNum: number
|
||||||
|
closeNum: number
|
||||||
|
highNum: number
|
||||||
|
lowNum: number
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
openNum: numberFromText(row.open) ?? 0,
|
||||||
|
closeNum: numberFromText(row.close) ?? 0,
|
||||||
|
highNum: numberFromText(row.high) ?? 0,
|
||||||
|
lowNum: numberFromText(row.low) ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildActionSummaryMap(actions: TraderAction[]): Map<string, KlineActionSummary> {
|
||||||
|
const grouped = new Map<string, KlineActionDetail[]>()
|
||||||
|
|
||||||
|
for (const action of actions) {
|
||||||
|
const list = grouped.get(action.trade_date) ?? []
|
||||||
|
list.push({
|
||||||
|
tradeDate: action.trade_date,
|
||||||
|
matchedTraderName: action.matched_trader_name,
|
||||||
|
seatName: action.seat_name,
|
||||||
|
tableTitle: action.table_title,
|
||||||
|
buyAmountWan: numberFromText(action.buy_amount_wan) ?? 0,
|
||||||
|
sellAmountWan: numberFromText(action.sell_amount_wan) ?? 0,
|
||||||
|
netAmountWan: numberFromText(action.net_amount_wan) ?? 0,
|
||||||
|
})
|
||||||
|
grouped.set(action.trade_date, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryMap = new Map<string, KlineActionSummary>()
|
||||||
|
|
||||||
|
for (const [tradeDate, list] of grouped.entries()) {
|
||||||
|
const sorted = [...list].sort((left, right) => {
|
||||||
|
const absDiff = Math.abs(right.netAmountWan) - Math.abs(left.netAmountWan)
|
||||||
|
if (absDiff !== 0) return absDiff
|
||||||
|
|
||||||
|
const flowDiff = right.buyAmountWan + right.sellAmountWan - (left.buyAmountWan + left.sellAmountWan)
|
||||||
|
if (flowDiff !== 0) return flowDiff
|
||||||
|
|
||||||
|
return left.matchedTraderName.localeCompare(right.matchedTraderName, 'zh-CN')
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalBuyWan = sorted.reduce((sum, item) => sum + item.buyAmountWan, 0)
|
||||||
|
const totalSellWan = sorted.reduce((sum, item) => sum + item.sellAmountWan, 0)
|
||||||
|
const totalNetWan = sorted.reduce((sum, item) => sum + item.netAmountWan, 0)
|
||||||
|
const hasBuy = sorted.some((item) => item.buyAmountWan > 0)
|
||||||
|
const hasSell = sorted.some((item) => item.sellAmountWan > 0)
|
||||||
|
|
||||||
|
let dominantSide: 'buy' | 'sell' | 'mixed' = 'mixed'
|
||||||
|
if (totalBuyWan > totalSellWan) dominantSide = 'buy'
|
||||||
|
if (totalSellWan > totalBuyWan) dominantSide = 'sell'
|
||||||
|
|
||||||
|
summaryMap.set(tradeDate, {
|
||||||
|
tradeDate,
|
||||||
|
traderCount: new Set(sorted.map((item) => item.matchedTraderName)).size,
|
||||||
|
seatCount: sorted.length,
|
||||||
|
totalBuyWan,
|
||||||
|
totalSellWan,
|
||||||
|
totalNetWan,
|
||||||
|
hasBuy,
|
||||||
|
hasSell,
|
||||||
|
dominantSide,
|
||||||
|
allActions: sorted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaryMap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKlineChartModel(
|
||||||
|
rows: Array<
|
||||||
|
MarketDailyRow & {
|
||||||
|
openNum: number
|
||||||
|
closeNum: number
|
||||||
|
highNum: number
|
||||||
|
lowNum: number
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
actionSummaryMap: Map<string, KlineActionSummary>,
|
||||||
|
): KlineChartModel {
|
||||||
|
const emptyModel: KlineChartModel = {
|
||||||
|
candles: [],
|
||||||
|
markers: [],
|
||||||
|
axis: [],
|
||||||
|
labels: [],
|
||||||
|
ma5: [],
|
||||||
|
width: CHART_WIDTH,
|
||||||
|
height: CHART_HEIGHT,
|
||||||
|
left: PADDING_LEFT,
|
||||||
|
right: PADDING_RIGHT,
|
||||||
|
top: PADDING_TOP,
|
||||||
|
bottom: PADDING_BOTTOM,
|
||||||
|
innerHeight: CHART_HEIGHT - PADDING_TOP - PADDING_BOTTOM,
|
||||||
|
hoverBandWidth: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) return emptyModel
|
||||||
|
|
||||||
|
const innerWidth = CHART_WIDTH - PADDING_LEFT - PADDING_RIGHT
|
||||||
|
const innerHeight = CHART_HEIGHT - PADDING_TOP - PADDING_BOTTOM
|
||||||
|
const slotWidth = innerWidth / rows.length
|
||||||
|
const hoverBandWidth = Math.max(12, slotWidth)
|
||||||
|
const bodyWidth = Math.max(5, Math.min(14, slotWidth * 0.54))
|
||||||
|
const prices = rows.flatMap((item) => [item.highNum, item.lowNum])
|
||||||
|
const minPrice = Math.min(...prices)
|
||||||
|
const maxPrice = Math.max(...prices)
|
||||||
|
|
||||||
|
const yOf = (price: number) =>
|
||||||
|
PADDING_TOP +
|
||||||
|
(maxPrice === minPrice ? innerHeight / 2 : ((maxPrice - price) / (maxPrice - minPrice)) * innerHeight)
|
||||||
|
|
||||||
|
const candles = rows.map((row, index) => {
|
||||||
|
const x = PADDING_LEFT + slotWidth * index + slotWidth / 2
|
||||||
|
const openY = yOf(row.openNum)
|
||||||
|
const closeY = yOf(row.closeNum)
|
||||||
|
const bodyTop = Math.min(openY, closeY)
|
||||||
|
const bodyBottom = Math.max(openY, closeY)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
x,
|
||||||
|
wickTop: yOf(row.highNum),
|
||||||
|
wickBottom: yOf(row.lowNum),
|
||||||
|
bodyTop,
|
||||||
|
bodyBottom,
|
||||||
|
bodyHeight: Math.max(3, bodyBottom - bodyTop),
|
||||||
|
bodyWidth,
|
||||||
|
direction: row.closeNum >= row.openNum ? 'rise' : 'fall',
|
||||||
|
actionSummary: actionSummaryMap.get(row.trade_date) ?? null,
|
||||||
|
} satisfies KlineCandle
|
||||||
|
})
|
||||||
|
|
||||||
|
const ma5 = candles.map((candle, index) => {
|
||||||
|
const slice = candles.slice(Math.max(0, index - 4), index + 1)
|
||||||
|
const average = slice.reduce((sum, item) => sum + item.closeNum, 0) / slice.length
|
||||||
|
return { x: candle.x, y: yOf(average) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const markers = candles
|
||||||
|
.map((candle, candleIndex) => {
|
||||||
|
const summary = candle.actionSummary
|
||||||
|
if (!summary) return null
|
||||||
|
|
||||||
|
const label = String(summary.traderCount)
|
||||||
|
return {
|
||||||
|
tradeDate: candle.trade_date,
|
||||||
|
x: candle.x,
|
||||||
|
y: Math.max(PADDING_TOP + 18, candle.wickTop - 24),
|
||||||
|
label,
|
||||||
|
width: label.length >= 2 ? 32 : 24,
|
||||||
|
candleIndex,
|
||||||
|
hasBuy: summary.hasBuy,
|
||||||
|
hasSell: summary.hasSell,
|
||||||
|
dominantSide: summary.dominantSide,
|
||||||
|
} satisfies KlineMarker
|
||||||
|
})
|
||||||
|
.filter((marker): marker is KlineMarker => Boolean(marker))
|
||||||
|
|
||||||
|
const labelStep = Math.max(1, Math.ceil(rows.length / 8))
|
||||||
|
const axis = Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const price = maxPrice - ((maxPrice - minPrice) * index) / 4
|
||||||
|
return {
|
||||||
|
y: PADDING_TOP + (innerHeight * index) / 4,
|
||||||
|
label: price.toFixed(2),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = candles.map((candle, index) => ({
|
||||||
|
x: candle.x,
|
||||||
|
label: candle.trade_date.slice(5),
|
||||||
|
visible: index % labelStep === 0 || index === candles.length - 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
candles,
|
||||||
|
markers,
|
||||||
|
axis,
|
||||||
|
labels,
|
||||||
|
ma5,
|
||||||
|
width: CHART_WIDTH,
|
||||||
|
height: CHART_HEIGHT,
|
||||||
|
left: PADDING_LEFT,
|
||||||
|
right: PADDING_RIGHT,
|
||||||
|
top: PADDING_TOP,
|
||||||
|
bottom: PADDING_BOTTOM,
|
||||||
|
innerHeight,
|
||||||
|
hoverBandWidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user