重新设计复盘页面并优化页面
@@ -45,10 +45,14 @@ backend/data/**
|
||||
!backend/data/.gitkeep
|
||||
data/**
|
||||
!data/.gitkeep
|
||||
# 扩展数据: 全部纳入版本控制(含 config.json 表结构 + part.parquet 数据文件)
|
||||
# 注意 gitignore 否定规则特性: 需先放行目录才能放行内部文件
|
||||
# 扩展数据: 仅放行 ext_gn_ths(概念)与 ext_hy_ths(行业)两个数据源
|
||||
# 含 config.json 表结构 + part.parquet 数据文件;其余 ext_data 子目录均不入库
|
||||
# (gitignore 否定规则特性: 要放行嵌套文件,需逐级放行目录 + /** 放行内部文件)
|
||||
!data/ext_data/
|
||||
!data/ext_data/**
|
||||
!data/ext_data/ext_gn_ths/
|
||||
!data/ext_data/ext_gn_ths/**
|
||||
!data/ext_data/ext_hy_ths/
|
||||
!data/ext_data/ext_hy_ths/**
|
||||
|
||||
# ===== Secrets =====
|
||||
.env
|
||||
|
||||
@@ -11,32 +11,24 @@
|
||||
[](./Dockerfile)
|
||||
[](https://github.com/shy3130/tickflow-stock-panel/stargazers)
|
||||
|
||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据 · 🚀 **开箱即用**(单容器 / None 模式)
|
||||
能力驱动,适配 None → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(例如 Tushare、自有量化项目数据)
|
||||
|
||||
**[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
||||
|
||||
</div>
|
||||
|
||||
> **⚠️说明**:目前项目基于[TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA)数据源。无需付费即可使用策略+回测等功能.自有个性化数据源可接入扩展数据使用。
|
||||
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
||||
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
||||
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
||||
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
||||
- 🔌 **自由扩展** — 接入 Tushare(开发中) / 自有量化项目数据,与内置数据同台分析
|
||||
- 🇨🇳 **A 股专用** — 连板梯队、涨停动量、内置ths 概念 / 行业
|
||||
|
||||
如果觉得有用,请Star支持一下,蟹蟹🌹
|
||||
> 基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
||||
|
||||
觉得有用可以点个 Star,蟹蟹 🌹
|
||||
|
||||
---
|
||||
|
||||
## 🎯 项目定位
|
||||
|
||||
让任何**个人散户 / 量化爱好者**,**零运维**地拥有一套**属于自己策略定制**的 A 股分析、选股、监控工作台。
|
||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) Key **零成本**获取数据,并使用策略定制+监控+回测功能。
|
||||
**内置ths概念、ths行业数据**。可接入第三方自有个性化扩展数据(人气、资金流向等)。
|
||||
|
||||
**项目所需配置**:
|
||||
|
||||
| 配置项 | 说明 | 是否必填 |
|
||||
| :--- | :--- | :--- |
|
||||
| **TickFlow API Key** | 数据源凭证,留空启用 None 模式;免费注册 Key 后进入 Free 模式,可使用历史日K与自选股实时监控 | 可选 |
|
||||
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
|
||||
## 📸 界面预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -44,140 +36,32 @@
|
||||
<td width="50%" align="center"><b>策略 Screener</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="./docs/screenshots/dashboard.png" alt="看板页面" title="看板页面"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/screener.png" alt="策略页" title="策略页"></td>
|
||||
<td width="50%"><img src="./screenshots/dashboard.png" alt="看板页面"></td>
|
||||
<td width="50%"><img src="./screenshots/screener.png" alt="策略页"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>回测 Backtest</b></td>
|
||||
<td width="50%" align="center"><b>监控中心 Monitor</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="./docs/screenshots/backtest.png" alt="回测页" title="回测页"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/monitor.png" alt="监控中心" title="监控中心"></td>
|
||||
|
||||
<td width="50%"><img src="./screenshots/backtest.png" alt="回测页"></td>
|
||||
<td width="50%"><img src="./screenshots/monitor.png" alt="监控中心"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>连板梯队 Limit Ladder</b></td>
|
||||
<td width="50%" align="center"><b>概念分析 Concept</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="./docs/screenshots/limit-ladder.png" alt="连板梯队页" title="连板梯队页"></td>
|
||||
<td width="50%"><img src="./docs/screenshots/concept-analysis.png" alt="概念分析" title="概念分析"></td>
|
||||
<td width="50%"><img src="./screenshots/limit-ladder.png" alt="连板梯队页"></td>
|
||||
<td width="50%"><img src="./screenshots/concept-analysis.png" alt="概念分析"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> ### ⚠️ 🚧 项目持续优化,功能陆续开放,敬请期待。
|
||||
<div align="center">
|
||||
|
||||
> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。
|
||||
### 📸 [查看更多界面截图 »](./screenshots/README.md)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🔍 选股引擎(Screener)
|
||||
|
||||
**20 个内置策略** —— 每个策略是一个独立 Python 文件(`backend/app/strategy/builtin/`),基于 Polars 表达式实现:
|
||||
|
||||
| 类型 | 代表策略 |
|
||||
| :--- | :--- |
|
||||
| 趋势 | 趋势突破 · 均线多头 · 缩量回踩 |
|
||||
| 形态 | MA 金叉 · MACD 金叉放量 · 布林突破 |
|
||||
| 量价 | 量价齐升 · 高换手强势 · 强势高开 |
|
||||
| 涨停 | 连板股 · 断板反包 · 逼近涨停 · 涨停动量 |
|
||||
| 反转 | 超跌反弹 · 超卖反转 · 新低反转 |
|
||||
| 波动 | 低波动龙头 · 回踩 MA20 反弹 |
|
||||
|
||||
- **自定义信号系统** —— 在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,**无需写代码**即可定义自己的买卖信号。
|
||||
- **策略商店** —— 内置策略 + 用户自定义策略统一管理,支持参数覆盖(`params` 暴露阈值)。
|
||||
|
||||
#### ➕ 添加自己的策略
|
||||
|
||||
除 20 个内置策略外,你可以用两种方式扩展:
|
||||
|
||||
| 方式 | 说明 | 前提 |
|
||||
| :--- | :--- | :--- |
|
||||
| **🤖 AI 生成** | 用自然语言描述策略思路,LLM 读取 [strategy-guide.md](./docs/strategy-guide.md) 自动生成完整 Polars 策略文件(经 `ast` 安全校验,限定 `import polars as pl`)。生成后落入 `data/strategies/ai/`,即刻可用 | 需先在 [配置](#️-配置) 中填入 AI Key |
|
||||
| **📝 代码自定义 / 策略迁移** | 参照 [策略开发指南](./docs/strategy-guide.md) 的文件结构模板,把你**已有的自有策略**改写为 Polars 文件放入 `data/strategies/custom/`(文件名/ID 建议 `custom_时间戳`),引擎自动发现加载——**轻松迁移你现成的量化项目策略**,无需从头重写 | 无 |
|
||||
| **🎛️ 自定义信号配置** | 不写代码,在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,即可定义自己的买卖信号 | 无 |
|
||||
|
||||
> 引擎按 `source` 标记来源:`builtin`(内置)/ `custom`(手写或迁移)/ `ai`(生成),三者统一进入策略商店管理。
|
||||
|
||||
### 📊 指标流水线(Indicators)
|
||||
|
||||
原生 Polars 向量化计算,全 A 股一次扫表落盘为 enriched Parquet:
|
||||
|
||||
| 分类 | 指标 |
|
||||
| :--- | :--- |
|
||||
| 均线系 | MA(5/10/20/30/60)· EMA(5/10/12/20/26/30/60) |
|
||||
| 趋势系 | MACD(DIF/DEA/HIST)· 动量(5/10/20/30/60d)· 布林带(上/下轨) |
|
||||
| 震荡系 | RSI(可配周期)· KDJ(K/D/J) |
|
||||
| 波动系 | ATR(14)· 年化波动率(20d)· 振幅 |
|
||||
| 量能系 | 量比(5d/10d)· 量均线 |
|
||||
| 涨跌停 | 涨停信号 · 连板数 · 涨跌幅 · 涨跌额 |
|
||||
| 原子信号 | MA 金叉/死叉 · MA20 突破/跌破 · MACD 金叉/死叉 · N 日新高/新低 · 布林突破 |
|
||||
| 复权 | 基于除权因子自动计算前复权(`ex_factor` / `cum_factor`),回测与指标一致 |
|
||||
|
||||
### 🧪 回测引擎(Backtest)
|
||||
|
||||
基于 vectorbt:
|
||||
|
||||
- **三种回测模式**:个股 · 策略组合 · 自由信号组合
|
||||
- **真实约束**:T+1 · 手续费 · 滑点(基点) · 止损 · 最大持仓天数
|
||||
- **组合管理**:最大持仓数 · 最大敞口 · 等权 / 自定义仓位
|
||||
- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次)
|
||||
- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细
|
||||
|
||||
### 📡 监控中心(Monitor)
|
||||
|
||||
**统一监控规则引擎** —— 一个页面管理所有类型的监控,实时推送 + 持久化触发记录:
|
||||
|
||||
- **四类监控**:策略监控 · 个股信号监控(选信号即加) · 个股价格/涨跌监控 · 全市场异动监控
|
||||
- **灵活条件**:多条件 AND/OR 组合 + 冷却期去重(防刷屏) + 严重级别(info/warn/critical)
|
||||
- **多入口配置**:监控中心页面新建规则 · 个股详情页「加监控」· 策略卡片一键开启
|
||||
- **实时 SSE 推送**:命中规则后右下角弹窗通知(可配声效) + 持久化到 `alerts.jsonl`
|
||||
- **触发记录**:时间倒序展示,支持按来源过滤 · 单条删除 · 清空 · 点击查看个股日K
|
||||
- **菜单未读徽标**:离开监控中心后有新触发,菜单显示未读数;进入页面后清零
|
||||
|
||||
### 🤖 AI 策略生成(可选)
|
||||
|
||||
- **自然语言 → 策略代码**:用一句话描述策略思路,LLM 读取 `docs/strategy-guide.md` 生成完整 Polars 策略文件
|
||||
- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达
|
||||
- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能
|
||||
|
||||
### 📈 个股分析(Beta)
|
||||
|
||||
**以「行情 + 关键价位」为视觉主体的单标的决策页**,与「财务分析」(财务质量评级)定位互补:
|
||||
|
||||
- **专用日 K 图表**:不复用行情浏览图表,主图 + 成交量 + 滑块三段布局,默认展示近 6 个月,9 类关键价位可逐组开关
|
||||
- **9 类关键价位**(均纯函数实时计算,毫秒级):
|
||||
|
||||
| 类型 | 算法 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| 压力支撑 | 布林带上下轨 | 近期波动边界 |
|
||||
| 成交密集区 | 成交量分布 POC + 高成交带 | 筹码密集价位 |
|
||||
| 枢轴点 | 经典 Pivot P/R1~R3/S1~S3 | 可配档位(1~3 档) |
|
||||
| 前高前低 | 60/250 日极值 + swing 高低点 | 历史转折参照 |
|
||||
| Keltner 通道 | MA20/60/120 ± n×ATR(短/中/长) | 波动自适应趋势边界 |
|
||||
| ATR 止损 | close ± 1.5/2×ATR | 动态止盈止损位 |
|
||||
| 缺口位 | 近 120 日未回补跳空缺口 | 天然支撑/阻力 |
|
||||
| 斐波那契 | 近期波段 0.236~0.786 回撤 | 经典回撤位 |
|
||||
| 整数关口 | 当前价附近心理整数位 | 自适应步长 |
|
||||
|
||||
- **AI 四维分析**:技术面 / 基本面 / 财务面 / 消息面流式生成,NDJSON 推送,「实战派交易员」视角输出买卖区间与操作建议
|
||||
- **蓝色胶囊**:与财务分析(紫色)并存的全局气泡,支持最小化后台生成、复制全文、历史报告(最多 50 条)
|
||||
- **记忆最近查看**:进入页面自动回显上次查看的个股
|
||||
|
||||
### 🧰 数据与扩展
|
||||
|
||||
- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 实时行情
|
||||
- **实时行情分档**:None 仅历史日K(当日数据通常盘后 1-2 小时可用);Free 可监控自选页前 5 个标的(最低 6 秒刷新);Starter+ 使用全市场实时行情
|
||||
- **🔌 第三方数据接入(重点)** —— TickFlow 之外的数据也能用:
|
||||
- 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库
|
||||
- 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一
|
||||
- **页面可视化配置**扩展数据表,无需改代码
|
||||
- 可接入**你自己的量化项目数据**,统一并入 DuckDB 查询面,与内置数据同台分析
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched 表 + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch 上限,批量合并 + 增量拉取,同一份数据多面板复用
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -186,118 +70,145 @@
|
||||
### 前置依赖
|
||||
|
||||
| 工具 | 版本 | 安装 |
|
||||
| :--- | :--- | :--- |
|
||||
| :--------------------------------- | :----- | :------------------------------------------------- |
|
||||
| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
|
||||
| Node | ≥ 20 | [nodejs.org](https://nodejs.org/) |
|
||||
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| `pnpm` | 9 | `npm i -g pnpm` 或 `corepack enable && corepack prepare pnpm@9 --activate` |
|
||||
| `pnpm` | 9 | `npm i -g pnpm` |
|
||||
|
||||
|
||||
|
||||
### 方式 A:Dev 模式(二次开发,最推荐)
|
||||
### 方式 A:Dev 模式(二次开发推荐)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 None 模式
|
||||
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
||||
./dev.sh # Windows: .\dev.ps1
|
||||
```
|
||||
|
||||
**一键启动**(推荐,自动检查\下载依赖 / 释放端口 / 同时起前后端,Ctrl-C 一并关闭):
|
||||
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
||||
|
||||
| 平台 | 命令 |
|
||||
| :--- | :--- |
|
||||
| **macOS / Linux** | `./dev.sh` |
|
||||
| **Windows (PowerShell)** | `.\dev.ps1` |
|
||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
|
||||
|
||||
|
||||
### 方式 B:Docker(最省心,可部署)
|
||||
### 方式 B:Docker(部署最省心)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 按需填写 Key(留空即 None 模式,可直接体验历史日K)
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
如果你的 VPS / 物理机 CPU 较老,`docker compose up --build` 时出现类似
|
||||
`Missing required CPU features: avx2, fma, bmi1...` 或容器 `exit code 132`,
|
||||
先在 `.env` 里打开 Polars 兼容运行时再重建:
|
||||
|
||||
```ini
|
||||
BACKEND_EXTRAS=legacy-cpu
|
||||
# 需要回测可同时启用(空格分隔):BACKEND_EXTRAS=legacy-cpu backtest
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
`legacy-cpu` 会让后端安装 `polars[rtcompat]`,适用于不支持 AVX2/FMA 的老 CPU;
|
||||
默认留空则保持 Polars 高性能运行时。该 wheel 约 50MB,启用后镜像体积会相应增大。
|
||||
|
||||
|
||||
首次运行会自动安装前后端依赖(约 1-2 分钟),之后直接启动:
|
||||
|
||||
- 后端 → <http://localhost:3018>
|
||||
- 前端 → <http://localhost:3011>
|
||||
|
||||
自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`(Windows:`.\dev.ps1 -BackendPort 8000 -FrontendPort 5173`)
|
||||
|
||||
<details>
|
||||
<summary>手动分别启动(备选)</summary>
|
||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
||||
|
||||
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。
|
||||
|
||||
**手动分别启动:**
|
||||
|
||||
```bash
|
||||
# 终端 1:后端
|
||||
cd backend
|
||||
uv sync
|
||||
# 后端
|
||||
cd backend && uv sync --extra backtest # 含回测依赖
|
||||
uv run uvicorn app.main:app --reload --port 3018
|
||||
|
||||
# 终端 2:前端
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:3011
|
||||
# 前端
|
||||
cd frontend && pnpm install && pnpm dev # http://localhost:3011
|
||||
```
|
||||
|
||||
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
|
||||
|
||||
</details>
|
||||
|
||||
> **启用回测**:`cd backend && uv sync --extra backtest`
|
||||
> vectorbt → numba 体积较大,故作为可选 extras。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
|
||||
### 🧭 跑起来后的第一次使用
|
||||
|
||||
> **老 CPU 兼容**:`cd backend && uv sync --extra legacy-cpu`
|
||||
> 当机器缺少 `avx2/fma` 等指令集时,给 Polars 切到 `rtcompat` 运行时。
|
||||
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
||||
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
||||
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
|
||||
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||
|
||||
---
|
||||
|
||||
## 🧭 第一次使用
|
||||
## ✨ 核心功能
|
||||
|
||||
1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label
|
||||
2. 点 **立即跑盘后管道** —— 拉日 K + 计算 enriched 表
|
||||
- **None / Free 用户**:历史日K走 free-api 通道;当日数据通常盘后 1-2 小时可用
|
||||
- **Starter+**:同步全 A 或可获取的 instruments 列表
|
||||
3. **自选**页:添加跟踪标的;Free 档实时行情会自动监控自选页前 5 个标的,可用「移到顶部」调整优先级;点代码进 **K 线**页看蜡烛图 + 买卖点
|
||||
4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件
|
||||
5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加
|
||||
### 🔍 选股引擎(Screener)
|
||||
|
||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||
|
||||
| 类型 | 代表策略 |
|
||||
| :---------- | :------------------------------------------------------- |
|
||||
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
|
||||
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
||||
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
||||
|
||||
**扩展策略的三种方式:**
|
||||
|
||||
| 方式 | 说明 |
|
||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||
|
||||
### 📊 指标流水线(Indicators)
|
||||
|
||||
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
||||
|
||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||
|
||||
### 🧪 回测引擎(Backtest)
|
||||
|
||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||
|
||||
### 📡 监控中心(Monitor)
|
||||
|
||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||
|
||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
|
||||
### 📈 个股分析(Beta)
|
||||
|
||||
以「行情 + 关键价位」为主体的单标的决策页:
|
||||
|
||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||
|
||||
### 🧰 数据与扩展
|
||||
|
||||
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
||||
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
所有配置通过项目根目录的 `.env` 文件读取(复制 `.env.example` 开始)。配置也可在面板 **设置** 页面内修改。
|
||||
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
|
||||
|
||||
### 数据源:TickFlow
|
||||
|
||||
TickFlow 提供订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 None 模式,可通过 free-api 使用历史日K;当日数据通常需盘后 1-2 小时可用**。免费注册并填写 Key 后进入 Free 模式,可开启自选股实时监控。
|
||||
|
||||
```ini
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式;填入 Key = 按订阅档位解锁
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
|
||||
```
|
||||
|
||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)。系统启动时会自动探测你的真实能力集,UI 显示「Free / Starter / Pro / Expert」等友好标签。高等档位包含较低档位的全部权益。
|
||||
>
|
||||
> 当前面板使用的实时能力:Free = 自选页前 5 个标的实时监控(最低 6 秒刷新);Starter+ = 全市场实时行情;Pro = 分钟K + 盘口;Expert = WebSocket + 财务数据。
|
||||
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
|
||||
|
||||
### AI(可选):策略生成
|
||||
| 档位 | 实时能力 |
|
||||
| :------- | :--------------------------------------- |
|
||||
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
|
||||
| Starter+ | 全市场实时行情 |
|
||||
| Pro | 分钟 K + 盘口 |
|
||||
| Expert | WebSocket + 财务数据 |
|
||||
|
||||
AI 模块用于「自然语言生成策略代码」。**所有配置留空即跳过 AI 功能,不影响核心使用**。支持任何 **OpenAI 兼容接口**:
|
||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
||||
|
||||
### AI(可选)
|
||||
|
||||
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
||||
|
||||
```ini
|
||||
AI_PROVIDER=openai_compat # openai_compat | ollama
|
||||
@@ -307,8 +218,6 @@ AI_MODEL=deepseek-chat
|
||||
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
|
||||
```
|
||||
|
||||
> 切换 `AI_PROVIDER=ollama` 时无需 `AI_API_KEY`,适合本地部署大模型。
|
||||
|
||||
### 服务与数据
|
||||
|
||||
```ini
|
||||
@@ -323,13 +232,13 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
## 🏗️ 技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
| :--- | :--- |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------ |
|
||||
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储)· PyArrow |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK(`tickflow[all]`) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind CSS · Framer Motion · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
|
||||
---
|
||||
@@ -337,35 +246,21 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
## 🗺️ 路线图
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
| :--- | :--- | :--- |
|
||||
| **0** | 仓库骨架 / FastAPI 壳 / Vite + React SPA / Docker 一键起 | ✅ |
|
||||
| **1** | 能力探测 + Kline 同步 + K 线分析页 | ✅ |
|
||||
| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ |
|
||||
| **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ |
|
||||
| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
|
||||
| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ |
|
||||
| **6** | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析 + 报告持久化) | ✅ |
|
||||
| **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||
| :----- | :----------------------------------------------------------------- | :--- |
|
||||
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
|
||||
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
|
||||
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
|
||||
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
|
||||
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
## 📚 文档与贡献
|
||||
|
||||
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成器与手写策略的规范)
|
||||
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
|
||||
- [docs/](./docs) —— 策略构建步骤、示例
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎 Issue 和 PR。本地开发:
|
||||
|
||||
```bash
|
||||
cd backend && uv sync --extra backtest # 含回测依赖
|
||||
cd ../frontend && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
新增内置策略:在 `backend/app/strategy/builtin/` 参照现有策略文件,实现 `StrategyDef` 即可被引擎自动发现。
|
||||
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
|
||||
|
||||
---
|
||||
|
||||
@@ -373,13 +268,9 @@ cd ../frontend && pnpm install && pnpm dev
|
||||
|
||||
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors
|
||||
|
||||
本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.60"
|
||||
__version__ = "0.1.61"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -24,6 +24,15 @@ from app.services.market_overview_builder import build_market_overview
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。
|
||||
_INDEX_SHORT = {
|
||||
"上证指数": "上",
|
||||
"深证成指": "深",
|
||||
"创业板指": "创",
|
||||
"科创综指": "科",
|
||||
"科创50": "科",
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# 系统提示词(市场策略师人格 + 固定七节模板)
|
||||
# ================================================================
|
||||
@@ -54,7 +63,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深
|
||||
成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。
|
||||
|
||||
### 6. 📰 消息催化
|
||||
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动。明确区分"已兑现"与"待发酵"。**若提供了"无新闻数据"的说明,则本节基于量价异动进行[推断],并如实标注,不要编造具体消息。**
|
||||
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现"与"待发酵"。**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息。**
|
||||
|
||||
### 7. 🎯 明日交易计划
|
||||
- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调
|
||||
@@ -68,6 +77,7 @@ _SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论。不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语——直接给结论。读者要的是复盘结果,不是你怎么推导出来的。
|
||||
1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%")
|
||||
2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断
|
||||
3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息
|
||||
@@ -202,7 +212,7 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
|
||||
"",
|
||||
"## 近期市场新闻",
|
||||
"(暂无新闻数据:本功能新闻检索能力将在后续版本接入。"
|
||||
"请按系统提示词第 6 节的说明,基于量价异动进行[推断],并如实标注,不要编造具体消息。)",
|
||||
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
|
||||
])
|
||||
|
||||
if focus.strip():
|
||||
@@ -216,7 +226,10 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
|
||||
# ================================================================
|
||||
|
||||
def _recap_summary(overview: dict) -> str:
|
||||
"""一句话摘要(供 meta 事件与历史列表展示)。"""
|
||||
"""一句话摘要(供 meta 事件与历史列表展示)。
|
||||
|
||||
指数用简称(上/深/创/科),与前端摘要条一致,避免列表里全称放不下。
|
||||
"""
|
||||
indices = overview.get("indices") or []
|
||||
emo = overview.get("emotion") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
@@ -224,8 +237,8 @@ def _recap_summary(overview: dict) -> str:
|
||||
total_amount = (amt.get("total") or 0) / 1e8
|
||||
|
||||
idx_str = "、".join(
|
||||
f"{(i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%"
|
||||
for i in indices[:3]
|
||||
f"{_INDEX_SHORT.get(i.get('name') or '', i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%"
|
||||
for i in indices[:4]
|
||||
) or "指数缺失"
|
||||
return (
|
||||
f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','—')}) | "
|
||||
|
||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_REPORTS = 30
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"label": "所属概念"
|
||||
}
|
||||
],
|
||||
"description": "ths概念、日更",
|
||||
"description": "",
|
||||
"symbol_map": {
|
||||
"type": "mapped",
|
||||
"col": "股票代码"
|
||||
@@ -39,6 +39,6 @@
|
||||
"from": "symbol",
|
||||
"method": "strip_exchange"
|
||||
},
|
||||
"created_at": "2026-06-19T19:56:33.098883",
|
||||
"updated_at": "2026-06-19T19:56:33.098883"
|
||||
"created_at": "2026-06-27T12:18:23.629390",
|
||||
"updated_at": "2026-06-27T12:18:23.629390"
|
||||
}
|
||||
@@ -26,10 +26,20 @@
|
||||
{
|
||||
"name": "所属同花顺行业",
|
||||
"dtype": "string",
|
||||
"label": "所属行业"
|
||||
"label": "所属同花顺行业"
|
||||
},
|
||||
{
|
||||
"name": "thscode",
|
||||
"dtype": "string",
|
||||
"label": "thscode"
|
||||
},
|
||||
{
|
||||
"name": "time",
|
||||
"dtype": "string",
|
||||
"label": "time"
|
||||
}
|
||||
],
|
||||
"description": "ths行业、月更",
|
||||
"description": "",
|
||||
"symbol_map": {
|
||||
"type": "mapped",
|
||||
"col": "股票代码"
|
||||
@@ -39,6 +49,6 @@
|
||||
"from": "symbol",
|
||||
"method": "strip_exchange"
|
||||
},
|
||||
"created_at": "2026-06-19T19:57:17.004819",
|
||||
"updated_at": "2026-06-19T19:57:17.004819"
|
||||
"created_at": "2026-06-27T12:18:42.205794",
|
||||
"updated_at": "2026-06-27T12:18:42.205794"
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"id": "ext_kz_ths",
|
||||
"label": "扩展快照ths",
|
||||
"mode": "snapshot",
|
||||
"fields": [
|
||||
{
|
||||
"name": "symbol",
|
||||
"dtype": "string",
|
||||
"label": "标的代码"
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"dtype": "string",
|
||||
"label": "代码"
|
||||
},
|
||||
{
|
||||
"name": "股票代码",
|
||||
"dtype": "string",
|
||||
"label": "股票代码"
|
||||
},
|
||||
{
|
||||
"name": "股票简称",
|
||||
"dtype": "string",
|
||||
"label": "股票简称"
|
||||
},
|
||||
{
|
||||
"name": "所属概念",
|
||||
"dtype": "string",
|
||||
"label": "所属概念"
|
||||
},
|
||||
{
|
||||
"name": "人气排名",
|
||||
"dtype": "int",
|
||||
"label": "人气排名"
|
||||
},
|
||||
{
|
||||
"name": "资金流向",
|
||||
"dtype": "float",
|
||||
"label": "资金流向"
|
||||
},
|
||||
{
|
||||
"name": "PE",
|
||||
"dtype": "float",
|
||||
"label": "PE"
|
||||
},
|
||||
{
|
||||
"name": "PB",
|
||||
"dtype": "float",
|
||||
"label": "PB"
|
||||
},
|
||||
{
|
||||
"name": "ROE",
|
||||
"dtype": "float",
|
||||
"label": "ROE"
|
||||
},
|
||||
{
|
||||
"name": "营收",
|
||||
"dtype": "float",
|
||||
"label": "营收"
|
||||
},
|
||||
{
|
||||
"name": "净利润",
|
||||
"dtype": "float",
|
||||
"label": "净利润"
|
||||
}
|
||||
],
|
||||
"description": "",
|
||||
"symbol_map": {
|
||||
"type": "mapped",
|
||||
"col": "股票代码"
|
||||
},
|
||||
"code_map": {
|
||||
"type": "computed",
|
||||
"from": "symbol",
|
||||
"method": "strip_exchange"
|
||||
},
|
||||
"created_at": "2026-06-05T13:48:50.034495",
|
||||
"updated_at": "2026-06-05T16:02:52.060612"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.60",
|
||||
"version": "0.1.61",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -143,7 +143,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
<thead>
|
||||
<tr className="bg-elevated/50">
|
||||
{header.map((cell, ci) => (
|
||||
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-secondary border-b border-border/40 whitespace-nowrap">
|
||||
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-foreground border-b border-border/40 whitespace-nowrap">
|
||||
{renderInline(cell, `th-${key}-${ci}`)}
|
||||
</th>
|
||||
))}
|
||||
@@ -153,7 +153,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
{body.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2.5 py-1.5 text-foreground/90 align-top break-words">
|
||||
<td key={ci} className="px-2.5 py-1.5 text-foreground align-top break-words">
|
||||
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
|
||||
</td>
|
||||
))}
|
||||
@@ -178,7 +178,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
blocks.push(
|
||||
<ul key={key++} className="my-4 space-y-2">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<li key={ii} className="flex items-start gap-2 text-sm text-foreground leading-relaxed">
|
||||
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
|
||||
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
@@ -202,7 +202,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
|
||||
{ii + 1}
|
||||
</span>
|
||||
<span className="flex-1">{renderInline(item, `ol-${key}-${ii}`)}</span>
|
||||
<span className="flex-1 text-foreground">{renderInline(item, `ol-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>,
|
||||
@@ -212,7 +212,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
|
||||
// 普通段落
|
||||
blocks.push(
|
||||
<p key={key++} className="my-3 text-xs text-foreground/90 leading-relaxed">
|
||||
<p key={key++} className="my-3 text-sm text-foreground leading-relaxed">
|
||||
{renderInline(trimmed, `p-${key}`)}
|
||||
</p>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 复盘生成状态的全局单例 store —— 脱离 Review 组件生命周期。
|
||||
*
|
||||
* 解决的问题:生成中切换到其他页面,Review 组件卸载会丢失 phase/content。
|
||||
* 本 store 把流式生成的状态提到模块级,组件卸载后流仍在后台继续跑,
|
||||
* 回到页面订阅即可恢复显示。
|
||||
*
|
||||
* 设计:
|
||||
* - 模块级 state(phase/content/meta/focus),唯一的生成实例
|
||||
* - AbortController 存模块级 ref,组件卸载不中断流
|
||||
* - 订阅者列表(notify 机制),Review mount 时订阅、unmount 时退订
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export type ReviewPhase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ReviewMeta {
|
||||
as_of?: string
|
||||
emotion_score?: number
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface ReviewState {
|
||||
phase: ReviewPhase
|
||||
content: string
|
||||
error: string
|
||||
meta: ReviewMeta | null
|
||||
focus: string
|
||||
}
|
||||
|
||||
const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null, focus: '' }
|
||||
|
||||
// ===== 模块级单例状态(组件卸载不销毁)=====
|
||||
let state: ReviewState = { ...INITIAL }
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
// ===== 订阅机制 =====
|
||||
type Listener = () => void
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function notify() {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
export function getReviewState(): ReviewState {
|
||||
return state
|
||||
}
|
||||
|
||||
export function subscribeReview(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
}
|
||||
|
||||
// 暴露给组件直接读取最新 meta(用于自动归档,避免闭包取旧值)
|
||||
export function getReviewMeta(): ReviewMeta | null {
|
||||
return state.meta
|
||||
}
|
||||
|
||||
/** 是否正在生成(loading 或 streaming) */
|
||||
export function isReviewGenerating(): boolean {
|
||||
return state.phase === 'loading' || state.phase === 'streaming'
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动复盘生成。返回后流在后台独立运行,组件卸载不影响。
|
||||
* @param asOf 复盘日期
|
||||
* @param focus 用户追加的复盘关注点
|
||||
* @param onDone 完成回调(供调用方做自动归档)
|
||||
*/
|
||||
export async function startReviewGeneration(
|
||||
asOf: string | undefined,
|
||||
focus: string,
|
||||
onDone?: (fullContent: string, meta: ReviewMeta | null) => void,
|
||||
): Promise<void> {
|
||||
// 已在生成中,不重复启动
|
||||
if (isReviewGenerating()) return
|
||||
|
||||
state = { phase: 'loading', content: '', error: '', meta: null, focus }
|
||||
notify()
|
||||
|
||||
abortCtrl = new AbortController()
|
||||
let buf = ''
|
||||
let failed = false
|
||||
let doneMeta: ReviewMeta | null = null
|
||||
|
||||
try {
|
||||
for await (const evt of api.reviewStream(asOf, focus)) {
|
||||
if (abortCtrl.signal.aborted) break
|
||||
if (evt.type === 'meta') {
|
||||
doneMeta = evt
|
||||
state = { ...state, meta: evt }
|
||||
notify()
|
||||
} else if (evt.type === 'delta' && evt.content) {
|
||||
buf += evt.content
|
||||
state = { ...state, content: buf, phase: 'streaming' }
|
||||
notify()
|
||||
} else if (evt.type === 'error') {
|
||||
failed = true
|
||||
state = { ...state, error: evt.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
return
|
||||
} else if (evt.type === 'done') {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
}
|
||||
}
|
||||
// 流正常结束但无 done 事件,按 done 处理
|
||||
if (buf && !failed) {
|
||||
state = { ...state, phase: 'done' }
|
||||
notify()
|
||||
// 自动归档
|
||||
if (buf && !failed) {
|
||||
onDone?.(buf, doneMeta)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
state = { ...state, error: e?.message ?? '复盘失败', phase: 'error' }
|
||||
notify()
|
||||
}
|
||||
} finally {
|
||||
abortCtrl = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 中断当前生成(供"查看历史"等场景主动中断流)。 */
|
||||
export function abortReviewGeneration(): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
}
|
||||
|
||||
/** 设置当前查看的历史报告(把 store 状态切到 done + 该报告内容)。 */
|
||||
export function setViewingReport(report: {
|
||||
content: string
|
||||
as_of?: string
|
||||
emotion_score?: number | null
|
||||
emotion_label?: string
|
||||
summary?: string
|
||||
}): void {
|
||||
abortCtrl?.abort()
|
||||
abortCtrl = null
|
||||
state = {
|
||||
phase: 'done',
|
||||
content: report.content,
|
||||
error: '',
|
||||
meta: {
|
||||
as_of: report.as_of,
|
||||
emotion_score: report.emotion_score ?? undefined,
|
||||
emotion_label: report.emotion_label,
|
||||
summary: report.summary,
|
||||
},
|
||||
focus: state.focus,
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
/** 重置到 idle(清空当前显示)。 */
|
||||
export function resetReview(): void {
|
||||
state = { ...INITIAL }
|
||||
notify()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 订阅 reviewStore 的 React hook。
|
||||
*
|
||||
* mount 时订阅,store 变化触发 re-render;unmount 时退订(但 store 流继续跑)。
|
||||
* 用 useSyncExternalStore 保证与 React 18 并发模式兼容。
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getReviewState, subscribeReview, type ReviewState,
|
||||
} from '@/lib/reviewStore'
|
||||
|
||||
export function useReviewState(): ReviewState {
|
||||
return useSyncExternalStore(subscribeReview, getReviewState, getReviewState)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass } from 'lucide-react'
|
||||
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
@@ -70,6 +70,25 @@ export function Financials() {
|
||||
<p className="mt-2 text-xs leading-relaxed text-secondary">
|
||||
财务数据接口仅 Expert 套餐可用。升级后此页自动显示财务数据面板。
|
||||
</p>
|
||||
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
|
||||
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
|
||||
关于数据源
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-secondary">
|
||||
当前财务数据源需付费,后续会接入免费数据源。如你常用某个免费财务数据源,欢迎在 Issues 中多多推荐哈 ~
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/shy3130/tickflow-stock-panel/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-accent hover:underline"
|
||||
>
|
||||
前往 Issues 推荐
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* AI 大盘复盘页 —— 盘后复盘看板 + 流式 LLM 复盘报告 + 历史归档。
|
||||
* AI 大盘复盘页 —— 以流式 LLM 复盘报告为主体的盘后复盘工作台。
|
||||
*
|
||||
* 数据分工:
|
||||
* - 顶部看板(指数/涨跌/连板/封板/情绪雷达)来自 GET /api/overview/market
|
||||
* - 复盘报告(markdown)由 POST /api/market-recap/analyze 流式生成
|
||||
* 视觉语言对齐 Dashboard:A 股红涨绿跌、rounded-card 卡片、SectionTitle 层级。
|
||||
* 设计定位:极简专注型。不复刻 Dashboard 的看板(KPI/雷达/板块排名),
|
||||
* 仅保留一行「市场摘要条」作为报告上下文参照;AI 报告 + 历史归档是页面主体。
|
||||
* - 摘要数据:GET /api/overview/market
|
||||
* - 报告流式:POST /api/market-recap/analyze
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
@@ -12,16 +12,21 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
|
||||
BarChart3, Activity, Layers, ArrowUpRight, ArrowDownRight, Database, Wand2,
|
||||
Database, Wand2, Copy, Download,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtPrice } from '@/lib/format'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { useReviewState } from '@/lib/useReviewStore'
|
||||
import {
|
||||
startReviewGeneration, resetReview, isReviewGenerating,
|
||||
type ReviewPhase,
|
||||
} from '@/lib/reviewStore'
|
||||
|
||||
// ================================================================
|
||||
// 涨跌幅格式化(注意单位差异)
|
||||
@@ -47,19 +52,27 @@ function scoreColor(v: number | null | undefined): string {
|
||||
return '#12B76A'
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
|
||||
// 归档时刻格式化:ISO → "MM-DD HH:mm"(用于历史列表显示复盘时间)
|
||||
function fmtArchivedAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${mm}-${dd} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
// Phase 类型复用 store 的定义(单一来源)
|
||||
|
||||
export function Review() {
|
||||
const qc = useQueryClient()
|
||||
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
|
||||
const asOf: string | undefined = undefined
|
||||
const [focus, setFocus] = useState('')
|
||||
const [phase, setPhase] = useState<Phase>('idle')
|
||||
const [content, setContent] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [meta, setMeta] = useState<{ as_of?: string; emotion_score?: number; emotion_label?: string; summary?: string } | null>(null)
|
||||
// 生成状态走全局 store:切走页面流不中断,回来可恢复
|
||||
const { phase, content, error, meta } = useReviewState()
|
||||
const [viewing, setViewing] = useState<AiReviewReport | null>(null) // 查看历史报告
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const reportEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 看板数据(与总览页同源)
|
||||
@@ -92,80 +105,70 @@ export function Review() {
|
||||
}
|
||||
}, [content, phase])
|
||||
|
||||
// 主流程:生成复盘
|
||||
const generate = useCallback(async () => {
|
||||
if (phase === 'loading' || phase === 'streaming') return
|
||||
setViewing(null)
|
||||
setPhase('loading')
|
||||
setContent('')
|
||||
setError('')
|
||||
setMeta(null)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current = ctrl
|
||||
let buf = ''
|
||||
let failed = false
|
||||
try {
|
||||
for await (const evt of api.reviewStream(asOf, focus)) {
|
||||
if (ctrl.signal.aborted) break
|
||||
if (evt.type === 'meta') {
|
||||
setMeta(evt)
|
||||
} else if (evt.type === 'delta' && evt.content) {
|
||||
buf += evt.content
|
||||
setContent(buf)
|
||||
setPhase('streaming')
|
||||
} else if (evt.type === 'error') {
|
||||
failed = true
|
||||
setError(evt.message ?? '复盘失败')
|
||||
setPhase('error')
|
||||
return
|
||||
} else if (evt.type === 'done') {
|
||||
setPhase('done')
|
||||
}
|
||||
}
|
||||
// 流正常结束但无 done 事件,按 done 处理
|
||||
if (buf && !failed) setPhase('done')
|
||||
} catch (e: any) {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(e?.message ?? '复盘失败')
|
||||
setPhase('error')
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [asOf, focus, phase])
|
||||
|
||||
// 保存当前报告
|
||||
const saveCurrent = useCallback(async () => {
|
||||
if (!content) return
|
||||
const reportAsOf = meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
|
||||
// 自动归档(生成完成后台静默保存)—— 通过回调注入 store,避免 store 直接依赖 qc/marketQuery
|
||||
const onGenerationDone = useCallback(async (fullContent: string, doneMeta: { as_of?: string; summary?: string; emotion_score?: number; emotion_label?: string } | null) => {
|
||||
const reportAsOf = doneMeta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
await api.reviewReportSave({
|
||||
as_of: reportAsOf,
|
||||
focus,
|
||||
content,
|
||||
summary: meta?.summary,
|
||||
emotion_score: meta?.emotion_score ?? null,
|
||||
emotion_label: meta?.emotion_label ?? '',
|
||||
content: fullContent,
|
||||
summary: doneMeta?.summary,
|
||||
emotion_score: doneMeta?.emotion_score ?? null,
|
||||
emotion_label: doneMeta?.emotion_label ?? '',
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: QK.reviewReports })
|
||||
toast('复盘已归档', 'success')
|
||||
} catch { /* request() 已 toast */ }
|
||||
}, [content, meta, asOf, focus, marketQuery.data, qc])
|
||||
} catch { /* 静默 */ }
|
||||
}, [focus, asOf, marketQuery.data, qc])
|
||||
|
||||
// 查看历史报告
|
||||
// 主流程:生成复盘(委托给全局 store,流在后台独立运行)
|
||||
const generate = useCallback(() => {
|
||||
if (isReviewGenerating()) return
|
||||
setViewing(null)
|
||||
resetReview()
|
||||
startReviewGeneration(asOf, focus, (full, doneMeta) => {
|
||||
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
|
||||
})
|
||||
}, [asOf, focus, onGenerationDone])
|
||||
|
||||
// 复制全文到剪贴板(viewing 优先,与主区域显示一致)
|
||||
const copyContent = useCallback(async () => {
|
||||
const text = viewing?.content ?? content
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast('已复制到剪贴板', 'success')
|
||||
} catch {
|
||||
toast('复制失败,请手动选择文本', 'error')
|
||||
}
|
||||
}, [content, viewing])
|
||||
|
||||
// 下载为 .md 文件(viewing 优先)
|
||||
const downloadContent = useCallback(() => {
|
||||
const text = viewing?.content ?? content
|
||||
if (!text) return
|
||||
const reportDate = viewing?.as_of ?? meta?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
|
||||
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `复盘_${reportDate}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [content, viewing, meta, asOf])
|
||||
|
||||
// 查看历史报告(不中断后台生成:仅临时把 viewing 覆盖到主区域,
|
||||
// 生成中的流仍在 store 里继续跑,点"生成中"项即可切回)
|
||||
const viewReport = useCallback((r: AiReviewReport) => {
|
||||
abortRef.current?.abort()
|
||||
setViewing(r)
|
||||
setContent(r.content)
|
||||
setMeta({ as_of: r.as_of, emotion_score: r.emotion_score ?? undefined, emotion_label: r.emotion_label, summary: r.summary })
|
||||
setPhase('done')
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
const isGenerating = phase === 'loading' || phase === 'streaming'
|
||||
const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新'
|
||||
const data = marketQuery.data
|
||||
// 主区域显示的内容:viewing(查看历史)优先于 store 的生成 content,
|
||||
// 这样点历史报告不会覆盖后台生成中的流。
|
||||
const displayContent = viewing?.content ?? content
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -179,7 +182,7 @@ export function Review() {
|
||||
onClick={() => { marketQuery.refetch() }}
|
||||
disabled={marketQuery.isFetching}
|
||||
className="inline-flex items-center gap-1 rounded-btn border border-border bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground disabled:opacity-50"
|
||||
title="刷新看板数据"
|
||||
title="刷新市场数据"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />刷新
|
||||
</button>
|
||||
@@ -204,7 +207,7 @@ export function Review() {
|
||||
/>
|
||||
|
||||
<div className="min-h-full bg-[radial-gradient(circle_at_15%_-5%,rgba(59,130,246,0.10),transparent_30%),radial-gradient(circle_at_85%_5%,rgba(139,92,246,0.08),transparent_30%)] px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto max-w-[1440px] space-y-4">
|
||||
<div className="mx-auto max-w-[1280px] space-y-3">
|
||||
|
||||
{marketQuery.isLoading && !data ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
@@ -233,27 +236,8 @@ export function Review() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ===== 指数行情条(对齐 Dashboard IndexTicker) ===== */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
|
||||
</div>
|
||||
|
||||
{/* ===== KPI 网格 ===== */}
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
|
||||
<KpiCell label="涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
|
||||
<KpiCell label="涨停 / 跌停" value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}% · 炸板 ${data.limit.broken ?? 0}`} />
|
||||
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}板`} sub={`梯队 ${data.limit.tiers.length}档`} tone="accent" />
|
||||
<KpiCell label="两市成交" value={`${((data.amount.total ?? 0) / 1e8).toFixed(0)}亿`} sub={`均额 ${((data.amount.avg ?? 0) / 1e8).toFixed(1)}亿`} />
|
||||
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover}`} tone="accent" />
|
||||
<KpiCell label="MA5 / 20 / 60" value={`${data.trend.above_ma5_pct.toFixed(0)}%`} sub={`${data.trend.above_ma20_pct.toFixed(0)}% / ${data.trend.above_ma60_pct.toFixed(0)}%`} />
|
||||
</div>
|
||||
|
||||
{/* ===== 情绪雷达 + 板块排名 双栏 ===== */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<EmotionSection data={data} />
|
||||
<SectorSection title="概念板块" rank={data.concept_rank} tone="concept" />
|
||||
<SectorSection title="行业板块" rank={data.industry_rank} tone="industry" />
|
||||
</div>
|
||||
{/* ===== 市场摘要条(轻量上下文,非重复看板)===== */}
|
||||
<MarketSummaryBar data={data} />
|
||||
|
||||
{/* ===== 关注点输入 ===== */}
|
||||
<div className="flex items-center gap-2 rounded-card border border-border bg-surface/80 px-3.5 py-2.5 transition-colors focus-within:border-accent/40">
|
||||
@@ -270,15 +254,16 @@ export function Review() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 报告 + 历史 双栏 ===== */}
|
||||
{/* ===== 报告 + 历史 双栏(报告为主体)===== */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_18rem]">
|
||||
<ReportPanel
|
||||
phase={phase}
|
||||
content={content}
|
||||
content={displayContent}
|
||||
error={error}
|
||||
isGenerating={isGenerating}
|
||||
viewing={viewing}
|
||||
onSave={saveCurrent}
|
||||
onCopy={copyContent}
|
||||
onDownload={downloadContent}
|
||||
onRegenerate={generate}
|
||||
reportEndRef={reportEndRef}
|
||||
/>
|
||||
@@ -286,7 +271,9 @@ export function Review() {
|
||||
reports={historyQuery.data?.reports ?? []}
|
||||
loading={historyQuery.isLoading}
|
||||
viewingId={viewing?.id ?? null}
|
||||
generating={isGenerating}
|
||||
onView={viewReport}
|
||||
onBackToGenerating={() => setViewing(null)}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
@@ -299,184 +286,94 @@ export function Review() {
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 指数行情卡(对齐 Dashboard IndexTicker)
|
||||
// 市场摘要条 —— 复盘页的轻量上下文(非重复看板)
|
||||
// 仅一行:三大指数涨跌 · 情绪分 · 涨停结构 · 成交额
|
||||
// 详细数据请去 Dashboard 看,这里只给 AI 报告提供背景参照
|
||||
// ================================================================
|
||||
function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
|
||||
const pct = item.change_pct
|
||||
const isUp = (pct ?? 0) >= 0
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[1fr_auto] items-center gap-x-2 gap-y-0.5 rounded-card border border-border bg-surface/80 px-3 py-2 transition-colors hover:border-accent/40">
|
||||
<div className="truncate text-xs font-medium text-foreground">{item.name || item.symbol}</div>
|
||||
<div className={cn('font-mono text-xs font-semibold tabular-nums', pctClass(pct))}>{fmtPctAlready(pct, 2, true)}</div>
|
||||
<div className="font-mono text-[10px] text-muted">{item.symbol}</div>
|
||||
<div className={cn('flex items-center gap-0.5 font-mono text-[11px] tabular-nums', pctClass(pct))}>
|
||||
{isUp ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
|
||||
{fmtPrice(item.last_price)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// 指数简称映射:全称太长(上证指数/深证成指/创业板指/科创综指)摘要条放不下,统一缩成单字
|
||||
const INDEX_SHORT: Record<string, string> = {
|
||||
'上证指数': '上', '深证成指': '深', '创业板指': '创', '科创综指': '科', '科创50': '科',
|
||||
}
|
||||
function indexShort(name?: string | null, symbol?: string): string {
|
||||
if (!name) return symbol ?? '—'
|
||||
return INDEX_SHORT[name] ?? (name.replace(/指数|成指|A股|综指|50/g, '').slice(0, 2) || name.slice(0, 1))
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// KPI 单元(对齐 Dashboard KpiCell)
|
||||
// ================================================================
|
||||
function KpiCell({ label, value, sub, tone }: {
|
||||
label: React.ReactNode
|
||||
value: React.ReactNode
|
||||
sub?: string
|
||||
tone?: 'bull' | 'bear' | 'accent'
|
||||
}) {
|
||||
const isPlain = typeof value === 'string' || typeof value === 'number'
|
||||
const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
|
||||
return (
|
||||
<div className="min-w-0 rounded-card border border-border bg-surface/80 px-3 py-2">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">{label}</div>
|
||||
<div className={cn('mt-1 truncate font-mono text-base font-semibold leading-none tabular-nums', isPlain ? color : 'text-foreground')}>{value}</div>
|
||||
{sub && <div className="mt-1 truncate text-[10px] text-muted">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
// 批量替换文本中的指数全称为简称(用于历史列表 summary 显示,
|
||||
// 兼容存量旧报告 —— 它们存盘时 summary 还是全称)。
|
||||
const _INDEX_FULL_RE = /上证指数|深证成指|创业板指|科创综指|科创50/g
|
||||
function shortenIndexNames(text: string): string {
|
||||
return text.replace(_INDEX_FULL_RE, (m) => INDEX_SHORT[m] ?? m)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 章节标题(对齐 Dashboard SectionTitle)
|
||||
// ================================================================
|
||||
function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||
<h2 className="text-xs font-semibold text-foreground">{title}</h2>
|
||||
</div>
|
||||
{hint && <span className="font-mono text-[10px] text-muted">{hint}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 情绪雷达章节(SVG 雷达图,对齐 Dashboard EmotionRadar)
|
||||
// ================================================================
|
||||
function EmotionSection({ data }: { data: OverviewMarket }) {
|
||||
const score = data.emotion.score
|
||||
const color = scoreColor(score)
|
||||
const radar = data.radar ?? []
|
||||
const size = 220
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const maxR = 68
|
||||
|
||||
const points = radar.map((r, i) => {
|
||||
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
|
||||
const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100
|
||||
return {
|
||||
...r,
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
lx: cx + Math.cos(angle) * (maxR + 24),
|
||||
ly: cy + Math.sin(angle) * (maxR + 24),
|
||||
gx: cx + Math.cos(angle) * maxR,
|
||||
gy: cy + Math.sin(angle) * maxR,
|
||||
// 从 summary 的指数段(如「上-2.26%、深-3.44%、创-4.07%、科-2.02%」)
|
||||
// 解析出 [{name, pctStr, pctNum}],供列表项按涨跌染色渲染。
|
||||
const _INDEX_PCT_RE = /([上深创科])([+-]?\d+\.\d+%)/g
|
||||
function parseIndexPcts(indexSegment: string): { name: string; pctStr: string; pctNum: number }[] {
|
||||
const out: { name: string; pctStr: string; pctNum: number }[] = []
|
||||
for (const m of indexSegment.matchAll(_INDEX_PCT_RE)) {
|
||||
out.push({ name: m[1], pctStr: m[2], pctNum: parseFloat(m[2]) })
|
||||
}
|
||||
})
|
||||
const polygon = points.map(p => `${p.x},${p.y}`).join(' ')
|
||||
const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({
|
||||
level, idx,
|
||||
points: radar.map((_, i) => {
|
||||
const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
|
||||
return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}`
|
||||
}).join(' '),
|
||||
}))
|
||||
return out
|
||||
}
|
||||
|
||||
function MarketSummaryBar({ data }: { data: OverviewMarket }) {
|
||||
const score = data.emotion?.score ?? null
|
||||
const emoColor = scoreColor(score)
|
||||
const indices = (data.indices ?? []).slice(0, 4)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-card border bg-surface/80 p-3"
|
||||
style={{ borderColor: `${color}40` }}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-card border border-border bg-surface/80 px-4 py-2.5">
|
||||
{/* 情绪分(带色徽章)—— 复盘的核心定调 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded font-mono text-xs font-bold tabular-nums"
|
||||
style={{ color: emoColor, backgroundColor: `${emoColor}1a` }}
|
||||
>
|
||||
<SectionTitle icon={Sparkles} title="情绪雷达" hint={`评分 ${score} · ${data.emotion.label}`} />
|
||||
{radar.length === 0 ? (
|
||||
<div className="flex h-44 items-center justify-center text-xs text-muted">暂无雷达数据</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-52 w-full">
|
||||
<defs>
|
||||
<radialGradient id="reviewRadarFill" cx="50%" cy="45%" r="70%">
|
||||
<stop offset="0%" stopColor={`${color}57`} />
|
||||
<stop offset="100%" stopColor={`${color}1f`} />
|
||||
</radialGradient>
|
||||
<radialGradient id="reviewRadarCenter" cx="50%" cy="50%" r="55%">
|
||||
<stop offset="0%" stopColor="rgba(24,24,27,0.92)" />
|
||||
<stop offset="68%" stopColor="rgba(24,24,27,0.70)" />
|
||||
<stop offset="100%" stopColor="rgba(24,24,27,0)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{gridPolygons.map(g => (
|
||||
<polygon
|
||||
key={g.level}
|
||||
points={g.points}
|
||||
fill={g.idx % 2 === 0 ? 'rgba(33,33,38,0.26)' : 'rgba(24,24,27,0.16)'}
|
||||
stroke={g.level === 1 ? 'rgba(148,163,184,0.22)' : 'rgba(148,163,184,0.12)'}
|
||||
strokeWidth={g.level === 1 ? 1.2 : 0.8}
|
||||
/>
|
||||
))}
|
||||
{points.map(p => <line key={p.key} x1={cx} y1={cy} x2={p.gx} y2={p.gy} stroke="rgba(148,163,184,0.08)" />)}
|
||||
<polygon points={polygon} fill="url(#reviewRadarFill)" stroke={color} strokeWidth="2" />
|
||||
{points.map(p => <circle key={p.key} cx={p.x} cy={p.y} r="2.8" fill={color} stroke="rgba(24,24,27,0.9)" strokeWidth="1" />)}
|
||||
<circle cx={cx} cy={cy} r="26" fill="url(#reviewRadarCenter)" />
|
||||
<text x={cx} y={cy + 6} textAnchor="middle" className="fill-foreground font-mono text-[22px] font-bold">{score}</text>
|
||||
{points.map(p => (
|
||||
<text key={`${p.key}-label`} x={p.lx} y={p.ly + 4} textAnchor="middle" className="fill-secondary text-[9px] font-medium">{p.label}</text>
|
||||
))}
|
||||
</svg>
|
||||
{score ?? '—'}
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-[11px] font-medium text-foreground">{data.emotion?.label ?? '情绪'}</div>
|
||||
<div className="text-[9px] text-secondary">情绪温度</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 板块排名章节(领涨/领跌)
|
||||
// ================================================================
|
||||
function SectorSection({ title, rank, tone }: {
|
||||
title: string
|
||||
rank: OverviewMarket['concept_rank'] | OverviewMarket['industry_rank']
|
||||
tone: 'concept' | 'industry'
|
||||
}) {
|
||||
const leading = rank?.leading ?? []
|
||||
const lagging = rank?.lagging ?? []
|
||||
const hasData = leading.length > 0 || lagging.length > 0
|
||||
return (
|
||||
<section className="rounded-card border border-border bg-surface/80 p-3">
|
||||
<SectionTitle icon={tone === 'concept' ? Layers : BarChart3} title={title} hint="领涨/领跌" />
|
||||
{!hasData ? (
|
||||
<div className="py-6 text-center text-[11px] text-muted">暂无数据</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<RankColumn rows={leading} tone="bull" />
|
||||
<RankColumn rows={lagging} tone="bear" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
<div className="hidden h-7 w-px bg-border sm:block" />
|
||||
|
||||
function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['leading']; tone: 'bull' | 'bear' }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className={cn('text-[10px] font-medium', tone === 'bull' ? 'text-bull' : 'text-bear')}>
|
||||
{tone === 'bull' ? '领涨' : '领跌'}
|
||||
</div>
|
||||
{rows.slice(0, 5).map((r, idx) => (
|
||||
<div key={`${r.name}-${idx}`} className="grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded bg-elevated/40 px-1.5 py-1">
|
||||
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[11px] text-foreground" title={r.name}>{r.name}</div>
|
||||
<div className="truncate text-[9px] text-muted">{r.count}只 · {r.leader?.name ?? '—'}</div>
|
||||
</div>
|
||||
<div className={cn('font-mono text-[10px] font-semibold tabular-nums', pctClass(r.avg_pct))}>
|
||||
{fmtPctAlready((r.avg_pct ?? 0) * 100, 2, true)}
|
||||
</div>
|
||||
{/* 四大指数(简称:上深创科)*/}
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
{indices.map(idx => (
|
||||
<div key={idx.symbol} className="flex items-center gap-1">
|
||||
<span className="text-[11px] text-secondary">{indexShort(idx.name, idx.symbol)}</span>
|
||||
<span className={cn('font-mono text-[11px] font-semibold tabular-nums', pctClass(idx.change_pct))}>
|
||||
{fmtPctAlready(idx.change_pct, 2, true)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && <div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted">—</div>}
|
||||
</div>
|
||||
|
||||
<div className="hidden h-7 w-px bg-border sm:block" />
|
||||
|
||||
{/* 涨跌结构 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">涨跌</span>
|
||||
<span className="font-mono font-semibold text-bull">{data.breadth?.up ?? 0}</span>
|
||||
<span className="text-muted">/</span>
|
||||
<span className="font-mono font-semibold text-bear">{data.breadth?.down ?? 0}</span>
|
||||
</div>
|
||||
|
||||
{/* 涨停结构 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">涨停</span>
|
||||
<span className="font-mono font-semibold text-bull">{data.limit?.limit_up ?? 0}</span>
|
||||
<span className="text-secondary">封板 {(data.limit?.seal_rate ?? 0).toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
{/* 成交额 */}
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-secondary">成交</span>
|
||||
<span className="font-mono font-semibold text-foreground">{fmtBigNum(data.amount?.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -485,14 +382,15 @@ function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['lead
|
||||
// 报告面板(流式 + 错误 + 历史/完成态)
|
||||
// ================================================================
|
||||
function ReportPanel({
|
||||
phase, content, error, isGenerating, viewing, onSave, onRegenerate, reportEndRef,
|
||||
phase, content, error, isGenerating, viewing, onCopy, onDownload, onRegenerate, reportEndRef,
|
||||
}: {
|
||||
phase: Phase
|
||||
phase: ReviewPhase
|
||||
content: string
|
||||
error: string
|
||||
isGenerating: boolean
|
||||
viewing: AiReviewReport | null
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onDownload: () => void
|
||||
onRegenerate: () => void
|
||||
reportEndRef: React.RefObject<HTMLDivElement>
|
||||
}) {
|
||||
@@ -516,29 +414,49 @@ function ReportPanel({
|
||||
|
||||
if (phase === 'idle' && !content) {
|
||||
return (
|
||||
<div className="flex min-h-[24rem] flex-col items-center justify-center gap-4 rounded-card border border-border bg-surface/80 px-6 py-12">
|
||||
<div className="flex min-h-[28rem] flex-col items-center justify-center gap-5 rounded-card border border-border bg-surface/80 px-6 py-16">
|
||||
<div className="relative">
|
||||
<div className="grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
|
||||
<BookOpenCheck className="h-7 w-7 text-accent" strokeWidth={1.8} />
|
||||
<div className="grid h-20 w-20 place-items-center rounded-2xl bg-gradient-to-br from-accent/20 to-purple-500/15 border border-accent/30">
|
||||
<BookOpenCheck className="h-9 w-9 text-accent" strokeWidth={1.8} />
|
||||
</div>
|
||||
<Sparkles className="absolute -right-1 -top-1 h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-foreground">AI 大盘复盘</div>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-xs leading-relaxed text-muted">
|
||||
点击右上角「生成复盘」,基于今日指数结构、涨跌家数、连板梯队、板块轮动与情绪雷达,
|
||||
生成可直接指导次日仓位与节奏的盘后复盘报告。
|
||||
<div className="text-base font-semibold text-foreground">AI 大盘复盘</div>
|
||||
<p className="mx-auto mt-2 max-w-sm text-xs leading-relaxed text-secondary">
|
||||
一键生成今日盘后复盘报告 —— 从一句话定调到明日交易计划,
|
||||
结构化输出可直接指导次日仓位与节奏。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-muted">
|
||||
{/* 报告七节预览 —— 空状态也有内容感,暗示报告结构 */}
|
||||
<div className="mt-2 grid w-full max-w-md grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ icon: '🎯', label: '一句话定调' },
|
||||
{ icon: '📊', label: '盘面总览' },
|
||||
{ icon: '🔥', label: '板块主线' },
|
||||
{ icon: '💰', label: '资金情绪' },
|
||||
{ icon: '📰', label: '消息催化' },
|
||||
{ icon: '🎯', label: '明日计划' },
|
||||
{ icon: '⚠️', label: '风险提示' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex flex-col items-center gap-1 rounded-btn bg-elevated/40 px-2 py-2">
|
||||
<span className="text-base">{s.icon}</span>
|
||||
<span className="text-[10px] text-secondary">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-muted">
|
||||
<Sparkles className="h-3 w-3 text-accent" />
|
||||
七节结构化报告 · 一键归档 · 历史回看
|
||||
点击右上角「生成复盘」开始
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showCursor = isGenerating
|
||||
const showSave = phase === 'done' && !!content && !viewing
|
||||
// 仅当显示生成内容(非查看历史)且正在生成时,才显示流式光标
|
||||
const showCursor = isGenerating && !viewing
|
||||
// 查看历史时(即使后台在生成)也能复制/下载该历史报告
|
||||
const showActions = !!content && (!isGenerating || !!viewing)
|
||||
const showViewingTag = !!viewing
|
||||
const isLoading = phase === 'loading' && !content
|
||||
|
||||
@@ -555,13 +473,18 @@ function ReportPanel({
|
||||
{showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
|
||||
</span>
|
||||
</div>
|
||||
{showSave && (
|
||||
<button onClick={onSave} className="inline-flex items-center gap-1 rounded-btn bg-accent/10 px-2 py-1 text-[11px] text-accent transition-colors hover:bg-accent/20">
|
||||
<History className="h-3 w-3" />归档
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={onCopy} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="复制全文">
|
||||
<Copy className="h-3 w-3" />复制
|
||||
</button>
|
||||
<button onClick={onDownload} className="inline-flex items-center gap-1 rounded-btn bg-elevated px-2 py-1 text-[11px] text-secondary transition-colors hover:text-foreground hover:bg-elevated/70" title="下载为 Markdown">
|
||||
<Download className="h-3 w-3" />下载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto px-5 py-4">
|
||||
<div className="max-h-[calc(100vh-22rem)] overflow-y-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<div className="relative">
|
||||
@@ -570,8 +493,8 @@ function ReportPanel({
|
||||
</div>
|
||||
<RefreshCw className="absolute -inset-1 h-13 w-13 animate-spin text-accent/30" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary">AI 正在分析今日盘面…</div>
|
||||
<div className="text-[10px] text-muted">读取指数结构 · 涨跌家数 · 连板梯队 · 板块轮动 · 情绪雷达</div>
|
||||
<div className="text-sm text-foreground">AI 正在复盘今日盘面…</div>
|
||||
<div className="text-xs text-secondary">分析指数结构 · 连板梯队 · 板块轮动 · 资金情绪</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
@@ -591,14 +514,17 @@ function ReportPanel({
|
||||
// 历史面板
|
||||
// ================================================================
|
||||
function HistoryPanel({
|
||||
reports, loading, viewingId, onView, onDelete,
|
||||
reports, loading, viewingId, generating, onView, onBackToGenerating, onDelete,
|
||||
}: {
|
||||
reports: AiReviewReport[]
|
||||
loading: boolean
|
||||
viewingId: string | null
|
||||
generating: boolean
|
||||
onView: (r: AiReviewReport) => void
|
||||
onBackToGenerating: () => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const empty = !generating && reports.length === 0
|
||||
return (
|
||||
<div className="overflow-hidden rounded-card border border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 border-b border-border bg-gradient-to-r from-accent/5 to-transparent px-3 py-2.5">
|
||||
@@ -609,14 +535,32 @@ function HistoryPanel({
|
||||
<div className="max-h-[calc(100vh-26rem)] overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="grid h-20 place-items-center"><RefreshCw className="h-4 w-4 animate-spin text-muted" /></div>
|
||||
) : reports.length === 0 ? (
|
||||
) : empty ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-3 py-10 text-center">
|
||||
<History className="h-7 w-7 text-muted/40" strokeWidth={1.5} />
|
||||
<div className="text-[11px] text-muted">暂无历史复盘</div>
|
||||
<div className="text-[10px] text-muted/60">生成后点「归档」即可保存</div>
|
||||
<div className="text-[10px] text-muted/60">生成完成后自动归档</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* 生成中占位项:列表顶部,点击回到正在生成的流式内容 */}
|
||||
{generating && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded px-2 py-2 cursor-pointer transition-colors',
|
||||
viewingId === null ? 'bg-accent/10 ring-1 ring-accent/20' : 'hover:bg-elevated/60',
|
||||
)}
|
||||
onClick={onBackToGenerating}
|
||||
>
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-accent/15">
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[11px] font-medium text-accent">生成中…</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-secondary">AI 正在复盘今日盘面</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reports.map((r) => {
|
||||
const color = scoreColor(r.emotion_score)
|
||||
return (
|
||||
@@ -637,13 +581,28 @@ function HistoryPanel({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{r.emotion_label ?? '—'}</span>
|
||||
<span className="font-mono text-[10px] text-muted">{r.as_of}</span>
|
||||
<span className="font-mono text-[10px] text-secondary">{r.as_of}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted">
|
||||
{r.summary ?? r.content.slice(0, 40)}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{r.summary
|
||||
? (() => {
|
||||
const pcts = parseIndexPcts(shortenIndexNames(r.summary).split('|')[0])
|
||||
if (pcts.length === 0) {
|
||||
return <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>
|
||||
}
|
||||
return pcts.map((p) => (
|
||||
<span key={p.name} className="inline-flex items-center gap-0.5 text-[10px]">
|
||||
<span className="text-secondary">{p.name}</span>
|
||||
<span className={cn('font-mono font-medium tabular-nums', pctClass(p.pctNum))}>{p.pctStr}</span>
|
||||
</span>
|
||||
))
|
||||
})()
|
||||
: <span className="truncate text-[10px] text-secondary">{r.content.slice(0, 40)}</span>}
|
||||
</div>
|
||||
{r.created_at && (
|
||||
<div className="mt-0.5 font-mono text-[9px] text-muted">{fmtArchivedAt(r.created_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(r.id) }}
|
||||
className="shrink-0 p-1 text-muted opacity-0 transition-all hover:text-bear group-hover:opacity-100"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 项目截图
|
||||
|
||||
### 看板 Dashboard
|
||||
|
||||

|
||||
|
||||
### 策略 Screener
|
||||
|
||||

|
||||
|
||||
### 回测 Backtest
|
||||
|
||||

|
||||
|
||||
### 监控中心 Monitor
|
||||
|
||||

|
||||
|
||||
### 连板梯队 Limit Ladder
|
||||
|
||||

|
||||
|
||||
### 概念分析 Concept
|
||||
|
||||

|
||||
|
||||
### 财务分析
|
||||
|
||||

|
||||
|
||||
### 财务分析报告
|
||||
|
||||

|
||||
|
Before Width: | Height: | Size: 653 KiB After Width: | Height: | Size: 653 KiB |
|
Before Width: | Height: | Size: 492 KiB After Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 440 KiB After Width: | Height: | Size: 440 KiB |