重新设计复盘页面并优化页面

This commit is contained in:
shy3130
2026-06-28 13:49:05 +08:00
parent acd0a21c12
commit 33f10c2847
28 changed files with 671 additions and 645 deletions
+7 -3
View File
@@ -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
+129 -238
View File
@@ -11,32 +11,24 @@
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](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) 提供数据服务,使用前请遵守其服务条款。
## 社区
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.60"
__version__ = "0.1.61"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+18 -5
View File
@@ -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','')}) | "
+1 -1
View File
@@ -28,7 +28,7 @@ from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 30
MAX_REPORTS = 20
def _path() -> Path:
+1 -1
View File
@@ -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"
View File
+3 -3
View File
@@ -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"
}
Binary file not shown.
+14 -4
View File
@@ -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"
}
Binary file not shown.
-79
View File
@@ -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"
}
Binary file not shown.
+1 -1
View File
@@ -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>,
)
+162
View File
@@ -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()
}
+14
View File
@@ -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)
}
+20 -1
View File
@@ -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>
</>
+249 -290
View File
@@ -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"
+33
View File
@@ -0,0 +1,33 @@
# 项目截图
### 看板 Dashboard
![看板页面](./dashboard.png)
### 策略 Screener
![策略页](./screener.png)
### 回测 Backtest
![回测页](./backtest.png)
### 监控中心 Monitor
![监控中心](./monitor.png)
### 连板梯队 Limit Ladder
![连板梯队页](./limit-ladder.png)
### 概念分析 Concept
![概念分析](./concept-analysis.png)
### 财务分析
![财务分析](./财务分析.png)
### 财务分析报告
![财务分析报告](./财务分析报告.png)

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