Compare commits
44 Commits
1ae2102b4c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2b53e03e | |||
| 490fb7ac0a | |||
| 566a8be912 | |||
| 476201abed | |||
| e21d2f69ee | |||
| 0ad4b1f030 | |||
| 7a96d51f8a | |||
| f408362c91 | |||
| 008f92ffe3 | |||
| eae13737ff | |||
| fcebba0a32 | |||
| e7cd30b30d | |||
| c1fa0461c1 | |||
| 267b8557e8 | |||
| 4cd74b06f1 | |||
| ef6e308ad1 | |||
| 26a7c02887 | |||
| 5c4ae36adc | |||
| 22415d3f25 | |||
| 6c770f828e | |||
| 833e4e90b9 | |||
| 3a963a130f | |||
| efdfe29ccc | |||
| a415762a77 | |||
| c08e7f2c54 | |||
| 9ecfc2bb71 | |||
| d2f428ec86 | |||
| fc69f51e23 | |||
| 22049582df | |||
| b2a4e90e74 | |||
| 6dfce96bb3 | |||
| 4384f5fa00 | |||
| 331ff3067f | |||
| 11dc81a189 | |||
| 431aae0882 | |||
| 0861e68fe4 | |||
| a83da2b8b4 | |||
| c71f45e147 | |||
| fa89b382df | |||
| e843d7556d | |||
| 580743853a | |||
| 817d130fd3 | |||
| 8031f27f85 | |||
| 274b90eef6 |
@@ -1 +1,49 @@
|
||||
# README
|
||||
# A股智能量化工作台
|
||||
|
||||
本项目是一个面向个人散户与量化爱好者的 **A 股「选股 + 监控 + 回测」量化工作台**,采用前后端分离架构,支持 Docker 一键部署。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
stock/
|
||||
├── local/ # 本地完整版(满血功能)
|
||||
└── serve/ # 服务端轻量版(备份 + 基础查看)
|
||||
```
|
||||
|
||||
### local/ — 本地完整版
|
||||
|
||||
自托管的满血工作站,所有功能本地运行:
|
||||
|
||||
- **选股**:20+ 内置策略,支持自定义信号与 AI 生成策略
|
||||
- **回测**:基于 vectorbt 的向量化回测,支持 T+1、手续费、止损等真实约束
|
||||
- **实时监控**:策略 / 个股信号 / 价格 / 异动四类规则,SSE 实时告警 + 飞书推送
|
||||
- **个股分析**:日 K 图表、9 类关键价位、AI 四维分析
|
||||
- **数据扩展**:TickFlow 多源数据 + 第三方 HTTP / CSV / Excel 接入
|
||||
|
||||
详细说明与部署指南见 [`local/README.md`](./local/README.md)。
|
||||
|
||||
### serve/ — 服务端轻量版
|
||||
|
||||
`local/` 的云端伴侣,用于**数据备份**与**远程基础查看**:
|
||||
|
||||
- 接收 `local/` 推送的选股结果、复盘报告、历史 K 线等基础数据
|
||||
- 支持多用户(admin / viewer)角色隔离
|
||||
- 不包含回测、实时监控、实时行情等需要本地运行的能力
|
||||
|
||||
详细说明与部署指南见 [`serve/README.md`](./serve/README.md)。
|
||||
|
||||
## 快速选择
|
||||
|
||||
| 场景 | 使用目录 |
|
||||
| :--- | :--- |
|
||||
| 在自己的电脑 / 服务器上跑完整功能 | [`local/`](./local/README.md) |
|
||||
| 只需要远程备份和查看基础数据 | [`serve/`](./serve/README.md) |
|
||||
| 既要本地分析,又要远程查看 | 同时部署 `local/` + `serve/`,并通过 `SYNC_SERVE_URL` 同步 |
|
||||
|
||||
## 数据安全提示
|
||||
|
||||
两个目录下的 `data/` 文件夹均为运行时生成的用户数据,**不纳入 git 管理**。更新代码时 `git pull` 不会影响已有数据,但请勿使用 `git clean -fdx` 或 `git reset --hard`,以免误删数据。
|
||||
|
||||
## 开源协议
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tickflow-stock-panel contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+112
-106
@@ -1,10 +1,10 @@
|
||||
<div align="center">
|
||||
|
||||
# 📈 A股智能量化工作台
|
||||
# 📈 A股智能量化工作台(本地完整版)
|
||||
|
||||
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
|
||||
**自托管、满血功能的 A 股「选股 + 监控 + 回测」量化工作台**
|
||||
|
||||
**面向个人散户与量化爱好者而生**
|
||||
**面向个人散户与量化爱好者,所有功能本地运行**
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://www.python.org/)
|
||||
@@ -17,26 +17,23 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[同步到服务端](#-同步到服务端)**
|
||||
|
||||
</div>
|
||||
|
||||
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
||||
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
||||
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
||||
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
||||
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
|
||||
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
|
||||
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
||||
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
||||
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
||||
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
||||
- 📡 **实时行情与监控** — 自选股实时行情、五档盘口、监控规则命中后 SSE 弹窗 + 飞书推送
|
||||
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
|
||||
- 🇨🇳 **A 股专用** — 盘后自动 AI 复盘并推送至飞书等;连板梯队、涨停动量、内置 ths 概念 / 行业
|
||||
|
||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
||||
|
||||
|
||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
||||
|
||||
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
|
||||
|
||||
|
||||
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
|
||||
|
||||
> ⚠️ 考虑到 tickflow 数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的 ths 概念/ths 行业(后续更新在这里)
|
||||
>
|
||||
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
|
||||
|
||||
觉得有用可以点个 Star,蟹蟹 🌹
|
||||
|
||||
@@ -44,7 +41,9 @@
|
||||
|
||||
## 🎯 项目定位
|
||||
|
||||
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
|
||||
`local/` 目录下的版本是**本地完整版**,面向希望在自己的电脑或局域网中运行全部功能的用户。
|
||||
|
||||
它包含完整的选股、回测、实时监控、个股分析、财务分析、连板梯队、概念/行业分析等全部能力,是项目功能的「满血」形态。同时支持把数据同步到远程 `serve/` 服务端,作为云端备份和基础数据查看入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -96,19 +95,19 @@
|
||||
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| `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 一并关闭。默认:
|
||||
|
||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
|
||||
### 方式 B:Docker(部署最省心)
|
||||
### 方式 B:Docker(部署最省心)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -116,12 +115,14 @@ docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
容器名:`TickFlow_Local`。默认端口 `3018`,数据持久化到 `./data/`。
|
||||
|
||||
<details>
|
||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
||||
|
||||
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。
|
||||
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。
|
||||
|
||||
**手动分别启动:**
|
||||
**手动分别启动:**
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
@@ -132,42 +133,42 @@ uv run uvicorn app.main:app --reload --port 3018
|
||||
cd frontend && pnpm install && pnpm dev # http://localhost:3011
|
||||
```
|
||||
|
||||
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
|
||||
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
|
||||
|
||||
</details>
|
||||
|
||||
### 🔄 更新代码(已部署用户必读)
|
||||
### 🔄 更新代码(已部署用户必读)
|
||||
|
||||
拉取新版本只需一条命令:
|
||||
拉取新版本只需一条命令:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
|
||||
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
|
||||
|
||||
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
|
||||
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
|
||||
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
|
||||
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
|
||||
> - `git reset --hard`
|
||||
> - 直接删除整个项目文件夹重新 `git clone`
|
||||
>
|
||||
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
|
||||
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
|
||||
|
||||
### 🧭 跑起来后的第一次使用
|
||||
|
||||
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
||||
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
||||
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
||||
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
||||
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
|
||||
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
|
||||
### 🔍 选股引擎(Screener)
|
||||
### 🔍 选股引擎(Screener)
|
||||
|
||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||
|
||||
| 类型 | 代表策略 |
|
||||
| :---------- | :------------------------------------------------------- |
|
||||
@@ -175,79 +176,81 @@ git pull
|
||||
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
||||
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
||||
|
||||
**扩展策略的三种方式:**
|
||||
**扩展策略的三种方式:**
|
||||
|
||||
| 方式 | 说明 |
|
||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||
|
||||
### 📊 指标流水线(Indicators)
|
||||
### 📊 指标流水线(Indicators)
|
||||
|
||||
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
||||
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
||||
|
||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||
|
||||
### 🧪 回测引擎(Backtest)
|
||||
### 🧪 回测引擎(Backtest)
|
||||
|
||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||
|
||||
### 📡 监控中心(Monitor)
|
||||
### 📡 监控中心(Monitor)
|
||||
|
||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||
|
||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
||||
- **SSE 实时推送**:本地运行时,命中告警通过 Server-Sent Events 实时推送到前端
|
||||
|
||||
### 📈 个股分析(Beta)
|
||||
### 📈 个股分析(Beta)
|
||||
|
||||
以「行情 + 关键价位」为主体的单标的决策页:
|
||||
以「行情 + 关键价位」为主体的单标的决策页:
|
||||
|
||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||
|
||||
### 🧰 数据与扩展
|
||||
|
||||
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
||||
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
||||
- **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
|
||||
|
||||
```ini
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
|
||||
```
|
||||
|
||||
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
|
||||
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
|
||||
|
||||
| 档位 | 实时能力 |
|
||||
| :------- | :--------------------------------------- |
|
||||
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
|
||||
| None | 无实时行情,仅历史日 K |
|
||||
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
|
||||
| Starter+ | 全市场实时行情 |
|
||||
| Pro | 分钟 K + 盘口 |
|
||||
| Expert | WebSocket + 财务数据 |
|
||||
|
||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
||||
|
||||
### AI(可选)
|
||||
### AI(可选)
|
||||
|
||||
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
||||
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
||||
|
||||
```ini
|
||||
AI_PROVIDER=openai_compat # openai_compat | ollama
|
||||
@@ -268,15 +271,33 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
|
||||
### 访问密码
|
||||
|
||||
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
|
||||
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
|
||||
|
||||
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
|
||||
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
|
||||
```ini
|
||||
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
|
||||
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
|
||||
```
|
||||
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
|
||||
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
|
||||
|
||||
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
|
||||
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 同步到服务端
|
||||
|
||||
本地完整版支持把核心数据同步到远程 `serve/` 服务端,作为:
|
||||
|
||||
- **异地备份**:防止本地磁盘损坏导致历史数据丢失
|
||||
- **远程查看**:在手机或其他设备上通过浏览器查看基础行情与选股结果
|
||||
|
||||
配置方式:
|
||||
|
||||
```ini
|
||||
SYNC_SERVE_URL=https://your-serve.example.com
|
||||
SYNC_KEY=your-shared-secret
|
||||
```
|
||||
|
||||
> 同步是**单向**的:本地 → 服务端。服务端只接收并展示基础数据,不包含本地版的实时行情、回测、监控等复杂能力。详细说明见 `../serve/README.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -285,12 +306,13 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
| 层 | 选型 |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------ |
|
||||
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
| **桌面端** | PyInstaller + Inno Setup(Windows 安装包) |
|
||||
|
||||
---
|
||||
|
||||
@@ -301,28 +323,12 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
|
||||
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
|
||||
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
|
||||
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
|
||||
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||
| 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。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
|
||||
|
||||
## 社区
|
||||
|
||||
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""数据同步 — 将本地 data/ 打包推送到 serve 端。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/data/sync", tags=["data-sync"])
|
||||
|
||||
# 允许同步的子目录 — 覆盖 serve 功能面板所需的数据
|
||||
SYNCABLE_PARTS = [
|
||||
"kline_daily",
|
||||
"kline_daily_enriched",
|
||||
"kline_index_daily",
|
||||
"kline_index_enriched",
|
||||
"kline_etf_daily",
|
||||
"kline_etf_enriched",
|
||||
"kline_etf_minute",
|
||||
"kline_minute",
|
||||
"adj_factor",
|
||||
"adj_factor_etf",
|
||||
"instruments",
|
||||
"instruments_index",
|
||||
"instruments_etf",
|
||||
"financials",
|
||||
"pools",
|
||||
"ext_data",
|
||||
]
|
||||
|
||||
|
||||
def _part_size_info(data_dir: Path, part: str) -> dict:
|
||||
"""获取指定子目录的文件数和大小。"""
|
||||
part_dir = data_dir / part
|
||||
if not part_dir.exists():
|
||||
return {"file_count": 0, "size_bytes": 0, "last_modified": None}
|
||||
files = list(part_dir.rglob("*.parquet"))
|
||||
if not files:
|
||||
return {"file_count": 0, "size_bytes": 0, "last_modified": None}
|
||||
total = sum(f.stat().st_size for f in files)
|
||||
mtimes = [f.stat().st_mtime for f in files]
|
||||
last_ts = datetime.fromtimestamp(max(mtimes)).isoformat() if mtimes else None
|
||||
return {"file_count": len(files), "size_bytes": total, "last_modified": last_ts}
|
||||
|
||||
|
||||
def _pack_parts(data_dir: Path, parts: list[str]) -> bytes:
|
||||
"""将指定子目录打包为 tar.gz 字节流。"""
|
||||
buf = BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
for part in parts:
|
||||
part_dir = data_dir / part
|
||||
if not part_dir.exists():
|
||||
continue
|
||||
for fpath in sorted(part_dir.rglob("*.parquet")):
|
||||
rel = fpath.relative_to(data_dir)
|
||||
tar.add(fpath, arcname=str(rel))
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class SyncConfig(BaseModel):
|
||||
serve_url: str = ""
|
||||
sync_key: str = ""
|
||||
enable_auto: bool = False
|
||||
interval_minutes: int = 0
|
||||
|
||||
|
||||
# ── 存储配置用的 JSON 文件 ──────────────────────────────────
|
||||
CONFIG_FILE = "sync_config.json"
|
||||
|
||||
|
||||
def _config_path(data_dir: Path) -> Path:
|
||||
return data_dir / "user_data" / CONFIG_FILE
|
||||
|
||||
|
||||
def _load_config(data_dir: Path) -> SyncConfig:
|
||||
cp = _config_path(data_dir)
|
||||
if cp.exists():
|
||||
try:
|
||||
return SyncConfig(**json.loads(cp.read_text()))
|
||||
except Exception:
|
||||
pass
|
||||
return SyncConfig()
|
||||
|
||||
|
||||
def _save_config(data_dir: Path, cfg: SyncConfig) -> None:
|
||||
cp = _config_path(data_dir)
|
||||
cp.parent.mkdir(parents=True, exist_ok=True)
|
||||
cp.write_text(cfg.model_dump_json(indent=2))
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def sync_status(request: Request):
|
||||
"""各数据目录的本地状态 + 同步配置。"""
|
||||
data_dir: Path = request.app.state.datastore.data_dir
|
||||
cfg = _load_config(data_dir)
|
||||
parts = {}
|
||||
for p in SYNCABLE_PARTS:
|
||||
parts[p] = _part_size_info(data_dir, p)
|
||||
return {
|
||||
"parts": parts,
|
||||
"config": cfg.model_dump(),
|
||||
"last_sync": cfg.model_dump().get("_last_sync_time"),
|
||||
}
|
||||
|
||||
|
||||
class ConfigReq(BaseModel):
|
||||
serve_url: str
|
||||
sync_key: str
|
||||
enable_auto: bool = False
|
||||
interval_minutes: int = 0
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
def update_sync_config(request: Request, body: ConfigReq):
|
||||
"""更新同步配置。"""
|
||||
data_dir: Path = request.app.state.datastore.data_dir
|
||||
cfg = SyncConfig(
|
||||
serve_url=body.serve_url.rstrip("/"),
|
||||
sync_key=body.sync_key,
|
||||
enable_auto=body.enable_auto,
|
||||
interval_minutes=body.interval_minutes,
|
||||
)
|
||||
_save_config(data_dir, cfg)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class PushReq(BaseModel):
|
||||
parts: list[str]
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def push_sync(request: Request, body: PushReq):
|
||||
"""打包指定 parts 推送到 serve 端。"""
|
||||
data_dir: Path = request.app.state.datastore.data_dir
|
||||
cfg = _load_config(data_dir)
|
||||
if not cfg.serve_url:
|
||||
raise HTTPException(status_code=400, detail="未配置 serve_url,请先在同步设置中配置")
|
||||
|
||||
requested = [p for p in body.parts if p in SYNCABLE_PARTS]
|
||||
if not requested:
|
||||
raise HTTPException(status_code=400, detail="未指定有效的同步目录")
|
||||
|
||||
# 检查有没有数据可同步
|
||||
available = [p for p in requested if (data_dir / p).exists()]
|
||||
if not available:
|
||||
raise HTTPException(status_code=400, detail="所选目录均无数据可同步")
|
||||
|
||||
# 打包
|
||||
tarball = _pack_parts(data_dir, available)
|
||||
size_bytes = len(tarball)
|
||||
logger.info("packed %d parts -> %d bytes", len(available), size_bytes)
|
||||
|
||||
# 推送
|
||||
upload_url = f"{cfg.serve_url}/api/data/sync/upload"
|
||||
headers = {"X-Sync-Key": cfg.sync_key} if cfg.sync_key else {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=600) as client:
|
||||
resp = await client.post(
|
||||
upload_url,
|
||||
files={"file": ("sync.tar.gz", tarball, "application/gzip")},
|
||||
data={"parts": ",".join(available)},
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
detail = resp.text[:500]
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"serve 返回异常 ({resp.status_code}): {detail}",
|
||||
)
|
||||
result = resp.json()
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"连接 serve 失败: {exc}") from exc
|
||||
|
||||
# 记录上次同步时间
|
||||
cfg_dict = cfg.model_dump()
|
||||
cfg_dict["_last_sync_time"] = datetime.now().isoformat()
|
||||
cfg = SyncConfig(**cfg_dict)
|
||||
_save_config(data_dir, cfg)
|
||||
|
||||
logger.info("sync pushed: parts=%s files=%d", available, result.get("file_count", 0))
|
||||
return {
|
||||
"ok": True,
|
||||
"parts_sent": available,
|
||||
"size_bytes": size_bytes,
|
||||
"serve_response": result,
|
||||
}
|
||||
@@ -95,6 +95,10 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
backtest_range_guard: bool = False
|
||||
|
||||
# Sync — 数据同步到 serve
|
||||
sync_serve_url: str = ""
|
||||
sync_key: str = ""
|
||||
|
||||
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
|
||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||
auth_password: str = ""
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api import analysis, auth as auth_api, backtest, data, data_sync, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
|
||||
from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
@@ -207,7 +207,7 @@ app.add_middleware(
|
||||
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
|
||||
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
|
||||
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
|
||||
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ app.include_router(overview.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(pipeline.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(data_sync.router)
|
||||
app.include_router(ext_data.router)
|
||||
app.include_router(financials.router)
|
||||
app.include_router(stock_analysis.router)
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
||||
container_name: TickFlow_Stock_Panel
|
||||
container_name: TickFlow_Local
|
||||
ports:
|
||||
- "${PORT:-3018}:3018"
|
||||
env_file:
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
BookOpenCheck,
|
||||
ExternalLink,
|
||||
X,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api, type IndexQuote } from '@/lib/api'
|
||||
@@ -78,6 +79,7 @@ const nav = [
|
||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||
{ to: '/trading', label: '交易', icon: Cable },
|
||||
{ to: '/data', label: '数据', icon: Database },
|
||||
{ to: '/sync', label: '数据同步', icon: Upload },
|
||||
] as const
|
||||
|
||||
function fmtIndexValue(v: number | null | undefined) {
|
||||
|
||||
@@ -1752,6 +1752,14 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ strategy_id: strategyId, code }),
|
||||
}),
|
||||
|
||||
// ===== Sync =====
|
||||
syncStatus: (): Promise<SyncStatusResponse> =>
|
||||
request('/api/data/sync/status'),
|
||||
syncConfig: (body: SyncConfigReq): Promise<{ ok: boolean }> =>
|
||||
request('/api/data/sync/config', { method: 'POST', body: JSON.stringify(body) }),
|
||||
syncPush: (parts: string[]): Promise<SyncPushResponse> =>
|
||||
request('/api/data/sync/push', { method: 'POST', body: JSON.stringify({ parts }) }),
|
||||
}
|
||||
|
||||
// ===== Pipeline =====
|
||||
@@ -1931,3 +1939,39 @@ export interface AnalysisMenu {
|
||||
updated_at?: string | null
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
export interface SyncPartInfo {
|
||||
file_count: number
|
||||
size_bytes: number
|
||||
last_modified: string | null
|
||||
}
|
||||
|
||||
export interface SyncStatusResponse {
|
||||
parts: Record<string, SyncPartInfo>
|
||||
config: {
|
||||
serve_url: string
|
||||
sync_key: string
|
||||
enable_auto: boolean
|
||||
interval_minutes: number
|
||||
}
|
||||
last_sync?: string | null
|
||||
}
|
||||
|
||||
export interface SyncPushResponse {
|
||||
ok: boolean
|
||||
parts_sent: string[]
|
||||
size_bytes: number
|
||||
serve_response: {
|
||||
ok: boolean
|
||||
parts: string[]
|
||||
file_count: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncConfigReq {
|
||||
serve_url: string
|
||||
sync_key: string
|
||||
enable_auto?: boolean
|
||||
interval_minutes?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Database,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Settings,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { api, type SyncStatusResponse, type SyncPushResponse } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
type PartEntry = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const ALL_PARTS: PartEntry[] = [
|
||||
{ key: 'kline_daily', label: '日 K 线' },
|
||||
{ key: 'kline_daily_enriched', label: '日 K 增强' },
|
||||
{ key: 'kline_index_daily', label: '指数日 K' },
|
||||
{ key: 'kline_index_enriched', label: '指数日 K 增强' },
|
||||
{ key: 'kline_etf_daily', label: 'ETF 日 K' },
|
||||
{ key: 'kline_etf_enriched', label: 'ETF 日 K 增强' },
|
||||
{ key: 'kline_etf_minute', label: 'ETF 分钟 K' },
|
||||
{ key: 'kline_minute', label: '分钟 K' },
|
||||
{ key: 'adj_factor', label: '复权因子' },
|
||||
{ key: 'adj_factor_etf', label: 'ETF 复权因子' },
|
||||
{ key: 'instruments', label: '股票列表' },
|
||||
{ key: 'instruments_index', label: '指数列表' },
|
||||
{ key: 'instruments_etf', label: 'ETF 列表' },
|
||||
{ key: 'financials', label: '财务数据' },
|
||||
{ key: 'pools', label: '板块池' },
|
||||
{ key: 'ext_data', label: '扩展数据(概念/行业)' },
|
||||
]
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes === 0) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function Sync() {
|
||||
const qc = useQueryClient()
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [serveUrl, setServeUrl] = useState('')
|
||||
const [syncKey, setSyncKey] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(ALL_PARTS.map(p => p.key)))
|
||||
|
||||
// Status
|
||||
const { data: status, isLoading, refetch } = useQuery<SyncStatusResponse>({
|
||||
queryKey: ['sync-status'],
|
||||
queryFn: api.syncStatus,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
// Init form from server config
|
||||
useEffect(() => {
|
||||
if (status?.config) {
|
||||
setServeUrl(status.config.serve_url)
|
||||
setSyncKey(status.config.sync_key)
|
||||
}
|
||||
}, [status?.config])
|
||||
|
||||
// Save config
|
||||
const saveCfg = useMutation<{ ok: boolean }, Error, void>({
|
||||
mutationFn: () => api.syncConfig({ serve_url: serveUrl, sync_key: syncKey }),
|
||||
onSuccess: () => {
|
||||
toast('同步配置已保存', 'success')
|
||||
qc.invalidateQueries({ queryKey: ['sync-status'] })
|
||||
setShowSettings(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Push sync
|
||||
const push = useMutation<SyncPushResponse, Error, void>({
|
||||
mutationFn: () => api.syncPush([...selected]),
|
||||
onSuccess: (res) => {
|
||||
if (res.ok) {
|
||||
toast(
|
||||
`同步完成: ${res.parts_sent.length} 个目录, ${res.serve_response.file_count} 个文件`,
|
||||
'success',
|
||||
)
|
||||
qc.invalidateQueries({ queryKey: ['sync-status'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const togglePart = useCallback((key: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = () => setSelected(new Set(ALL_PARTS.map(p => p.key)))
|
||||
const selectNone = () => setSelected(new Set())
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 px-4 py-6">
|
||||
<PageHeader
|
||||
title="数据同步"
|
||||
subtitle="将本地数据推送到 serve 服务器"
|
||||
/>
|
||||
|
||||
{/* 连接配置 */}
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.config.serve_url ? (
|
||||
<Cloud className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<CloudOff className="h-5 w-5 text-muted" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">Serve 连接</div>
|
||||
<div className="text-xs text-muted">
|
||||
{status?.config.serve_url
|
||||
? status.config.serve_url
|
||||
: '未配置 — 点击右侧设置按钮'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status?.last_sync && (
|
||||
<span className="text-[10px] text-muted">
|
||||
上次同步: {new Date(status.last_sync).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowSettings(v => !v); refetch() }}
|
||||
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title="同步设置"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-btn p-2 text-muted hover:text-foreground hover:bg-elevated transition-colors"
|
||||
title="刷新状态"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<div className="mt-4 space-y-3 border-t border-border pt-4">
|
||||
<div>
|
||||
<label className="text-xs text-muted">Serve 地址</label>
|
||||
<input
|
||||
value={serveUrl}
|
||||
onChange={e => setServeUrl(e.target.value)}
|
||||
placeholder="http://your-server:3018"
|
||||
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted">同步 Key(可选)</label>
|
||||
<input
|
||||
value={syncKey}
|
||||
onChange={e => setSyncKey(e.target.value)}
|
||||
placeholder="留空则不校验"
|
||||
type="password"
|
||||
className="mt-1 w-full rounded border border-border bg-base px-3 py-2 text-sm text-foreground placeholder:text-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveCfg.mutate()}
|
||||
disabled={saveCfg.isPending}
|
||||
className="inline-flex items-center gap-2 rounded-btn bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saveCfg.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 数据选择 */}
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">选择要同步的数据</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<span className="text-xs text-muted">/</span>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="text-xs text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{ALL_PARTS.map(({ key, label }) => {
|
||||
const info = status?.parts?.[key]
|
||||
const hasData = info && info.file_count > 0
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border px-3 py-2.5 cursor-pointer transition-colors',
|
||||
selected.has(key)
|
||||
? 'border-accent/40 bg-accent/5'
|
||||
: 'border-border bg-base hover:border-muted',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(key)}
|
||||
onChange={() => togglePart(key)}
|
||||
className="h-4 w-4 rounded border-border text-accent focus:ring-accent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
{hasData && (
|
||||
<HardDrive className="h-3 w-3 text-accent shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{info && (
|
||||
<div className="mt-0.5 text-[10px] text-muted">
|
||||
{info.file_count > 0
|
||||
? `${info.file_count} 文件 · ${fmtSize(info.size_bytes)}`
|
||||
: '无数据'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 操作 */}
|
||||
<section className="flex items-center justify-between rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Database className="h-4 w-4" />
|
||||
<span>
|
||||
已选 {selected.size} 个目录
|
||||
{push.data && (
|
||||
<span className="ml-2 text-bull">
|
||||
<CheckCircle2 className="inline h-3.5 w-3.5 mr-0.5" />
|
||||
上次同步成功
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => push.mutate()}
|
||||
disabled={push.isPending || selected.size === 0}
|
||||
className="inline-flex items-center gap-2 rounded-btn bg-accent px-5 py-2.5 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{push.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
同步中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
立即同步
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 同步日志 */}
|
||||
{push.data && (
|
||||
<section className="rounded-card border border-border bg-surface p-4">
|
||||
<div className="text-xs text-muted font-mono leading-relaxed">
|
||||
<div>✓ 同步完成</div>
|
||||
<div> 目录: {push.data.parts_sent.join(', ')}</div>
|
||||
<div> 数据包: {fmtSize(push.data.size_bytes)}</div>
|
||||
<div> 文件数: {push.data.serve_response.file_count}</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{push.error && (
|
||||
<section className="rounded-card border border-danger/30 bg-danger/5 p-4">
|
||||
<div className="text-xs text-danger font-mono leading-relaxed">
|
||||
<div>✗ 同步失败</div>
|
||||
<div> {String(push.error)}</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { Branding } from './pages/Branding'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Indices } from './pages/Indices'
|
||||
import { Dev } from './pages/Dev'
|
||||
import { Sync } from './pages/Sync'
|
||||
import { useSettings } from './lib/useSharedQueries'
|
||||
import { Logo } from './components/Logo'
|
||||
|
||||
@@ -77,6 +78,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'data', element: <Data /> },
|
||||
{ path: 'monitor', element: <Monitor /> },
|
||||
{ path: 'trading', element: <Trading /> },
|
||||
{ path: 'sync', element: <Sync /> },
|
||||
{ path: 'limit-ladder', element: <LimitUpLadder /> },
|
||||
{ path: 'indices', element: <Indices /> },
|
||||
{ path: 'branding', element: <Branding /> },
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tickflow-stock-panel contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+132
-244
@@ -1,94 +1,44 @@
|
||||
<div align="center">
|
||||
|
||||
# 📈 A股智能量化工作台
|
||||
# 📈 A股智能量化工作台(服务端备份/查看版)
|
||||
|
||||
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
|
||||
**本地完整版的云端伴侣:数据备份 + 基础数据查看**
|
||||
|
||||
**面向个人散户与量化爱好者而生**
|
||||
**面向已部署 `local/` 完整版的用户,提供远程备份与轻量查看能力**
|
||||
|
||||
[](./LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
|
||||
[](./Dockerfile)
|
||||
[](https://github.com/shy3130/tickflow-stock-panel/stargazers)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)**
|
||||
**[快速开始](#-快速开始)** · **[功能范围](#-功能范围)** · **[接收本地同步](#-接收本地同步)** · **[部署说明](#-部署说明)**
|
||||
|
||||
</div>
|
||||
|
||||
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
|
||||
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
|
||||
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
|
||||
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
|
||||
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
|
||||
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
|
||||
|
||||
|
||||
|
||||
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
|
||||
|
||||
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
|
||||
|
||||
|
||||
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
|
||||
|
||||
|
||||
觉得有用可以点个 Star,蟹蟹 🌹
|
||||
|
||||
---
|
||||
|
||||
## 🎯 项目定位
|
||||
|
||||
**面向个人散户与量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
|
||||
`serve/` 目录下的版本是 **服务端轻量版**,它不是 `local/` 的替代品,而是其**配套服务端**:
|
||||
|
||||
- **数据备份**:接收本地完整版推送的核心数据,防止本地磁盘故障导致历史数据丢失
|
||||
- **远程查看**:在手机、平板或其他设备上通过浏览器查看基础行情、选股结果、复盘报告等
|
||||
- **多用户隔离**:支持 admin / viewer 角色,适合家庭或小团队共享查看
|
||||
|
||||
**它不是满血版**,不包含实时行情、回测、实时监控、五档盘口、AI 策略生成等需要本地运行或消耗大量计算/Quota 的能力。所有复杂分析和决策功能,请在 `local/` 本地完整版中使用。
|
||||
|
||||
---
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>看板 Dashboard</b></td>
|
||||
<td width="50%" align="center"><b>策略 Screener</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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="./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="./screenshots/limit-ladder.png" alt="连板梯队页"></td>
|
||||
<td width="50%"><img src="./screenshots/concept-analysis.png" alt="概念分析"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 📸 [查看更多界面截图 »](./screenshots/README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
## ⚡ 快速开始
|
||||
|
||||
### 前置依赖
|
||||
|
||||
与 `local/` 相同:
|
||||
|
||||
| 工具 | 版本 | 安装 |
|
||||
| :--------------------------------- | :----- | :------------------------------------------------- |
|
||||
| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
|
||||
@@ -96,19 +46,19 @@
|
||||
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| `pnpm` | 9 | `npm i -g pnpm` |
|
||||
|
||||
### 方式 A:Dev 模式(二次开发推荐)
|
||||
### 方式 A:Dev 模式
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
|
||||
cp .env.example .env # 配置监听端口、同步密钥等
|
||||
./dev.sh # Windows: .\dev.ps1
|
||||
```
|
||||
|
||||
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
|
||||
默认:
|
||||
|
||||
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
|
||||
|
||||
### 方式 B:Docker(部署最省心)
|
||||
### 方式 B:Docker(推荐)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -116,213 +66,151 @@ docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
|
||||
容器名:`stock_panel`。默认端口 `3018`。
|
||||
|
||||
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`。
|
||||
|
||||
**手动分别启动:**
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend && uv sync --extra backtest # 含回测依赖
|
||||
uv run uvicorn app.main:app --reload --port 3018
|
||||
|
||||
# 前端
|
||||
cd frontend && pnpm install && pnpm dev # http://localhost:3011
|
||||
```
|
||||
|
||||
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
|
||||
|
||||
</details>
|
||||
|
||||
### 🔄 更新代码(已部署用户必读)
|
||||
|
||||
拉取新版本只需一条命令:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
|
||||
|
||||
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
|
||||
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
|
||||
> - `git reset --hard`
|
||||
> - 直接删除整个项目文件夹重新 `git clone`
|
||||
>
|
||||
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
|
||||
|
||||
### 🧭 跑起来后的第一次使用
|
||||
|
||||
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
|
||||
2. **设置** → **立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
|
||||
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
|
||||
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
|
||||
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
|
||||
> 与 `local/` 不同,`serve/` 的 `data/` 目录**默认被 `.dockerignore` 忽略**,容器重启不会保留容器内的数据。请通过本地 `local/` 主动同步,或将 `data/` 挂载到持久化卷。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心功能
|
||||
## 📦 功能范围
|
||||
|
||||
### 🔍 选股引擎(Screener)
|
||||
| 功能 | serve/ 服务端 | 说明 |
|
||||
| :--- | :---: | :--- |
|
||||
| 看板 Dashboard | ✅ | 展示已同步的基础市场概况 |
|
||||
| 选股结果查看 | ✅ | 查看 local/ 推送的选股结果 |
|
||||
| 概念 / 行业分析 | ✅ | 查看已同步的概念/行业数据 |
|
||||
| 复盘报告 | ✅ | 查看 local/ 生成的盘后 AI 复盘 |
|
||||
| 指数 / 个股基础 K 线 | ✅ | 查看已同步的历史 K 线 |
|
||||
| 多用户认证(admin/viewer) | ✅ | 支持多用户、角色隔离 |
|
||||
| 数据同步接收 | ✅ | 接收 local/ 推送的数据 |
|
||||
| 回测 | ❌ | 请在 local/ 中完成 |
|
||||
| 实时监控 / SSE 告警 | ❌ | 请在 local/ 中使用 |
|
||||
| 五档盘口 / 实时行情 | ❌ | 请在 local/ 中使用 |
|
||||
| 自选实时监控 | ❌ | 请在 local/ 中使用 |
|
||||
| AI 策略生成 | ❌ | 请在 local/ 中使用 |
|
||||
|
||||
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
|
||||
### 适用场景
|
||||
|
||||
| 类型 | 代表策略 |
|
||||
| :---------- | :------------------------------------------------------- |
|
||||
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
|
||||
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
|
||||
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
|
||||
- **异地灾备**:local/ 每天收盘后自动把 enriched 数据、选股结果、复盘报告推送到 serve/
|
||||
- **移动查看**:出差时用手机浏览器访问 serve/,查看本地已分析好的结果
|
||||
- **家庭共享**:家人用 viewer 账号查看,admin 账号管理同步密钥
|
||||
|
||||
**扩展策略的三种方式:**
|
||||
---
|
||||
|
||||
| 方式 | 说明 |
|
||||
| :---------------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
|
||||
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
|
||||
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
|
||||
## 🔄 接收本地同步
|
||||
|
||||
### 📊 指标流水线(Indicators)
|
||||
### 1. 服务端配置
|
||||
|
||||
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
|
||||
在 `serve/.env` 中设置同步密钥(与 local/ 保持一致):
|
||||
|
||||
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
|
||||
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
|
||||
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
|
||||
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
|
||||
- **复权**:基于除权因子自动前复权,回测与指标口径一致
|
||||
```ini
|
||||
SYNC_KEY=your-shared-secret
|
||||
```
|
||||
|
||||
### 🧪 回测引擎(Backtest)
|
||||
首次启动时建议通过 `AUTH_PASSWORD` 预置管理员密码:
|
||||
|
||||
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
|
||||
```ini
|
||||
AUTH_PASSWORD=你的密码
|
||||
```
|
||||
|
||||
### 📡 监控中心(Monitor)
|
||||
### 2. 本地端配置
|
||||
|
||||
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
|
||||
在 `local/.env` 中填写服务端地址和密钥:
|
||||
|
||||
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
|
||||
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
|
||||
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
|
||||
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
|
||||
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
|
||||
```ini
|
||||
SYNC_SERVE_URL=https://your-serve.example.com
|
||||
SYNC_KEY=your-shared-secret
|
||||
```
|
||||
|
||||
### 📈 个股分析(Beta)
|
||||
### 3. 触发同步
|
||||
|
||||
以「行情 + 关键价位」为主体的单标的决策页:
|
||||
在 local/ 的「设置 → 数据同步」页面手动触发,或等待盘后定时任务自动推送。
|
||||
|
||||
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
|
||||
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
|
||||
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
|
||||
### 4. 安全建议
|
||||
|
||||
### 🧰 数据与扩展
|
||||
- 务必通过 HTTPS 暴露 serve/
|
||||
- `SYNC_KEY` 应使用强随机字符串,并定期更换
|
||||
- 不要对外开放 22 / 数据库等无关端口
|
||||
- 若暴露在公网,务必先设置 `AUTH_PASSWORD`
|
||||
|
||||
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
|
||||
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
|
||||
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
|
||||
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
|
||||
---
|
||||
|
||||
## 🏗️ 部署说明
|
||||
|
||||
### 最小资源
|
||||
|
||||
| 资源 | 建议值 |
|
||||
| :--- | :--- |
|
||||
| CPU | 1 核 |
|
||||
| 内存 | 512 MB |
|
||||
| 磁盘 | 根据数据量,建议 ≥ 10 GB |
|
||||
|
||||
### 持久化
|
||||
|
||||
serve/ 的数据主要来自 local/ 同步。如果你希望容器重启后保留数据,请在 `docker-compose.yml` 中挂载持久卷:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /your/host/data:/app/data
|
||||
```
|
||||
|
||||
### 反向代理示例(Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your-serve.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3018;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置
|
||||
|
||||
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
|
||||
|
||||
### 数据源:TickFlow
|
||||
|
||||
```ini
|
||||
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
|
||||
# 同步密钥(必须和 local/.env 里的 SYNC_KEY 一致)
|
||||
SYNC_KEY=your-shared-secret
|
||||
|
||||
# 服务端口
|
||||
HOST=0.0.0.0
|
||||
PORT=3018
|
||||
|
||||
# 首次管理员密码(仅首次启动生效,写入 auth.json 后不再读取)
|
||||
AUTH_PASSWORD=你的密码
|
||||
|
||||
# 日志级别
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 数据目录(Docker 中建议挂载到宿主机)
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
|
||||
完整配置项参考 `serve/.env.example`。
|
||||
|
||||
| 档位 | 实时能力 |
|
||||
| :------- | :--------------------------------------- |
|
||||
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
|
||||
| Starter+ | 全市场实时行情 |
|
||||
| Pro | 分钟 K + 盘口 |
|
||||
| Expert | WebSocket + 财务数据 |
|
||||
---
|
||||
|
||||
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
|
||||
## 🗺️ 与 local/ 的关系
|
||||
|
||||
### AI(可选)
|
||||
|
||||
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
|
||||
|
||||
```ini
|
||||
AI_PROVIDER=openai_compat # openai_compat | ollama
|
||||
AI_BASE_URL=https://api.deepseek.com/v1
|
||||
AI_API_KEY= # 留空 = 关闭 AI
|
||||
AI_MODEL=deepseek-chat
|
||||
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ local/ 本地完整版 │
|
||||
│ 选股 · 回测 · 实时监控 · AI 分析 │
|
||||
│ ↓ 定时/手动同步 │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼ HTTPS + SYNC_KEY
|
||||
┌─────────────────────────────────────┐
|
||||
│ serve/ 服务端轻量版 │
|
||||
│ 数据备份 · 基础查看 · 多用户 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 服务与数据
|
||||
|
||||
```ini
|
||||
HOST=0.0.0.0 # 监听地址
|
||||
PORT=3018 # 服务端口
|
||||
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
|
||||
DATA_DIR=./data # Parquet / DuckDB 数据存储目录
|
||||
```
|
||||
|
||||
### 访问密码
|
||||
|
||||
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
|
||||
|
||||
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
|
||||
```ini
|
||||
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
|
||||
```
|
||||
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
|
||||
|
||||
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 技术栈
|
||||
|
||||
| 层 | 选型 |
|
||||
| :----------- | :------------------------------------------------------------------------------------------------ |
|
||||
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
|
||||
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
|
||||
| **回测** | vectorbt(全项目唯一 pandas 边界) |
|
||||
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
|
||||
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
|
||||
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
|
||||
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 路线图
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
| :----- | :----------------------------------------------------------------- | :--- |
|
||||
| 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/](./docs) —— 策略构建步骤、示例
|
||||
|
||||
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
|
||||
|
||||
## 社区
|
||||
|
||||
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
|
||||
- **local/**:功能核心,承担所有计算、实时行情、回测、监控
|
||||
- **serve/**:数据落地与轻量展示,不承担计算,不直接调用 TickFlow 高配额接口
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""TickFlow Stock Panel backend."""
|
||||
"""A股工作台 backend."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
+106
-28
@@ -1,11 +1,15 @@
|
||||
"""访问认证 API。
|
||||
|
||||
端点:
|
||||
GET /api/auth/status — 是否已设密码、当前会话是否有效
|
||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
|
||||
POST /api/auth/logout — 注销当前会话
|
||||
POST /api/auth/change-password — 改密码(需已登录)
|
||||
GET /api/auth/status — 认证状态 + 当前用户信息
|
||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||
POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流)
|
||||
POST /api/auth/logout — 注销当前会话
|
||||
POST /api/auth/change-password — 改密码(需已登录)
|
||||
GET /api/auth/users — 用户列表(需 admin)
|
||||
POST /api/auth/users — 创建用户(需 admin)
|
||||
DELETE /api/auth/users/{username} — 删除用户(需 admin)
|
||||
POST /api/auth/users/{username}/reset-password — 重置密码(需 admin)
|
||||
|
||||
安全:
|
||||
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
||||
@@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> None:
|
||||
_fail_counter.pop(ip, None)
|
||||
|
||||
|
||||
def _get_current_user(request: Request) -> str:
|
||||
"""从 cookie 获取当前登录的 username, 未登录抛 401。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
username = auth.get_session_user(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
return username
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> str:
|
||||
"""要求当前用户为 admin, 否则抛 403。"""
|
||||
username = _get_current_user(request)
|
||||
token = request.cookies.get(COOKIE_NAME, "")
|
||||
role = auth.get_session_role(token)
|
||||
if role != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
return username
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 端点
|
||||
# ================================================================
|
||||
@@ -110,6 +133,7 @@ class PasswordIn(BaseModel):
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str = Field(default="admin", min_length=1, max_length=64, description="用户名, 默认 admin")
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
@@ -118,13 +142,27 @@ class ChangePasswordIn(BaseModel):
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class CreateUserIn(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
role: str = Field(default="viewer", pattern=r"^(admin|viewer)$")
|
||||
|
||||
|
||||
class ResetPasswordIn(BaseModel):
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status(request: Request) -> dict:
|
||||
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
"""认证状态: 是否已设密码 + 当前请求是否已登录 + 用户信息。"""
|
||||
token = request.cookies.get(COOKIE_NAME) or ""
|
||||
username = auth.get_session_user(token)
|
||||
role = auth.get_session_role(token)
|
||||
return {
|
||||
"configured": auth.is_configured(),
|
||||
"authenticated": bool(token and auth.is_valid_session(token)),
|
||||
"authenticated": bool(username),
|
||||
"username": username or None,
|
||||
"role": role or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -153,18 +191,19 @@ def setup_password(req: PasswordIn, request: Request) -> dict:
|
||||
|
||||
@router.post("/login")
|
||||
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||
"""登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||
ip = _client_ip(request)
|
||||
_check_login_rate_limit(ip)
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
token = auth.verify_and_create_session(req.password)
|
||||
if not token:
|
||||
result = auth.login(req.username, req.password)
|
||||
if not result:
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token, username = result
|
||||
_clear_login_fails(ip)
|
||||
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
||||
response.set_cookie(
|
||||
@@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||
path="/",
|
||||
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
||||
)
|
||||
return {"ok": True, "authenticated": True}
|
||||
return {"ok": True, "authenticated": True, "username": username}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict:
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
||||
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
|
||||
"""修改密码: 需验证旧密码。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not (token and auth.is_valid_session(token)):
|
||||
username = auth.get_session_user(token or "")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
# 验证旧密码
|
||||
new_token = auth.verify_and_create_session(req.old_password)
|
||||
if not new_token:
|
||||
ip = _client_ip(request)
|
||||
_record_login_fail(ip)
|
||||
if not auth.change_password(username, req.old_password, req.new_password):
|
||||
raise HTTPException(status_code=401, detail="旧密码错误")
|
||||
# 临时 token 用完即弃
|
||||
auth.revoke_session(new_token)
|
||||
|
||||
# 改密码(set_password 会清空所有会话)
|
||||
auth.set_password(req.new_password)
|
||||
return {"ok": True, "message": "密码已修改, 请重新登录"}
|
||||
return {"ok": True, "message": "密码已修改"}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 用户管理 (admin only)
|
||||
# ================================================================
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(request: Request) -> dict:
|
||||
"""用户列表(需 admin)。"""
|
||||
_require_admin(request)
|
||||
users = auth.list_users()
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(req: CreateUserIn, request: Request) -> dict:
|
||||
"""创建用户(需 admin)。"""
|
||||
_require_admin(request)
|
||||
try:
|
||||
auth.create_user(req.username, req.password, role=req.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True, "username": req.username}
|
||||
|
||||
|
||||
@router.delete("/users/{username}")
|
||||
def delete_user(username: str, request: Request) -> dict:
|
||||
"""删除用户(需 admin, 不能删除 admin 自身)。"""
|
||||
_require_admin(request)
|
||||
try:
|
||||
ok = auth.delete_user(username)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/users/{username}/reset-password")
|
||||
def reset_password(username: str, req: ResetPasswordIn, request: Request) -> dict:
|
||||
"""重置用户密码(需 admin)。"""
|
||||
_require_admin(request)
|
||||
try:
|
||||
ok = auth.admin_reset_password(username, req.new_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail=f"用户 '{username}' 不存在")
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""数据同步 — 接收 local 端推送的 Parquet 数据包。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/data/sync", tags=["data-sync"])
|
||||
|
||||
# 允许同步的子目录列表
|
||||
SYNCABLE_PARTS = {
|
||||
"kline_daily", "kline_daily_enriched",
|
||||
"kline_index_daily", "kline_index_enriched",
|
||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute",
|
||||
"kline_minute",
|
||||
"adj_factor", "adj_factor_etf",
|
||||
"instruments", "instruments_index", "instruments_etf",
|
||||
"financials",
|
||||
"pools",
|
||||
"ext_data",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_unique_key(columns: list[str]) -> list[str] | None:
|
||||
"""根据列名推断去重键。
|
||||
|
||||
策略:
|
||||
- 含 symbol + datetime: 按 [symbol, datetime] 去重(分钟 K)
|
||||
- 含 symbol + date: 按 [symbol, date] 去重(日 K / 复权因子)
|
||||
- 含 symbol: 按 [symbol] 去重(标的维表)
|
||||
- 其他: 无法推断,回退到覆盖
|
||||
"""
|
||||
cols = set(columns)
|
||||
if "symbol" not in cols:
|
||||
return None
|
||||
if "datetime" in cols:
|
||||
return ["symbol", "datetime"]
|
||||
if "date" in cols:
|
||||
return ["symbol", "date"]
|
||||
return ["symbol"]
|
||||
|
||||
|
||||
def _merge_parquet(target: Path, new_bytes: bytes) -> tuple[bool, str]:
|
||||
"""把 new_bytes 代表的 Parquet 与 target 已有文件合并去重。
|
||||
|
||||
返回 (是否成功落盘, 操作描述):
|
||||
- created: 目标不存在,直接新建
|
||||
- merged: 与已有文件按主键合并去重(保留后写入的记录)
|
||||
- empty_new: 上传文件为空,保留旧文件
|
||||
- overwritten_no_key: 无法推断去重键,回退覆盖
|
||||
- overwritten_corrupt_existing: 已有文件损坏,回退覆盖
|
||||
- invalid_new: 上传文件不是合法 Parquet,未落盘
|
||||
"""
|
||||
if not target.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(new_bytes)
|
||||
return True, "created"
|
||||
|
||||
try:
|
||||
new_df = pl.read_parquet(BytesIO(new_bytes))
|
||||
except Exception as exc:
|
||||
logger.warning("failed to parse uploaded parquet %s: %s", target.name, exc)
|
||||
return False, "invalid_new"
|
||||
|
||||
if new_df.is_empty():
|
||||
return True, "empty_new"
|
||||
|
||||
key = _resolve_unique_key(new_df.columns)
|
||||
if key is None:
|
||||
logger.warning(
|
||||
"no unique key for %s (columns=%s), overwriting",
|
||||
target.name,
|
||||
new_df.columns,
|
||||
)
|
||||
target.write_bytes(new_bytes)
|
||||
return True, "overwritten_no_key"
|
||||
|
||||
try:
|
||||
existing_df = pl.read_parquet(target)
|
||||
except Exception as exc:
|
||||
logger.warning("existing parquet %s corrupt, overwriting: %s", target, exc)
|
||||
target.write_bytes(new_bytes)
|
||||
return True, "overwritten_corrupt_existing"
|
||||
|
||||
if existing_df.is_empty():
|
||||
merged = new_df
|
||||
action = "created"
|
||||
else:
|
||||
merged = pl.concat([existing_df, new_df], how="diagonal_relaxed").unique(
|
||||
subset=key, keep="last"
|
||||
)
|
||||
action = "merged"
|
||||
|
||||
# 原子写入,防止写入过程中崩溃导致文件损坏
|
||||
tmp = target.with_suffix(f".tmp-{os.getpid()}")
|
||||
try:
|
||||
merged.write_parquet(tmp)
|
||||
os.replace(tmp, target)
|
||||
except Exception:
|
||||
if tmp.exists():
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return True, action
|
||||
|
||||
|
||||
def _verify_sync_key(request: Request) -> None:
|
||||
"""简单的鉴权 — 校验 X-Sync-Key 头。"""
|
||||
key = request.headers.get("X-Sync-Key", "")
|
||||
expected = getattr(settings, "sync_key", "")
|
||||
if expected and key != expected:
|
||||
raise HTTPException(status_code=403, detail="sync key mismatch")
|
||||
|
||||
|
||||
def _refresh_views(request: Request) -> None:
|
||||
"""重新注册 DuckDB 视图 + 刷新 Polars 缓存。"""
|
||||
repo = getattr(request.app.state, "repo", None)
|
||||
if repo is None:
|
||||
return
|
||||
try:
|
||||
d = repo.store.data_dir.as_posix()
|
||||
views = {
|
||||
"kline_daily": f"{d}/kline_daily/**/*.parquet",
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
|
||||
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
|
||||
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
|
||||
}
|
||||
for name, path in views.items():
|
||||
try:
|
||||
repo.store.db.execute(
|
||||
f"CREATE OR REPLACE VIEW {name} AS "
|
||||
f"SELECT * FROM read_parquet('{path}', union_by_name=true)"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("view %s refresh failed: %s", name, exc)
|
||||
repo.store._register_unified_views()
|
||||
repo.clear_cache()
|
||||
repo.refresh_cache()
|
||||
# 清除 API 数据缓存
|
||||
from app.api.data import invalidate_data_cache
|
||||
invalidate_data_cache()
|
||||
except Exception as exc:
|
||||
logger.warning("cache refresh failed: %s", exc)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_sync(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
parts: str = Form(""),
|
||||
_auth: None = Depends(_verify_sync_key),
|
||||
):
|
||||
"""接收 local 端打包的 tar.gz 数据,解压写入 data/ 目录。"""
|
||||
data_dir: Path = request.app.state.datastore.data_dir
|
||||
|
||||
# 解析要同步的 parts
|
||||
requested = set(p.strip() for p in parts.split(",") if p.strip())
|
||||
if not requested:
|
||||
raise HTTPException(status_code=400, detail="parts 不能为空,逗号分隔子目录名")
|
||||
|
||||
invalid = requested - SYNCABLE_PARTS
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的同步目录: {', '.join(sorted(invalid))}",
|
||||
)
|
||||
|
||||
# 读取上传的 tar.gz
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||
|
||||
extracted = 0
|
||||
action_counter: dict[str, int] = {}
|
||||
try:
|
||||
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
# 安全校验: 防止路径穿越
|
||||
member_path = Path(member.name)
|
||||
if member_path.is_absolute() or ".." in member_path.parts:
|
||||
logger.warning("skipping unsafe path: %s", member.name)
|
||||
continue
|
||||
# 确定属于哪个 part
|
||||
top_dir = member_path.parts[0] if member_path.parts else ""
|
||||
if top_dir not in requested:
|
||||
continue
|
||||
target = data_dir / member.name
|
||||
if member.isfile():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tar.extractfile(member) as src:
|
||||
if src is None:
|
||||
continue
|
||||
ok, action = _merge_parquet(target, src.read())
|
||||
if ok:
|
||||
extracted += 1
|
||||
action_counter[action] = action_counter.get(action, 0) + 1
|
||||
except tarfile.TarError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
|
||||
|
||||
# 刷新缓存
|
||||
_refresh_views(request)
|
||||
|
||||
logger.info("sync uploaded: parts=%s files=%d actions=%s", parts, extracted, action_counter)
|
||||
return {"ok": True, "parts": sorted(requested), "file_count": extracted, "actions": action_counter}
|
||||
@@ -26,7 +26,6 @@ from app.services.ext_data import (
|
||||
write_ext_parquet,
|
||||
rows_to_parquet,
|
||||
)
|
||||
from app.services.ext_pull import fetch_and_ingest, pull_scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/ext-data", tags=["ext-data"])
|
||||
@@ -330,20 +329,8 @@ async def fetch_preset_data(request: Request, config_id: str):
|
||||
"""手动触发内置预设 (概念/行业) 的数据拉取。
|
||||
|
||||
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
|
||||
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
|
||||
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
|
||||
"""
|
||||
from app.services.ext_presets import fetch_preset
|
||||
|
||||
try:
|
||||
n = await fetch_preset(config_id, _data_dir(request))
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
_refresh_views(request)
|
||||
return {"status": "ok", "rows": n}
|
||||
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -548,115 +535,20 @@ def ingest_data(request: Request, config_id: str, body: IngestReq):
|
||||
|
||||
@router.put("/{config_id}/pull")
|
||||
def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
"""配置(或更新)定时拉取。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
|
||||
# 保留历史状态字段
|
||||
old_pull = config.pull
|
||||
config.pull = PullConfig(
|
||||
url=body.url,
|
||||
method=body.method,
|
||||
headers=body.headers,
|
||||
body=body.body,
|
||||
response_path=body.response_path,
|
||||
field_map=body.field_map,
|
||||
schedule_minutes=body.schedule_minutes,
|
||||
enabled=body.enabled,
|
||||
last_run=old_pull.last_run if old_pull else None,
|
||||
last_status=old_pull.last_status if old_pull else None,
|
||||
last_message=old_pull.last_message if old_pull else None,
|
||||
last_rows=old_pull.last_rows if old_pull else None,
|
||||
)
|
||||
store.upsert(config)
|
||||
|
||||
# 刷新调度器
|
||||
pull_scheduler.refresh(_data_dir(request))
|
||||
|
||||
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
|
||||
if not config.pull.enabled:
|
||||
cleared = store.get(config_id)
|
||||
if cleared and cleared.pull and cleared.pull.next_run:
|
||||
cleared.pull.next_run = None
|
||||
store.upsert(cleared)
|
||||
|
||||
return {"status": "ok", "pull": config.pull.to_dict()}
|
||||
"""配置(或更新)定时拉取 — serve 端为只读模式,已禁用。"""
|
||||
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||
|
||||
|
||||
@router.post("/{config_id}/pull/test")
|
||||
async def test_pull(request: Request, config_id: str):
|
||||
"""测试拉取:请求外部 API 并返回预览数据,不写入。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if not config.pull or not config.pull.url:
|
||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
||||
|
||||
# 临时构建一个带新配置的 config 用于测试
|
||||
from app.services.ext_pull import _extract_rows, _apply_field_map
|
||||
import httpx
|
||||
|
||||
pull = config.pull
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
headers = pull.headers or {}
|
||||
kwargs: dict = {"headers": headers}
|
||||
if pull.method.upper() == "POST" and pull.body:
|
||||
kwargs["content"] = pull.body
|
||||
if "content-type" not in {k.lower() for k in headers}:
|
||||
kwargs["headers"]["Content-Type"] = "application/json"
|
||||
resp = await client.request(pull.method.upper(), pull.url, **kwargs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
rows = _extract_rows(data, pull.response_path)
|
||||
preview = _apply_field_map(rows[:5], pull.field_map)
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_rows": len(rows),
|
||||
"preview": preview,
|
||||
"has_symbol": bool(rows and "symbol" in rows[0]),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"测试失败: {e}") from e
|
||||
"""测试拉取 — serve 端为只读模式,已禁用。"""
|
||||
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||
|
||||
|
||||
@router.post("/{config_id}/pull/run")
|
||||
async def run_pull(request: Request, config_id: str):
|
||||
"""手动触发一次拉取并写入。"""
|
||||
store = _store(request)
|
||||
config = store.get(config_id)
|
||||
if not config:
|
||||
raise HTTPException(404, f"配置 '{config_id}' 不存在")
|
||||
if not config.pull or not config.pull.url:
|
||||
raise HTTPException(400, "拉取未配置或 URL 为空")
|
||||
|
||||
try:
|
||||
n, d = await fetch_and_ingest(config, _data_dir(request))
|
||||
_refresh_views(request)
|
||||
# 写回执行状态, 让前端"上次执行"面板立即反映
|
||||
updated = store.get(config_id)
|
||||
if updated and updated.pull:
|
||||
from datetime import datetime, timezone
|
||||
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
updated.pull.last_status = "success"
|
||||
updated.pull.last_message = f"{n} rows @ {d}"
|
||||
updated.pull.last_rows = n
|
||||
store.upsert(updated)
|
||||
return {"status": "ok", "rows": n, "date": d}
|
||||
except Exception as e:
|
||||
# 失败也写回状态, 记录错误信息
|
||||
failed = store.get(config_id)
|
||||
if failed and failed.pull:
|
||||
from datetime import datetime, timezone
|
||||
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
failed.pull.last_status = "error"
|
||||
failed.pull.last_message = str(e)[:200]
|
||||
store.upsert(failed)
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,11 +20,7 @@ router = APIRouter(prefix="/api/financials", tags=["financials"])
|
||||
|
||||
@router.get("/status")
|
||||
def financial_status(request: Request):
|
||||
"""返回各财务表的同步状态。无需 FINANCIAL 权限(前端根据 available 决定是否展示)。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"available": False, "tables": {}}
|
||||
|
||||
"""返回各财务表的同步状态。"""
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
tables = {}
|
||||
|
||||
@@ -42,15 +38,16 @@ def financial_status(request: Request):
|
||||
else:
|
||||
tables[table] = {"rows": 0, "symbols": 0}
|
||||
|
||||
capset = request.app.state.capabilities
|
||||
has_cap = capset.has(Cap.FINANCIAL)
|
||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
||||
last_sync = fs.last_sync if fs else {}
|
||||
has_data = any(t.get("rows", 0) > 0 for t in tables.values())
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"available": has_cap or has_data,
|
||||
"tables": tables,
|
||||
"last_sync": last_sync,
|
||||
# 服务端是否正在同步(手动触发)——前端据此显示"同步中"并防重复点击,
|
||||
# 且刷新页面后仍能正确反映服务端状态。
|
||||
"syncing": bool(fs and fs.is_syncing),
|
||||
}
|
||||
|
||||
@@ -58,9 +55,6 @@ def financial_status(request: Request):
|
||||
@router.get("/metrics")
|
||||
def get_metrics(request: Request, symbol: str | None = None):
|
||||
"""查询核心财务指标。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "metrics")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
@@ -72,9 +66,6 @@ def get_metrics(request: Request, symbol: str | None = None):
|
||||
@router.get("/income")
|
||||
def get_income(request: Request, symbol: str | None = None):
|
||||
"""查询利润表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "income")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
@@ -86,9 +77,6 @@ def get_income(request: Request, symbol: str | None = None):
|
||||
@router.get("/balance-sheet")
|
||||
def get_balance_sheet(request: Request, symbol: str | None = None):
|
||||
"""查询资产负债表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "balance_sheet")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
@@ -100,9 +88,6 @@ def get_balance_sheet(request: Request, symbol: str | None = None):
|
||||
@router.get("/cash-flow")
|
||||
def get_cash_flow(request: Request, symbol: str | None = None):
|
||||
"""查询现金流量表。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
df = get_financial_df(request.app.state.repo.store.data_dir, "cash_flow")
|
||||
if df.is_empty():
|
||||
return {"data": []}
|
||||
@@ -113,27 +98,8 @@ def get_cash_flow(request: Request, symbol: str | None = None):
|
||||
|
||||
@router.post("/sync/{table}")
|
||||
def sync_table(request: Request, table: str):
|
||||
"""手动触发同步(立即返回,后台异步执行)。
|
||||
|
||||
table: metrics / income / balance_sheet / cash_flow / all
|
||||
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
|
||||
前端通过轮询 GET /status 的 syncing 字段观察进度。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
valid_tables = {"metrics", "income", "balance_sheet", "cash_flow", "all"}
|
||||
if table not in valid_tables:
|
||||
raise HTTPException(400, f"invalid table: {table}, expected one of {valid_tables}")
|
||||
|
||||
fs = getattr(request.app.state, "financial_scheduler", None)
|
||||
if not fs:
|
||||
return {"status": "error", "message": "FinancialScheduler not available"}
|
||||
|
||||
target = None if table == "all" else table
|
||||
result = fs.trigger(target)
|
||||
|
||||
return {"status": "ok", "synced": result}
|
||||
"""手动触发同步 — serve 端为只读模式,已禁用。"""
|
||||
raise HTTPException(403, "serve 端为只读模式,数据仅通过 local→serve 同步")
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
@@ -159,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
|
||||
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus, username=request.state.username):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -185,11 +151,12 @@ class SaveReportRequest(BaseModel):
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
|
||||
"""获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"reports": []}
|
||||
return {"reports": ai_reports.list_reports()}
|
||||
username = getattr(request.state, "username", None)
|
||||
return {"reports": ai_reports.list_reports(username)}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
@@ -197,6 +164,7 @@ def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
username = getattr(request.state, "username", None)
|
||||
report = ai_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
@@ -204,7 +172,7 @@ def save_report(request: Request, req: SaveReportRequest):
|
||||
"content": req.content,
|
||||
"periods": req.periods,
|
||||
"summary": req.summary,
|
||||
})
|
||||
}, username)
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@@ -213,5 +181,6 @@ def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
ok = ai_reports.delete_report(report_id)
|
||||
username = getattr(request.state, "username", None)
|
||||
ok = ai_reports.delete_report(report_id, username)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -55,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
|
||||
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in recap_market_stream(repo, as_of, req.focus):
|
||||
async for chunk in recap_market_stream(repo, as_of, req.focus, username=request.state.username):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -81,13 +81,15 @@ class SaveReportRequest(BaseModel):
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": market_recap_reports.list_reports()}
|
||||
"""获取当前用户的历史复盘(按时间降序,后端已裁剪到上限)。"""
|
||||
username = getattr(request.state, "username", None)
|
||||
return {"reports": market_recap_reports.list_reports(username)}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条复盘报告。"""
|
||||
username = getattr(request.state, "username", None)
|
||||
report = market_recap_reports.save_report({
|
||||
"as_of": req.as_of,
|
||||
"focus": req.focus,
|
||||
@@ -95,9 +97,7 @@ def save_report(request: Request, req: SaveReportRequest):
|
||||
"summary": req.summary,
|
||||
"emotion_score": req.emotion_score,
|
||||
"emotion_label": req.emotion_label,
|
||||
})
|
||||
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
|
||||
# 内部 try/except 静默降级, 不影响归档返回值。
|
||||
}, username)
|
||||
from app.jobs.daily_pipeline import _maybe_push_review
|
||||
_maybe_push_review(req.content, {
|
||||
"as_of": req.as_of,
|
||||
@@ -109,5 +109,6 @@ def save_report(request: Request, req: SaveReportRequest):
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条复盘报告。"""
|
||||
ok = market_recap_reports.delete_report(report_id)
|
||||
username = getattr(request.state, "username", None)
|
||||
ok = market_recap_reports.delete_report(report_id, username)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -55,6 +55,7 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_rotation_stream(
|
||||
repo, days, req.focus,
|
||||
username=request.state.username,
|
||||
):
|
||||
yield chunk + "\n"
|
||||
|
||||
|
||||
@@ -296,13 +296,14 @@ def get_cached(
|
||||
|
||||
|
||||
@router.get("/market-snapshot")
|
||||
def market_snapshot(request: Request):
|
||||
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。"""
|
||||
def market_snapshot(request: Request, as_of: date | None = None):
|
||||
"""全市场轻量行情快照(可选指定日期),供板块/概念聚合分析使用。"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = svc.latest_date()
|
||||
if as_of is None:
|
||||
as_of = svc.latest_date()
|
||||
if not as_of:
|
||||
return {"as_of": None, "rows": []}
|
||||
|
||||
|
||||
@@ -48,15 +48,50 @@ class TickflowKeyIn(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
|
||||
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
|
||||
if username:
|
||||
val = secrets_store.load_ai_config(username).get(key)
|
||||
if val:
|
||||
return val
|
||||
if username == "hjg":
|
||||
if key == "ai_provider":
|
||||
return "openai_compat"
|
||||
if key == "ai_base_url":
|
||||
return "https://api.deepseek.com/v1"
|
||||
if key == "ai_model":
|
||||
return "deepseek-chat"
|
||||
if key == "ai_user_agent":
|
||||
return (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
return secrets_store.get_ai_config(key, default)
|
||||
|
||||
|
||||
def _get_ai_api_key(username: str | None) -> str:
|
||||
"""读 AI API Key: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro Key。"""
|
||||
if username:
|
||||
val = secrets_store.load_ai_config(username).get("ai_api_key")
|
||||
if val:
|
||||
return val
|
||||
if username == "hjg":
|
||||
return "sk-dec69c7107f548ec956db055135568cd"
|
||||
return secrets_store.get_ai_key()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings() -> dict:
|
||||
def get_settings(request: Request) -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
|
||||
|
||||
username = getattr(request.state, "username", None)
|
||||
key = secrets_store.get_tickflow_key()
|
||||
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
|
||||
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
|
||||
ai_api_key = _get_ai_api_key(username)
|
||||
return {
|
||||
"mode": tf_client.current_mode(),
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
@@ -68,15 +103,15 @@ def get_settings() -> dict:
|
||||
"extras_caps": extras_caps(),
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
# AI 配置 (用户级优先)
|
||||
"ai_provider": ai_provider,
|
||||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_base_url": _get_ai_config(username, "ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(ai_api_key),
|
||||
"has_ai_key": bool(ai_api_key),
|
||||
"ai_configured": ai_configured(ai_provider),
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||||
"ai_user_agent": _get_ai_config(username, "ai_user_agent", settings.ai_user_agent),
|
||||
}
|
||||
|
||||
|
||||
@@ -238,11 +273,14 @@ class AiSettingsIn(BaseModel):
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
def save_ai_settings(req: AiSettingsIn, request: Request) -> dict:
|
||||
"""保存 AI 配置(按用户独立存储)。"""
|
||||
from app.config import settings
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
|
||||
|
||||
username = getattr(request.state, "username", None)
|
||||
target = username if username else "global"
|
||||
|
||||
updates: dict = {}
|
||||
if req.provider:
|
||||
updates["ai_provider"] = req.provider
|
||||
@@ -255,10 +293,10 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
updates["ai_api_key"] = req.api_key
|
||||
settings.ai_api_key = req.api_key
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
secrets_store.clear_ai_config(target, "ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.provider == "codex_cli" and not req.model:
|
||||
secrets_store.clear("ai_model")
|
||||
secrets_store.clear_ai_config(target, "ai_model")
|
||||
settings.ai_model = ""
|
||||
elif req.model:
|
||||
updates["ai_model"] = req.model
|
||||
@@ -270,12 +308,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updates["ai_codex_command"] = codex_command
|
||||
settings.ai_codex_command = codex_command
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
# user_agent 允许清空,故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
secrets_store.save_ai_config(target, updates)
|
||||
|
||||
provider = current_ai_provider()
|
||||
return {
|
||||
@@ -288,15 +326,15 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
|
||||
|
||||
@router.delete("/ai")
|
||||
def clear_ai_settings() -> dict:
|
||||
"""一键清空 AI 配置(provider / base_url / api_key / model)。
|
||||
|
||||
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
|
||||
"""
|
||||
def clear_ai_settings(request: Request) -> dict:
|
||||
"""一键清空当前用户的 AI 配置。"""
|
||||
from app.config import settings
|
||||
|
||||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
|
||||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||||
username = getattr(request.state, "username", None)
|
||||
target = username if username else "global"
|
||||
|
||||
secrets_store.clear_ai_config(target, "ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
|
||||
# 同步重置运行时内存
|
||||
settings.ai_provider = "openai_compat"
|
||||
settings.ai_base_url = ""
|
||||
settings.ai_api_key = ""
|
||||
|
||||
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
|
||||
data_dir = repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
|
||||
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus, username=request.state.username):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -191,13 +191,15 @@ class SaveReportRequest(BaseModel):
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": stock_reports.list_reports()}
|
||||
"""获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
|
||||
username = getattr(request.state, "username", None)
|
||||
return {"reports": stock_reports.list_reports(username)}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
username = getattr(request.state, "username", None)
|
||||
report = stock_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
@@ -206,12 +208,13 @@ def save_report(request: Request, req: SaveReportRequest):
|
||||
"summary": req.summary,
|
||||
"close": req.close,
|
||||
"levels": req.levels,
|
||||
})
|
||||
}, username)
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
ok = stock_reports.delete_report(report_id)
|
||||
username = getattr(request.state, "username", None)
|
||||
ok = stock_reports.delete_report(report_id, username)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -345,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
|
||||
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
|
||||
|
||||
try:
|
||||
result = await gen.generate(prompt)
|
||||
result = await gen.generate(prompt, username=request.state.username)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return result
|
||||
@@ -356,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
|
||||
async def ai_generate(req: AIGenerateRequest, request: Request):
|
||||
try:
|
||||
gen = AIStrategyGenerator()
|
||||
result = await gen.generate(req.prompt)
|
||||
result = await gen.generate(req.prompt, username=request.state.username)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
|
||||
@@ -97,6 +97,9 @@ class Settings(BaseSettings):
|
||||
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
|
||||
auth_password: str = ""
|
||||
|
||||
# Sync — 数据同步鉴权 Key(local→serve 推送时校验 X-Sync-Key 头)
|
||||
sync_key: str = ""
|
||||
|
||||
# Data — frozen: exe 同级 data/ 子目录; 非 frozen: 项目根 data/
|
||||
# (均可被环境变量 DATA_DIR 覆盖, pydantic-settings 自动注入)
|
||||
data_dir: Path = _user_data_root()
|
||||
|
||||
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app import __version__
|
||||
from app.api import analysis, auth as auth_api, data, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
|
||||
from app.api import analysis, auth as auth_api, data, data_sync, ext_data, financials, indices, kline, market_recap, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy
|
||||
from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
@@ -29,7 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
||||
"A股工作台 v%s starting (mode=%s)",
|
||||
__version__, tf_client.current_mode(),
|
||||
)
|
||||
|
||||
@@ -63,26 +63,13 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning("scheduler not started: %s", e)
|
||||
app.state.scheduler = None
|
||||
|
||||
# 扩展数据定时拉取
|
||||
from app.services.ext_pull import pull_scheduler
|
||||
pull_scheduler.start(store.data_dir)
|
||||
pull_scheduler.refresh(store.data_dir)
|
||||
app.state.pull_scheduler = pull_scheduler
|
||||
|
||||
# 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据
|
||||
# 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch)
|
||||
# 内置扩展表 (概念/行业): 只创建 config 供 sync 解压时用, 不拉数据。
|
||||
try:
|
||||
from app.services.ext_presets import ensure_builtin_presets
|
||||
await ensure_builtin_presets(store.data_dir)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
|
||||
|
||||
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
|
||||
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
financial_scheduler.start(store.data_dir, capset)
|
||||
app.state.financial_scheduler = financial_scheduler
|
||||
|
||||
# 策略引擎
|
||||
from app.strategy.engine import StrategyEngine
|
||||
from app.services.screener import ScreenerService
|
||||
@@ -105,17 +92,11 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
if app.state.scheduler:
|
||||
app.state.scheduler.shutdown(wait=False)
|
||||
ps = getattr(app.state, "pull_scheduler", None)
|
||||
if ps:
|
||||
ps.stop()
|
||||
fsc = getattr(app.state, "financial_scheduler", None)
|
||||
if fsc:
|
||||
fsc.stop()
|
||||
logger.info("shutdown")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="TickFlow Stock Panel",
|
||||
title="A股工作台",
|
||||
version=__version__,
|
||||
description="A 股选股 + 回测面板 — TickFlow 适配",
|
||||
lifespan=lifespan,
|
||||
@@ -141,7 +122,7 @@ app.add_middleware(
|
||||
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
|
||||
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
|
||||
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
|
||||
_AUTH_WHITELIST_PREFIX = ("/api/auth/", "/api/data/sync/")
|
||||
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
|
||||
|
||||
|
||||
@@ -172,7 +153,9 @@ async def auth_middleware(request: Request, call_next):
|
||||
|
||||
# 情况 3: 已设密码, 检查会话
|
||||
token = request.cookies.get(auth_api.COOKIE_NAME)
|
||||
if token and auth_service.is_valid_session(token):
|
||||
username = auth_service.get_session_user(token or "")
|
||||
if username:
|
||||
request.state.username = username
|
||||
return await call_next(request)
|
||||
# 未登录: 401(前端跳登录页)
|
||||
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
|
||||
@@ -188,6 +171,7 @@ app.include_router(overview.router)
|
||||
app.include_router(analysis.router)
|
||||
app.include_router(pipeline.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(data_sync.router)
|
||||
app.include_router(ext_data.router)
|
||||
app.include_router(financials.router)
|
||||
app.include_router(stock_analysis.router)
|
||||
|
||||
@@ -94,3 +94,53 @@ def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
|
||||
if len(key) <= prefix + suffix:
|
||||
return "•" * len(key)
|
||||
return f"{key[:prefix]}{'•' * 6}{key[-suffix:]}"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 按用户 AI 配置 (data/user_data/ai_settings/{username}.json)
|
||||
# ================================================================
|
||||
|
||||
def _ai_config_path(username: str) -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_settings" / f"{username}.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def load_ai_config(username: str) -> dict:
|
||||
"""加载指定用户的 AI 配置。用户不存在时返回空 dict。"""
|
||||
p = _ai_config_path(username)
|
||||
if p.exists():
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("ai_settings/%s.json malformed: %s", username, e)
|
||||
return {}
|
||||
|
||||
|
||||
def save_ai_config(username: str, updates: dict) -> dict:
|
||||
"""合并写入指定用户的 AI 配置。返回新内容。"""
|
||||
current = load_ai_config(username)
|
||||
current.update({k: v for k, v in updates.items() if v is not None})
|
||||
p = _ai_config_path(username)
|
||||
p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(p, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return current
|
||||
|
||||
|
||||
def clear_ai_config(username: str, *keys: str) -> dict:
|
||||
"""清掉指定用户的 AI 配置字段。不传 keys 则删除整个文件。"""
|
||||
p = _ai_config_path(username)
|
||||
if not p.exists():
|
||||
return {}
|
||||
if not keys:
|
||||
p.unlink()
|
||||
return {}
|
||||
current = load_ai_config(username)
|
||||
for k in keys:
|
||||
current.pop(k, None)
|
||||
p.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return current
|
||||
|
||||
@@ -25,16 +25,74 @@ Message = dict[str, str]
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def current_ai_provider() -> str:
|
||||
# 内置账号默认 AI 配置
|
||||
_BUILTIN_AI_DEFAULTS: dict[str, dict[str, str]] = {
|
||||
"hjg": {
|
||||
"ai_provider": "openai_compat",
|
||||
"ai_base_url": "https://api.deepseek.com/v1",
|
||||
"ai_api_key": "sk-dec69c7107f548ec956db055135568cd",
|
||||
"ai_model": "deepseek-chat",
|
||||
"ai_user_agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _builtin_ai_config(username: str | None, key: str) -> str | None:
|
||||
"""读取内置账号的默认 AI 配置。仅当用户未自行配置时作为回退。"""
|
||||
if not username:
|
||||
return None
|
||||
defaults = _BUILTIN_AI_DEFAULTS.get(username)
|
||||
if not defaults:
|
||||
return None
|
||||
return defaults.get(key)
|
||||
|
||||
|
||||
def current_ai_provider(username: str | None = None) -> str:
|
||||
builtin = _builtin_ai_config(username, "ai_provider")
|
||||
if builtin:
|
||||
return builtin
|
||||
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
|
||||
|
||||
|
||||
def current_ai_model() -> str:
|
||||
if current_ai_provider() == CODEX_CLI_PROVIDER:
|
||||
def current_ai_model(username: str | None = None) -> str:
|
||||
if current_ai_provider(username) == CODEX_CLI_PROVIDER:
|
||||
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
|
||||
# 用户未配置时使用内置默认值(不读 config.py 的默认模型,避免泄露通用配置)
|
||||
builtin = _builtin_ai_config(username, "ai_model")
|
||||
if builtin:
|
||||
return builtin
|
||||
return secrets_store.get_ai_config("ai_model", settings.ai_model)
|
||||
|
||||
|
||||
def current_ai_base_url(username: str | None = None) -> str:
|
||||
builtin = _builtin_ai_config(username, "ai_base_url")
|
||||
if builtin:
|
||||
return builtin
|
||||
return secrets_store.get_ai_config("ai_base_url", settings.ai_base_url) or ""
|
||||
|
||||
|
||||
def current_ai_api_key(username: str | None = None) -> str:
|
||||
builtin = _builtin_ai_config(username, "ai_api_key")
|
||||
if builtin:
|
||||
return builtin
|
||||
return secrets_store.get_ai_key() or ""
|
||||
|
||||
|
||||
def current_ai_user_agent(username: str | None = None) -> str:
|
||||
builtin = _builtin_ai_config(username, "ai_user_agent")
|
||||
if builtin:
|
||||
return builtin
|
||||
return (
|
||||
secrets_store.get_ai_config("ai_user_agent", "")
|
||||
or settings.ai_user_agent
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def current_codex_command() -> str:
|
||||
return normalize_codex_command(
|
||||
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
|
||||
@@ -82,11 +140,11 @@ def codex_cli_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def ai_configured(provider: str | None = None) -> bool:
|
||||
provider = provider or current_ai_provider()
|
||||
def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
|
||||
provider = provider or current_ai_provider(username)
|
||||
if is_codex_cli_provider(provider):
|
||||
return codex_cli_available()
|
||||
return bool(secrets_store.get_ai_key())
|
||||
return bool(current_ai_api_key(username))
|
||||
|
||||
|
||||
async def generate_ai_text(
|
||||
@@ -95,15 +153,17 @@ async def generate_ai_text(
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 3000,
|
||||
timeout: float = 180.0,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
"""Return a complete AI response from the currently configured provider."""
|
||||
if is_codex_cli_provider():
|
||||
if is_codex_cli_provider(current_ai_provider(username)):
|
||||
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||
return await _run_openai_once(
|
||||
messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
@@ -113,13 +173,14 @@ async def stream_ai_text(
|
||||
temperature: float = 0.5,
|
||||
max_tokens: int = 4000,
|
||||
timeout: float = 180.0,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield text deltas from the configured provider.
|
||||
|
||||
Codex CLI only exposes the final assistant message for this use case, so it
|
||||
yields one complete chunk after the command exits.
|
||||
"""
|
||||
if is_codex_cli_provider():
|
||||
if is_codex_cli_provider(current_ai_provider(username)):
|
||||
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
|
||||
return
|
||||
|
||||
@@ -128,6 +189,7 @@ async def stream_ai_text(
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
username=username,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@@ -138,14 +200,15 @@ async def _run_openai_once(
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
timeout: float,
|
||||
username: str | None = None,
|
||||
) -> str:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
ai_key = current_ai_api_key(username)
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
|
||||
|
||||
client = _openai_client(ai_key, timeout)
|
||||
client = _openai_client(ai_key, timeout, username=username)
|
||||
resp = await client.chat.completions.create(
|
||||
model=current_ai_model(),
|
||||
model=current_ai_model(username),
|
||||
messages=list(messages),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
@@ -161,14 +224,15 @@ async def _stream_openai(
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
timeout: float,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
ai_key = current_ai_api_key(username)
|
||||
if not ai_key:
|
||||
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
|
||||
|
||||
client = _openai_client(ai_key, timeout)
|
||||
client = _openai_client(ai_key, timeout, username=username)
|
||||
stream = await client.chat.completions.create(
|
||||
model=current_ai_model(),
|
||||
model=current_ai_model(username),
|
||||
messages=list(messages),
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
@@ -181,13 +245,13 @@ async def _stream_openai(
|
||||
yield delta.content
|
||||
|
||||
|
||||
def _openai_client(api_key: str, timeout: float):
|
||||
def _openai_client(api_key: str, timeout: float, username: str | None = None):
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
|
||||
user_agent = current_ai_user_agent(username)
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
|
||||
base_url=normalize_openai_base_url(current_ai_base_url(username)),
|
||||
timeout=timeout,
|
||||
max_retries=2,
|
||||
default_headers={"User-Agent": user_agent},
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
"""AI 财务分析报告持久化存储。
|
||||
"""AI 财务分析报告持久化存储 — 按用户名隔离。
|
||||
|
||||
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "rpt_xxx", # 唯一 id
|
||||
"symbol": "600519.SH",
|
||||
"name": "贵州茅台",
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"periods": 4, # 基于几期数据生成
|
||||
"summary": "metrics: 1期...", # 数据摘要
|
||||
"created_at": "2026-06-25T10:00:00"
|
||||
}
|
||||
存储位置: data/user_data/reports/{username}/ai_reports.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -27,16 +15,19 @@ logger = logging.getLogger(__name__)
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
def _path(username: str | None = None) -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_reports.json"
|
||||
if username:
|
||||
p = settings.data_dir / "user_data" / "reports" / username / "ai_reports.json"
|
||||
else:
|
||||
p = settings.data_dir / "user_data" / "ai_reports.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
def list_reports(username: str | None = None) -> list[dict]:
|
||||
"""返回指定用户的全部报告(按 created_at 降序)。"""
|
||||
p = _path(username)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -48,54 +39,49 @@ def list_reports() -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
def _save_all(reports: list[dict], username: str | None = None) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
# 保持降序
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
_path(username).write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
|
||||
|
||||
自动补全 id 与 created_at(若缺),并裁剪到上限。
|
||||
"""
|
||||
reports = list_reports()
|
||||
def save_report(report: dict, username: str | None = None) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
|
||||
reports = list_reports(username)
|
||||
if not report.get("id"):
|
||||
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
|
||||
_save_all(reports, username)
|
||||
logger.info("AI report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
def delete_report(report_id: str, username: str | None = None) -> bool:
|
||||
"""删除指定用户的报告。返回是否删除成功。"""
|
||||
reports = list_reports(username)
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
_save_all(reports, username)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def clear_reports() -> int:
|
||||
"""清空全部报告。返回删除数量。"""
|
||||
reports = list_reports()
|
||||
def clear_reports(username: str | None = None) -> int:
|
||||
"""清空指定用户的全部报告。返回删除数量。"""
|
||||
reports = list_reports(username)
|
||||
n = len(reports)
|
||||
if n > 0:
|
||||
_save_all([])
|
||||
_save_all([], username)
|
||||
return n
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
|
||||
from datetime import datetime
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""访问密码认证 — 单用户, 自托管场景。
|
||||
"""多用户访问密码认证 — 自托管场景。
|
||||
|
||||
设计:
|
||||
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
|
||||
即使 auth.json 泄露, 也无法逆向出明文密码。
|
||||
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
|
||||
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
|
||||
- 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户。
|
||||
|
||||
安全要点:
|
||||
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
|
||||
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
|
||||
- 单密码, 不做多用户(避免重构全项目数据层)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,13 +29,20 @@ _PBKDF2_ITER = 200_000
|
||||
_SALT_LEN = 16
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
|
||||
# 会话有效期: 30 天(自托管, 长一点减少重登频率)
|
||||
SESSION_TTL = 30 * 24 * 3600
|
||||
|
||||
_lock = threading.Lock()
|
||||
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
|
||||
_sessions: dict[str, float] = {}
|
||||
# schema 版本
|
||||
_CURRENT_SCHEMA_VERSION = 2
|
||||
|
||||
_lock = threading.Lock()
|
||||
# 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 存储
|
||||
# ================================================================
|
||||
|
||||
def _path() -> Path:
|
||||
from app.config import settings
|
||||
@@ -63,6 +70,10 @@ def _save(data: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 密码工具
|
||||
# ================================================================
|
||||
|
||||
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
||||
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
|
||||
if salt is None:
|
||||
@@ -83,36 +94,228 @@ def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 密码管理
|
||||
# 数据迁移 (v1 → v2)
|
||||
# ================================================================
|
||||
|
||||
def _migrate_from_v1(data: dict) -> dict | None:
|
||||
"""检测旧版单用户格式并迁移。返回迁移后的新数据, 无需迁移则返回 None。"""
|
||||
if data.get("schema_version") == _CURRENT_SCHEMA_VERSION:
|
||||
return None
|
||||
if not data.get("password_hash"):
|
||||
return None
|
||||
|
||||
logger.info("检测到旧版 auth.json, 正在迁移到多用户格式...")
|
||||
new_data = {
|
||||
"schema_version": _CURRENT_SCHEMA_VERSION,
|
||||
"users": {},
|
||||
"sessions": {},
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
# 将旧密码迁移为 admin 用户
|
||||
salt = data.get("password_salt", "")
|
||||
phash = data.get("password_hash", "")
|
||||
new_data["users"]["admin"] = {
|
||||
"password_hash": phash,
|
||||
"password_salt": salt,
|
||||
"role": "admin",
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
|
||||
# 迁移旧 sessions
|
||||
old_sessions = data.get("sessions") or {}
|
||||
now = time.time()
|
||||
for token, expire in old_sessions.items():
|
||||
if isinstance(expire, (int, float)) and expire > now:
|
||||
new_data["sessions"][token] = {"username": "admin", "expire": expire}
|
||||
|
||||
logger.info("auth.json 迁移完成: admin 用户, %d 个会话恢复", len(new_data["sessions"]))
|
||||
return new_data
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 用户管理
|
||||
# ================================================================
|
||||
|
||||
def _ensure_loaded() -> dict:
|
||||
"""加载数据, 必要时执行迁移。"""
|
||||
data = _load()
|
||||
migrated = _migrate_from_v1(data)
|
||||
if migrated is not None:
|
||||
data = migrated
|
||||
_save(data)
|
||||
return data
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""是否已设置访问密码。"""
|
||||
d = _load()
|
||||
return bool(d.get("password_hash"))
|
||||
"""是否已有用户。"""
|
||||
data = _ensure_loaded()
|
||||
return bool(data.get("users"))
|
||||
|
||||
|
||||
def set_password(password: str) -> None:
|
||||
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
|
||||
def create_user(username: str, password: str, role: str = "viewer") -> None:
|
||||
"""创建用户。username 已存在则抛出 ValueError。"""
|
||||
if len(password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
if not username or not username.strip():
|
||||
raise ValueError("用户名不能为空")
|
||||
username = username.strip()
|
||||
if not username.replace("_", "").replace("-", "").isalnum():
|
||||
raise ValueError("用户名只能包含字母、数字、下划线和连字符")
|
||||
|
||||
salt_hex, hash_hex = _hash_password(password)
|
||||
with _lock:
|
||||
_sessions.clear() # 改密码 = 旧会话全部失效
|
||||
_save({
|
||||
data = _ensure_loaded()
|
||||
users = data.setdefault("users", {})
|
||||
if username in users:
|
||||
raise ValueError(f"用户 '{username}' 已存在")
|
||||
users[username] = {
|
||||
"password_hash": hash_hex,
|
||||
"password_salt": salt_hex,
|
||||
"updated_at": int(time.time()),
|
||||
"sessions": {}, # 清空持久化会话
|
||||
"role": role,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
data["updated_at"] = int(time.time())
|
||||
_save(data)
|
||||
logger.info("user created: %s (role=%s)", username, role)
|
||||
|
||||
|
||||
def list_users() -> list[dict]:
|
||||
"""返回用户列表(不含密码哈希)。"""
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
result = []
|
||||
for name, info in users.items():
|
||||
result.append({
|
||||
"username": name,
|
||||
"role": info.get("role", "viewer"),
|
||||
"created_at": info.get("created_at"),
|
||||
})
|
||||
logger.info("access password set")
|
||||
result.sort(key=lambda u: u["created_at"] or 0)
|
||||
return result
|
||||
|
||||
|
||||
def delete_user(username: str) -> bool:
|
||||
"""删除用户。admin 用户不可删除。返回是否成功。"""
|
||||
with _lock:
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
if username not in users:
|
||||
return False
|
||||
if users[username].get("role") == "admin":
|
||||
raise ValueError("不能删除 admin 用户")
|
||||
del users[username]
|
||||
# 清除该用户的所有会话
|
||||
sessions = data.get("sessions") or {}
|
||||
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
|
||||
_sessions.clear() # 重新加载时同步
|
||||
data["updated_at"] = int(time.time())
|
||||
_save(data)
|
||||
logger.info("user deleted: %s", username)
|
||||
return True
|
||||
|
||||
|
||||
def change_password(username: str, old_password: str, new_password: str) -> bool:
|
||||
"""用户自己改密码。验证旧密码, 返回是否成功。"""
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
with _lock:
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
user = users.get(username)
|
||||
if not user:
|
||||
return False
|
||||
if not _verify_password(old_password, user.get("password_salt", ""), user.get("password_hash", "")):
|
||||
return False
|
||||
salt_hex, hash_hex = _hash_password(new_password)
|
||||
user["password_hash"] = hash_hex
|
||||
user["password_salt"] = salt_hex
|
||||
data["updated_at"] = int(time.time())
|
||||
_save(data)
|
||||
# 改密码不清除会话(与单用户模式不同, 多用户下只清自己的)
|
||||
logger.info("password changed: %s", username)
|
||||
return True
|
||||
|
||||
|
||||
def admin_reset_password(username: str, new_password: str) -> bool:
|
||||
"""管理员重置用户密码。清除该用户所有会话。"""
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
with _lock:
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
if username not in users:
|
||||
return False
|
||||
salt_hex, hash_hex = _hash_password(new_password)
|
||||
users[username]["password_hash"] = hash_hex
|
||||
users[username]["password_salt"] = salt_hex
|
||||
# 清除被重置用户的所有会话
|
||||
sessions = data.get("sessions") or {}
|
||||
data["sessions"] = {t: s for t, s in sessions.items() if s.get("username") != username}
|
||||
data["updated_at"] = int(time.time())
|
||||
_save(data)
|
||||
logger.info("password reset by admin: %s", username)
|
||||
return True
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 会话管理
|
||||
# ================================================================
|
||||
|
||||
def login(username: str, password: str) -> tuple[str, str] | None:
|
||||
"""验证密码, 成功则创建会话并返回 (token, username), 失败返回 None。"""
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
user = users.get(username)
|
||||
if not user:
|
||||
return None
|
||||
if not _verify_password(password, user.get("password_salt", ""), user.get("password_hash", "")):
|
||||
return None
|
||||
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
expire = time.time() + SESSION_TTL
|
||||
with _lock:
|
||||
_sessions[token] = {"username": username, "expire": expire}
|
||||
_persist_sessions_locked()
|
||||
return token, username
|
||||
|
||||
|
||||
def revoke_session(token: str) -> None:
|
||||
"""注销会话(登出)。"""
|
||||
with _lock:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
def get_session_user(token: str) -> str | None:
|
||||
"""返回 token 对应的 username, 无效/过期则返回 None。过期自动清理。"""
|
||||
if not token:
|
||||
return None
|
||||
with _lock:
|
||||
session = _sessions.get(token)
|
||||
if session is None:
|
||||
return None
|
||||
if time.time() > session["expire"]:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
return None
|
||||
return session["username"]
|
||||
|
||||
|
||||
def get_session_role(token: str) -> str | None:
|
||||
"""返回 token 对应用户的 role, 无效返回 None。"""
|
||||
username = get_session_user(token)
|
||||
if not username:
|
||||
return None
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
user = users.get(username)
|
||||
return user.get("role") if user else None
|
||||
|
||||
|
||||
def bootstrap_from_env() -> bool:
|
||||
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。
|
||||
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户。
|
||||
|
||||
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
|
||||
明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。
|
||||
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
|
||||
|
||||
Returns:
|
||||
@@ -123,74 +326,86 @@ def bootstrap_from_env() -> bool:
|
||||
pwd = (settings.auth_password or "").strip()
|
||||
if not pwd:
|
||||
return False
|
||||
if is_configured():
|
||||
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
|
||||
data = _ensure_loaded()
|
||||
if data.get("users"):
|
||||
return False
|
||||
try:
|
||||
set_password(pwd)
|
||||
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
|
||||
create_user("admin", pwd, role="admin")
|
||||
logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)")
|
||||
return True
|
||||
except ValueError as e:
|
||||
# 密码不合规 (< 6 位), 记日志但不阻断启动
|
||||
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def verify_and_create_session(password: str) -> str | None:
|
||||
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
|
||||
d = _load()
|
||||
if not d.get("password_hash"):
|
||||
return None
|
||||
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
|
||||
return None
|
||||
token = _secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
expire = time.time() + SESSION_TTL
|
||||
with _lock:
|
||||
_sessions[token] = expire
|
||||
_persist_sessions_locked()
|
||||
return token
|
||||
# ================================================================
|
||||
# 兼容旧版
|
||||
# ================================================================
|
||||
|
||||
|
||||
def revoke_session(token: str) -> None:
|
||||
"""注销会话(登出)。"""
|
||||
# set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用)
|
||||
def set_password(password: str) -> None:
|
||||
"""兼容旧版: 创建/更新 admin 用户密码。"""
|
||||
with _lock:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
data = _ensure_loaded()
|
||||
users = data.get("users") or {}
|
||||
if "admin" in users:
|
||||
salt_hex, hash_hex = _hash_password(password)
|
||||
users["admin"]["password_hash"] = hash_hex
|
||||
users["admin"]["password_salt"] = salt_hex
|
||||
data["updated_at"] = int(time.time())
|
||||
_save(data)
|
||||
_sessions.clear()
|
||||
logger.info("admin password updated via set_password")
|
||||
else:
|
||||
create_user("admin", password, role="admin")
|
||||
|
||||
|
||||
def is_valid_session(token: str) -> bool:
|
||||
"""检查会话是否有效(存在且未过期)。过期则清理。"""
|
||||
if not token:
|
||||
return False
|
||||
with _lock:
|
||||
expire = _sessions.get(token)
|
||||
if expire is None:
|
||||
return False
|
||||
if time.time() > expire:
|
||||
_sessions.pop(token, None)
|
||||
_persist_sessions_locked()
|
||||
return False
|
||||
return True
|
||||
"""兼容旧版: 检查会话是否有效。"""
|
||||
return get_session_user(token) is not None
|
||||
|
||||
|
||||
def verify_and_create_session(password: str) -> str | None:
|
||||
"""兼容旧版: 作为 admin 登录, 返回 token。"""
|
||||
result = login("admin", password)
|
||||
if result:
|
||||
return result[0]
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 启动恢复
|
||||
# ================================================================
|
||||
|
||||
def _persist_sessions_locked() -> None:
|
||||
"""把当前内存会话写回 auth.json(需持锁调用)。"""
|
||||
d = _load()
|
||||
d["sessions"] = {t: exp for t, exp in _sessions.items()}
|
||||
d.setdefault("users", {})
|
||||
d["sessions"] = dict(_sessions)
|
||||
if "schema_version" not in d:
|
||||
d["schema_version"] = _CURRENT_SCHEMA_VERSION
|
||||
_save(d)
|
||||
|
||||
|
||||
def _restore_sessions() -> None:
|
||||
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
|
||||
with _lock:
|
||||
d = _load()
|
||||
data = _ensure_loaded()
|
||||
now = time.time()
|
||||
saved = d.get("sessions") or {}
|
||||
for token, expire in saved.items():
|
||||
if isinstance(expire, (int, float)) and expire > now:
|
||||
_sessions[token] = expire
|
||||
saved = data.get("sessions") or {}
|
||||
for token, session in saved.items():
|
||||
if isinstance(session, dict):
|
||||
expire = session.get("expire", 0)
|
||||
username = session.get("username", "")
|
||||
elif isinstance(session, (int, float)):
|
||||
# 兼容旧版: 旧格式 {token: expire} 映射到 admin
|
||||
expire = session
|
||||
username = "admin"
|
||||
else:
|
||||
continue
|
||||
if expire > now:
|
||||
_sessions[token] = {"username": username, "expire": expire}
|
||||
if len(_sessions) != len(saved):
|
||||
# 有过期会话被清理, 落盘一次
|
||||
_persist_sessions_locked()
|
||||
|
||||
|
||||
|
||||
@@ -285,6 +285,8 @@ async def analyze_rotation_stream(
|
||||
repo,
|
||||
days: int = 12,
|
||||
focus: str = "",
|
||||
*,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
||||
|
||||
@@ -329,7 +331,7 @@ async def analyze_rotation_stream(
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text, ai_configured
|
||||
|
||||
if not ai_configured():
|
||||
if not ai_configured(username=username):
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
|
||||
@@ -344,6 +346,7 @@ async def analyze_rotation_stream(
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4000,
|
||||
username=username,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
|
||||
data_dir: Path,
|
||||
symbol: str,
|
||||
focus: str = "",
|
||||
*,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式分析:yield 出每个文本 chunk。
|
||||
|
||||
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
|
||||
],
|
||||
temperature=0.4,
|
||||
max_tokens=4000,
|
||||
username=username,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -255,6 +255,8 @@ async def recap_market_stream(
|
||||
as_of: date | None = None,
|
||||
focus: str = "",
|
||||
news: list[dict] | None = None,
|
||||
*,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式大盘复盘:yield 出每个 NDJSON 事件。
|
||||
|
||||
@@ -298,6 +300,7 @@ async def recap_market_stream(
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
username=username,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"""AI 大盘复盘报告持久化存储。
|
||||
"""AI 大盘复盘报告持久化存储 — 按用户名隔离。
|
||||
|
||||
与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 ——
|
||||
单独的文件、字段、上限,互不影响。刻意不复用,避免引入 kind 判别字段与分支
|
||||
(解耦 > 抽象)。
|
||||
|
||||
存储位置: data/user_data/ai_market_recaps.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "mkr_xxx", # 唯一 id(market-recap-report)
|
||||
"as_of": "2026-06-27", # 复盘日期
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"summary": "三大指数齐涨...", # 一句话摘要
|
||||
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
|
||||
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
|
||||
"created_at": "2026-06-27T15:35:00"
|
||||
}
|
||||
存储位置: data/user_data/reports/{username}/ai_market_recaps.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
|
||||
MAX_REPORTS = 20
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
def _path(username: str | None = None) -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
|
||||
if username:
|
||||
p = settings.data_dir / "user_data" / "reports" / username / "ai_market_recaps.json"
|
||||
else:
|
||||
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
def list_reports(username: str | None = None) -> list[dict]:
|
||||
"""返回指定用户的全部报告(按 created_at 降序)。"""
|
||||
p = _path(username)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -52,37 +39,37 @@ def list_reports() -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
def _save_all(reports: list[dict], username: str | None = None) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
_path(username).write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
def save_report(report: dict, username: str | None = None) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
|
||||
reports = list_reports()
|
||||
reports = list_reports(username)
|
||||
if not report.get("id"):
|
||||
report["id"] = f"mkr_{int(time.time() * 1000)}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("Market recap saved: %s (as_of=%s), total %d",
|
||||
report.get("id"), report.get("as_of"), len(reports))
|
||||
_save_all(reports, username)
|
||||
logger.info("Market recap saved by %s: %s (as_of=%s), total %d",
|
||||
username or "?", report.get("id"), report.get("as_of"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
def delete_report(report_id: str, username: str | None = None) -> bool:
|
||||
"""删除指定用户的报告。返回是否删除成功。"""
|
||||
reports = list_reports(username)
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
_save_all(reports, username)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
|
||||
data_dir: Path,
|
||||
symbol: str,
|
||||
focus: str = "",
|
||||
*,
|
||||
username: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式个股分析:yield 出每个 NDJSON 事件。
|
||||
|
||||
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4500,
|
||||
username=username,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"""AI 个股分析报告持久化存储。
|
||||
"""AI 个股分析报告持久化存储 — 按用户名隔离。
|
||||
|
||||
与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限,
|
||||
互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。
|
||||
|
||||
存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序)
|
||||
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
|
||||
|
||||
每条报告结构:
|
||||
{
|
||||
"id": "sar_xxx", # 唯一 id(stock-analysis-report)
|
||||
"symbol": "600519.SH",
|
||||
"name": "贵州茅台",
|
||||
"focus": "", # 用户追加的关心点(可为空)
|
||||
"content": "# ...markdown", # 报告正文
|
||||
"summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
|
||||
"levels": {...}, # 报告生成时的关键价位(供图表回放)
|
||||
"close": 12.3, # 报告生成时的收盘价
|
||||
"created_at": "2026-06-26T10:00:00"
|
||||
}
|
||||
存储位置: data/user_data/reports/{username}/ai_stock_reports.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
|
||||
MAX_REPORTS = 50
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
def _path(username: str | None = None) -> Path:
|
||||
from app.config import settings
|
||||
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
|
||||
if username:
|
||||
p = settings.data_dir / "user_data" / "reports" / username / "ai_stock_reports.json"
|
||||
else:
|
||||
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def list_reports() -> list[dict]:
|
||||
"""返回全部报告(按 created_at 降序)。"""
|
||||
p = _path()
|
||||
def list_reports(username: str | None = None) -> list[dict]:
|
||||
"""返回指定用户的全部报告(按 created_at 降序)。"""
|
||||
p = _path(username)
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
@@ -52,36 +39,36 @@ def list_reports() -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def _save_all(reports: list[dict]) -> None:
|
||||
def _save_all(reports: list[dict], username: str | None = None) -> None:
|
||||
"""全量写入(裁剪到 MAX_REPORTS)。"""
|
||||
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
||||
if len(reports) > MAX_REPORTS:
|
||||
reports = reports[:MAX_REPORTS]
|
||||
_path().write_text(
|
||||
_path(username).write_text(
|
||||
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def save_report(report: dict) -> dict:
|
||||
def save_report(report: dict, username: str | None = None) -> dict:
|
||||
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
|
||||
reports = list_reports()
|
||||
reports = list_reports(username)
|
||||
if not report.get("id"):
|
||||
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
|
||||
if not report.get("created_at"):
|
||||
report["created_at"] = _now_iso()
|
||||
reports.append(report)
|
||||
_save_all(reports)
|
||||
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
|
||||
_save_all(reports, username)
|
||||
logger.info("Stock report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
|
||||
return report
|
||||
|
||||
|
||||
def delete_report(report_id: str) -> bool:
|
||||
"""删除指定报告。返回是否删除成功。"""
|
||||
reports = list_reports()
|
||||
def delete_report(report_id: str, username: str | None = None) -> bool:
|
||||
"""删除指定用户的报告。返回是否删除成功。"""
|
||||
reports = list_reports(username)
|
||||
before = len(reports)
|
||||
reports = [r for r in reports if r.get("id") != report_id]
|
||||
if len(reports) < before:
|
||||
_save_all(reports)
|
||||
_save_all(reports, username)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
|
||||
self._guide_cache = ""
|
||||
return self._guide_cache
|
||||
|
||||
async def generate(self, user_prompt: str) -> dict:
|
||||
async def generate(self, user_prompt: str, *, username: str | None = None) -> dict:
|
||||
"""根据用户描述生成策略代码
|
||||
|
||||
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
|
||||
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
|
||||
guide = self._get_guide()
|
||||
|
||||
# 调用 LLM
|
||||
code = await self._call_llm(user_prompt, guide)
|
||||
code = await self._call_llm(user_prompt, guide, username=username)
|
||||
|
||||
# 验证
|
||||
try:
|
||||
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
|
||||
|
||||
return {"code": code, "meta": meta, "valid": True, "error": None}
|
||||
|
||||
async def _call_llm(self, user_prompt: str, guide: str) -> str:
|
||||
async def _call_llm(self, user_prompt: str, guide: str, username: str | None = None) -> str:
|
||||
"""Call the configured AI provider and return generated strategy code."""
|
||||
from app.services.ai_provider import generate_ai_text
|
||||
|
||||
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=3000,
|
||||
username=username,
|
||||
)
|
||||
# Extract fenced code if the model wrapped the answer in Markdown.
|
||||
if "```python" in content:
|
||||
|
||||
Generated
+1
-1
@@ -2491,7 +2491,7 @@ all = [
|
||||
|
||||
[[package]]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.66"
|
||||
version = "0.1.70"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
|
||||
container_name: TickFlow_Stock_Panel
|
||||
container_name: stock_panel
|
||||
ports:
|
||||
- "${PORT:-3018}:3018"
|
||||
env_file:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#8B5CF6" />
|
||||
<title>TickFlow Stock Panel · Quant Terminal</title>
|
||||
<title>A股工作台</title>
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ToastContainer } from '@/components/Toast'
|
||||
import { AlertToastContainer } from '@/components/AlertToast'
|
||||
@@ -10,7 +11,6 @@ import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBu
|
||||
import {
|
||||
useSettings,
|
||||
usePreferences,
|
||||
useVersion,
|
||||
} from '@/lib/useSharedQueries'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import {
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
Layers3,
|
||||
Landmark,
|
||||
BookOpenCheck,
|
||||
Moon,
|
||||
Sun,
|
||||
User,
|
||||
Users,
|
||||
Shield,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -47,7 +53,7 @@ const nav = [
|
||||
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
|
||||
return (
|
||||
<NavLink
|
||||
to="/settings?tab=ai"
|
||||
to="/settings/ai"
|
||||
className="mt-2 group block -mx-2.5"
|
||||
title="AI 配置"
|
||||
>
|
||||
@@ -76,14 +82,43 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
|
||||
export function Layout() {
|
||||
// ===== 共享 hooks (替代内联 useQuery) =====
|
||||
const { data: settingsState } = useSettings()
|
||||
const { data: versionData } = useVersion()
|
||||
const { data: prefs } = usePreferences()
|
||||
const { data: analysisMenus } = useQuery({
|
||||
queryKey: QK.analysisMenus,
|
||||
queryFn: api.analysisMenus,
|
||||
})
|
||||
const { data: authData } = useQuery({
|
||||
queryKey: ['auth-status'],
|
||||
queryFn: api.authStatus,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const version = versionData?.version
|
||||
const username = authData?.username
|
||||
const role = authData?.role
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
if (typeof document === 'undefined') return true
|
||||
try {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'light' || saved === 'dark') return saved === 'dark'
|
||||
} catch {}
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 切换暗夜模式
|
||||
const toggleDark = () => {
|
||||
const next = !darkMode
|
||||
setDarkMode(next)
|
||||
document.documentElement.classList.toggle('dark', next)
|
||||
try { localStorage.setItem('theme', next ? 'dark' : 'light') } catch {}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try { await api.authLogout() } catch {}
|
||||
qc.invalidateQueries({ queryKey: ['auth-status'] })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
// 合并内置页面 + 可见的扩展分析菜单
|
||||
const analysisNav = (analysisMenus?.items ?? [])
|
||||
@@ -119,18 +154,13 @@ export function Layout() {
|
||||
style={{ color: BRAND }}
|
||||
/>
|
||||
<div
|
||||
className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight"
|
||||
className="text-sm font-bold tracking-tight text-foreground leading-tight"
|
||||
style={{ textShadow: `0 0 10px ${BRAND}44` }}
|
||||
>
|
||||
<div>TickFlow</div>
|
||||
<div>Stock Panel</div>
|
||||
A股工作台
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
||||
Quant · Terminal
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 h-px"
|
||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||
@@ -160,38 +190,54 @@ export function Layout() {
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{/* 个股分析 Beta 标识 */}
|
||||
{(to === '/stock-analysis' || to === '/review') && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
|
||||
isActive
|
||||
? 'bg-elevated text-foreground font-medium'
|
||||
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
<span>设置</span>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted/50 select-none">
|
||||
{version ?? ''}
|
||||
</span>
|
||||
</NavLink>
|
||||
<div className="border-t border-border px-2 py-2.5 space-y-1 shrink-0">
|
||||
{/* 登录用户 */}
|
||||
{username && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-foreground/70">
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="truncate flex-1">{username}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded p-1 text-muted hover:text-danger hover:bg-danger/10 transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户管理 (admin only) */}
|
||||
{role === 'admin' && (
|
||||
<NavLink
|
||||
to="/settings/users"
|
||||
className={({ isActive }) =>
|
||||
'flex items-center gap-2 px-3 py-1.5 text-xs text-foreground/70 hover:bg-elevated hover:text-foreground transition-colors' +
|
||||
(isActive ? ' bg-accent/10 text-accent' : '')
|
||||
}
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<Shield className="h-3 w-3 text-amber-400" />
|
||||
<span className="flex-1">用户管理</span>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* 暗夜模式 */}
|
||||
<div className="px-1">
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="flex items-center gap-2 w-full rounded-btn px-2 py-1.5 text-xs text-foreground/70 hover:bg-elevated hover:text-foreground transition-colors"
|
||||
title={darkMode ? '切换为日间模式' : '切换为暗夜模式'}
|
||||
>
|
||||
{darkMode ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
|
||||
<span>{darkMode ? '暗夜' : '日间'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) {
|
||||
className={className}
|
||||
style={style}
|
||||
role="img"
|
||||
aria-label="TickFlow Stock Panel"
|
||||
aria-label="A股工作台"
|
||||
>
|
||||
{/* 左方括号 */}
|
||||
<path
|
||||
|
||||
@@ -177,7 +177,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
<button onClick={() => { window.location.href = '/settings/ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
|
||||
@@ -310,7 +310,7 @@ export function StrategyBuilderDialog({ open, onClose, onSavedId, mode = 'create
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-400/5 px-4 py-3 flex items-center gap-3">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
|
||||
<div className="flex-1 text-xs text-amber-400/80">AI API Key 未配置,无法生成策略。填写的内容会自动保存。</div>
|
||||
<button onClick={() => { persist(); window.location.href = '/settings?tab=ai' }}
|
||||
<button onClick={() => { persist(); window.location.href = '/settings/ai' }}
|
||||
className="h-7 px-3 rounded-lg bg-amber-400/15 border border-amber-400/30 text-amber-400 text-xs font-medium flex items-center gap-1.5 hover:bg-amber-400/20 shrink-0">
|
||||
<Settings2 className="h-3 w-3" />去配置
|
||||
</button>
|
||||
|
||||
@@ -170,7 +170,7 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
<button onClick={() => { window.location.href = '/settings/ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
|
||||
@@ -589,16 +589,16 @@ export const api = {
|
||||
|
||||
// ===== Auth (访问认证) =====
|
||||
authStatus: () =>
|
||||
request<{ configured: boolean; authenticated: boolean }>('/api/auth/status'),
|
||||
request<{ configured: boolean; authenticated: boolean; username?: string | null; role?: string | null }>('/api/auth/status'),
|
||||
authSetup: (password: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
authLogin: (password: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/login', {
|
||||
authLogin: (username: string, password: string) =>
|
||||
request<{ ok: boolean; username?: string }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
authLogout: () =>
|
||||
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
|
||||
@@ -607,6 +607,22 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
|
||||
}),
|
||||
authUsers: () =>
|
||||
request<{ users: { username: string; role: string; created_at?: number }[] }>('/api/auth/users'),
|
||||
authCreateUser: (username: string, password: string, role: string) =>
|
||||
request<{ ok: boolean }>('/api/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, role }),
|
||||
}),
|
||||
authDeleteUser: (username: string) =>
|
||||
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
authResetPassword: (username: string, newPassword: string) =>
|
||||
request<{ ok: boolean }>(`/api/auth/users/${encodeURIComponent(username)}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password: newPassword }),
|
||||
}),
|
||||
|
||||
settings: () => request<SettingsState>('/api/settings'),
|
||||
saveTickflowKey: (api_key: string) =>
|
||||
@@ -866,8 +882,8 @@ export const api = {
|
||||
? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}`
|
||||
: '/api/screener/cached',
|
||||
),
|
||||
marketSnapshot: () =>
|
||||
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'),
|
||||
marketSnapshot: (asOf?: string) =>
|
||||
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>(`/api/screener/market-snapshot${asOf ? `?as_of=${asOf}` : ''}`),
|
||||
overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
|
||||
|
||||
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { SettingsAIPanel } from './settings/AI'
|
||||
|
||||
export function AiSettings() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="AI 设置" subtitle="管理 AI 提供商连接与模型配置" />
|
||||
<div className="px-8 py-6">
|
||||
<SettingsAIPanel />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,14 @@ import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert, Sparkles } from 'lucide-react'
|
||||
import { Eye, EyeOff, Loader2, Lock, ShieldCheck, ShieldAlert } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Auth() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
@@ -43,7 +44,7 @@ export function Auth() {
|
||||
if (isSetup) {
|
||||
return api.authSetup(password)
|
||||
}
|
||||
return api.authLogin(password)
|
||||
return api.authLogin(username, password)
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 成功: 跳回原页面(或首页)
|
||||
@@ -89,7 +90,7 @@ export function Auth() {
|
||||
{/* Logo */}
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Logo className="h-10 w-10" />
|
||||
<h1 className="text-lg font-semibold text-foreground">TickFlow Stock Panel</h1>
|
||||
<h1 className="text-lg font-semibold text-foreground">A股工作台</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-card border border-border bg-surface/90 p-6 shadow-2xl backdrop-blur">
|
||||
@@ -112,6 +113,17 @@ export function Auth() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
{/* 用户名(仅登录模式) */}
|
||||
{!isSetup && (
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="用户名"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
)}
|
||||
{/* 密码输入 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -119,7 +131,7 @@ export function Auth() {
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="访问密码"
|
||||
autoFocus
|
||||
autoFocus={isSetup}
|
||||
className="h-10 w-full rounded-btn border border-border bg-base px-3 pr-9 text-sm text-foreground outline-none transition-colors focus:border-accent/50"
|
||||
/>
|
||||
<button
|
||||
@@ -184,11 +196,6 @@ export function Auth() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-1.5 text-[10px] text-muted/60">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
自托管量化工作台 · 数据完全掌握在自己手里
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,12 +25,12 @@ interface Variant {
|
||||
glow?: string // 名字下方的发光线条 hex
|
||||
}
|
||||
|
||||
// 同一个名字 "TickFlow Stock Panel" 在 4 种风格语言里的呈现
|
||||
// 同一个名字 "A股工作台" 在 4 种风格语言里的呈现
|
||||
// 长字符串自动用更小字号 + 更窄字距,免得撑爆卡片;但风格语言(字体/字重/配色/图标)保持不变
|
||||
const VARIANTS: Variant[] = [
|
||||
{
|
||||
id: 'pulsar',
|
||||
name: 'TickFlow Stock Panel',
|
||||
name: 'A股工作台',
|
||||
tagline: 'A-SHARE · SIGNAL TERMINAL',
|
||||
hint: '脉冲星、雷达波纹 — 青绿强调色,字重黑体,中等字距',
|
||||
icon: RadioTower,
|
||||
@@ -40,7 +40,7 @@ const VARIANTS: Variant[] = [
|
||||
},
|
||||
{
|
||||
id: 'vanta',
|
||||
name: 'TickFlow Stock Panel',
|
||||
name: 'A股工作台',
|
||||
tagline: 'MARKET · INTELLIGENCE',
|
||||
hint: 'Vantablack — 纯白单色,字重最重,字距最宽,monochrome 高级感',
|
||||
icon: Square,
|
||||
@@ -50,7 +50,7 @@ const VARIANTS: Variant[] = [
|
||||
},
|
||||
{
|
||||
id: 'helix',
|
||||
name: 'TickFlow Stock Panel',
|
||||
name: 'A股工作台',
|
||||
tagline: 'QUANT · TERMINAL',
|
||||
hint: 'DNA 螺旋 — 紫色强调,等宽字体,赛博朋克经典意象',
|
||||
icon: GitFork,
|
||||
@@ -60,7 +60,7 @@ const VARIANTS: Variant[] = [
|
||||
},
|
||||
{
|
||||
id: 'aurora',
|
||||
name: 'TickFlow Stock Panel',
|
||||
name: 'A股工作台',
|
||||
tagline: 'A-SHARE · DASHBOARD',
|
||||
hint: '极光 — 青色强调,细字优雅,适中字距,与涨跌语义色不冲突',
|
||||
icon: Sparkles,
|
||||
@@ -85,7 +85,7 @@ export function Branding() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="视觉风格预览"
|
||||
subtitle="名字保持 TickFlow Stock Panel,4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
||||
subtitle="名字「A股工作台」的 4 种赛博朋克 + 高级感的视觉处理 — 字重、字距、配色、图标各不同。挑你最喜欢的告诉我。"
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
Settings2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
|
||||
import { api, type MarketSnapshotRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -68,9 +68,7 @@ function loadConfig(): AnalysisFieldConfig {
|
||||
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
|
||||
}
|
||||
|
||||
function saveConfig(c: AnalysisFieldConfig) {
|
||||
storage.conceptAnalysisConfig.set(c)
|
||||
}
|
||||
|
||||
|
||||
function pickBestConfig(
|
||||
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
|
||||
@@ -234,14 +232,14 @@ function statSort(mode: SortMode) {
|
||||
}
|
||||
|
||||
export function ConceptAnalysis() {
|
||||
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [fieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [sortMode, setSortMode] = useState<SortMode>('heat')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const [showRps, setShowRps] = useState(false)
|
||||
const [asOf, setAsOf] = useState('')
|
||||
|
||||
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
|
||||
const availableConfigs = configsQuery.data?.items ?? []
|
||||
@@ -253,29 +251,14 @@ export function ConceptAnalysis() {
|
||||
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
|
||||
|
||||
const rowsQuery = useQuery({
|
||||
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
|
||||
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
|
||||
queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
|
||||
queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
|
||||
enabled: !!activeConfigId,
|
||||
})
|
||||
|
||||
// 内置概念预设 (ext_gn_ths) 手动获取数据
|
||||
const PRESET_CONCEPT_ID = 'ext_gn_ths'
|
||||
const queryClient = useQueryClient()
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: () => api.extDataPresetFetch(PRESET_CONCEPT_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK.extData })
|
||||
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) })
|
||||
},
|
||||
})
|
||||
// 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮
|
||||
const needsConceptFetch =
|
||||
!!activeConfig && activeConfig.id === PRESET_CONCEPT_ID &&
|
||||
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
|
||||
|
||||
const marketQuery = useQuery({
|
||||
queryKey: QK.marketSnapshot,
|
||||
queryFn: api.marketSnapshot,
|
||||
queryKey: ['market-snapshot', asOf],
|
||||
queryFn: () => api.marketSnapshot(asOf || undefined),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
@@ -316,12 +299,6 @@ export function ConceptAnalysis() {
|
||||
return set.size
|
||||
}, [stats])
|
||||
|
||||
const handleSaveConfig = (c: AnalysisFieldConfig) => {
|
||||
setFieldConfig(c)
|
||||
saveConfig(c)
|
||||
setSelectedKey(null)
|
||||
}
|
||||
|
||||
if (configsQuery.isLoading) {
|
||||
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
@@ -331,25 +308,9 @@ export function ConceptAnalysis() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="概念分析"
|
||||
right={
|
||||
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PresetFetchState
|
||||
title="暂无概念数据"
|
||||
hint="从同花顺获取概念分类数据后即可使用概念分析"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
<PageHeader title="概念分析" />
|
||||
<EmptyState icon={Layers3} title="暂无概念数据" hint="请通过 local→serve 同步概念数据" />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -358,9 +319,10 @@ export function ConceptAnalysis() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="概念分析"
|
||||
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
|
||||
subtitle={`${asOf || marketQuery.data?.as_of || rowsQuery.data?.date || '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
|
||||
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
|
||||
<button
|
||||
onClick={() => setShowRps(true)}
|
||||
@@ -369,17 +331,6 @@ export function ConceptAnalysis() {
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
|
||||
disabled={rowsQuery.isFetching || marketQuery.isFetching}
|
||||
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
|
||||
</button>
|
||||
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -411,23 +362,13 @@ export function ConceptAnalysis() {
|
||||
</div>
|
||||
) : rowsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算概念强度...</div>
|
||||
) : needsConceptFetch ? (
|
||||
<PresetFetchState
|
||||
title="未获取概念数据"
|
||||
hint="内置概念数据源已就绪,点击下方按钮从同花顺获取概念分类数据"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
{previewSymbol && (
|
||||
<StockPreviewDialog
|
||||
|
||||
@@ -2,19 +2,15 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtBigNum, fmtPct } from '@/lib/format'
|
||||
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { useDataStatus, useSettings } from '@/lib/useSharedQueries'
|
||||
import { SettingsModal } from '@/components/data/SettingsModal'
|
||||
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { cnSignal } from '@/lib/signals'
|
||||
import { boardTag } from '@/components/stock-table/primitives'
|
||||
|
||||
|
||||
function n(v: number | null | undefined) {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||||
@@ -79,140 +75,6 @@ function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; titl
|
||||
)
|
||||
}
|
||||
|
||||
// 看板监控中心小组件 — 显示前 10 条触发记录 + 更多按钮
|
||||
const _SOURCE_BADGE: Record<string, string> = {
|
||||
strategy: 'bg-amber-400/10 text-amber-400',
|
||||
signal: 'bg-accent/10 text-accent',
|
||||
price: 'bg-emerald-400/10 text-emerald-400',
|
||||
market: 'bg-purple-500/10 text-purple-400',
|
||||
}
|
||||
const _SOURCE_LABEL: Record<string, string> = {
|
||||
strategy: '策略', signal: '信号', price: '价格', market: '异动',
|
||||
}
|
||||
const _SEVERITY_BAR: Record<string, string> = {
|
||||
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
|
||||
}
|
||||
|
||||
function MonitorWidget() {
|
||||
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
|
||||
const alerts = useQuery({
|
||||
queryKey: ['alerts', ''],
|
||||
queryFn: () => api.alertsList({ days: 7, limit: 10 }),
|
||||
refetchInterval: 10000,
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
const events: AlertEvent[] = alerts.data?.alerts ?? []
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="mt-1 py-6 text-center text-[11px] text-muted">暂无触发记录</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{events
|
||||
.filter((ev: AlertEvent) => !(ev.source === 'strategy' && !ev.symbol))
|
||||
.map((ev, i) => {
|
||||
const sev = _SEVERITY_BAR[ev.severity ?? 'info'] ?? _SEVERITY_BAR.info
|
||||
const pct = ev.change_pct ?? 0
|
||||
const isStrategy = ev.source === 'strategy'
|
||||
const sm = isStrategy ? ev.message?.match(/策略「([^」]+)」/) : null
|
||||
const sname = sm ? sm[1] : ''
|
||||
const isNew = ev.type === 'new_entry'
|
||||
return (
|
||||
<motion.div
|
||||
key={`${ev.ts}-${i}`}
|
||||
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
|
||||
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
|
||||
>
|
||||
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
|
||||
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => ev.symbol && setPreviewEv(ev)}
|
||||
title={ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
|
||||
className="inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-[10px] font-medium text-foreground/80 hover:text-accent">{ev.symbol?.replace(/\.(SH|SZ|BJ)$/, '')}</span>
|
||||
{ev.symbol && (() => {
|
||||
const board = boardTag(ev.symbol)
|
||||
return board && (
|
||||
<span className={`inline-flex items-center justify-center h-3 w-3 rounded text-[7px] font-bold leading-none border ${board.color}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{ev.name && <span className="text-[10px] text-secondary truncate max-w-[5rem] hover:text-foreground">{ev.name}</span>}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{ev.price != null && (
|
||||
<span className="text-[10px] font-mono text-foreground/60 shrink-0">{fmtPrice(ev.price)}</span>
|
||||
)}
|
||||
{ev.change_pct != null && (
|
||||
<span className={cn('text-[10px] font-mono font-medium shrink-0 w-12 text-right', pct >= 0 ? 'text-danger' : 'text-bear')}>
|
||||
{fmtPct(pct)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 第二行: 策略类型走新格式, 其他走旧格式 */}
|
||||
{isStrategy ? (
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className={cn('text-[9px] font-medium', isNew ? 'text-danger' : 'text-emerald-400')}>
|
||||
{isNew ? '进入' : '移出'}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted">策略</span>
|
||||
<span className="text-[9px] font-medium text-amber-400">「{sname}」</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
|
||||
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-0.5 flex items-center gap-1.5">
|
||||
<span className={cn('shrink-0 rounded px-1 py-px text-[8px] font-medium', _SOURCE_BADGE[ev.source] ?? 'bg-elevated text-muted')}>
|
||||
{_SOURCE_LABEL[ev.source] ?? ev.source}
|
||||
</span>
|
||||
{ev.message && (
|
||||
<span className="text-[9px] text-muted truncate flex-1">{ev.message}</span>
|
||||
)}
|
||||
<span className="text-[8px] text-muted/50 shrink-0 font-mono">
|
||||
{ev.ts ? new Date(ev.ts).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
{ev.signals && ev.signals.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{ev.signals.map((s, j) => (
|
||||
<span key={j} className="rounded bg-accent/8 px-1 py-px text-[8px] text-accent/80">{cnSignal(s)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<StockPreviewDialog
|
||||
symbol={previewEv?.symbol ?? null}
|
||||
name={previewEv?.name ?? undefined}
|
||||
triggerInfo={previewEv ? {
|
||||
price: previewEv.price ?? null,
|
||||
changePct: previewEv.change_pct ?? null,
|
||||
ts: previewEv.ts,
|
||||
signals: previewEv.signals,
|
||||
message: previewEv.message,
|
||||
} : null}
|
||||
onClose={() => setPreviewEv(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCell({ label, value, sub, tone = 'neutral' }: { label: ReactNode; value: ReactNode; sub?: string; tone?: 'bull' | 'bear' | 'accent' | 'neutral' }) {
|
||||
const isPlain = typeof value === 'string' || typeof value === 'number'
|
||||
@@ -375,6 +237,30 @@ function LadderMini({ limit }: { limit: OverviewMarket['limit'] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DownLadderMini() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['limit-ladder-down'],
|
||||
queryFn: () => api.limitLadder(undefined, undefined, 'down'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const tiers = data?.tiers?.filter(t => t.boards >= 2).slice(0, 6) ?? []
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{isLoading && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted">加载中…</div>}
|
||||
{!isLoading && tiers.length === 0 && <div className="rounded border border-dashed border-border py-5 text-center text-xs text-muted">暂无 2 板以上</div>}
|
||||
{tiers.map(t => (
|
||||
<div key={t.boards} className="grid grid-cols-[42px_1fr_auto] items-center gap-2 rounded bg-elevated/35 px-2 py-1.5">
|
||||
<span className={`font-mono text-sm font-bold ${t.boards >= 5 ? 'text-bear' : t.boards >= 3 ? 'text-warning' : 'text-secondary'}`}>{t.boards}板</span>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-base">
|
||||
<div className="h-full rounded-full bg-bear/70" style={{ width: `${Math.min(100, t.count * 12)}%` }} />
|
||||
</div>
|
||||
<span className="font-mono text-xs text-foreground">{t.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string; value: string; cls?: string }) {
|
||||
return (
|
||||
<div className="rounded bg-elevated/45 px-2 py-1.5">
|
||||
@@ -484,11 +370,7 @@ export function Dashboard() {
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
const data = overview.data
|
||||
const caps = useCapabilities()
|
||||
const settings = useSettings()
|
||||
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
|
||||
const sealedReady = !!data?.limit?.sealed_ready
|
||||
const isSealedDegrade = !hasDepth || !sealedReady
|
||||
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
|
||||
const isNoKey = settings.data?.mode === 'none'
|
||||
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
|
||||
@@ -654,7 +536,7 @@ export function Dashboard() {
|
||||
<div className="mb-3 grid grid-cols-6 gap-2">
|
||||
<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">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
|
||||
<KpiCell label={<span className="inline-flex items-center gap-1">涨停 / 跌停<SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} 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)}%`} />
|
||||
<KpiCell label={<span className="inline-flex items-center gap-1">涨停 / 跌停</span>} 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)}%`} />
|
||||
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}板`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
|
||||
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
|
||||
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
|
||||
@@ -724,21 +606,12 @@ export function Dashboard() {
|
||||
|
||||
<aside className="min-w-0 space-y-3">
|
||||
<section className="rounded-card border border-border bg-surface/80 p-3">
|
||||
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
|
||||
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}</span>} />
|
||||
<LadderMini limit={data.limit} />
|
||||
</section>
|
||||
<section className="rounded-card border border-border bg-surface/80 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BellRing className="h-3.5 w-3.5 text-accent" />
|
||||
<h2 className="text-xs font-semibold text-foreground">监控中心</h2>
|
||||
<span className="font-mono text-[10px] text-muted">实时信号</span>
|
||||
</div>
|
||||
<Link to="/monitor" className="inline-flex items-center justify-center h-5 w-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors" title="进入监控中心">
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<MonitorWidget />
|
||||
<SectionTitle icon={ArrowDownRight} title="跌停梯队" hint={<span className="text-bear">{data.limit.limit_down} 只</span>} />
|
||||
<DownLadderMini />
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Loader2, Search, FileText, Database, Lightbulb, ExternalLink, X } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
|
||||
import { useFinancialStatus } from '@/lib/useFinancials'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
||||
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
metrics: '核心指标',
|
||||
@@ -27,131 +25,57 @@ const TABLE_ICON: Record<string, typeof FileText> = {
|
||||
}
|
||||
|
||||
export function Financials() {
|
||||
const { data: caps } = useCapabilities()
|
||||
const hasFinancial = caps?.capabilities?.['financial'] != null
|
||||
const { data: status, isLoading } = useFinancialStatus()
|
||||
const syncMut = useFinancialSync()
|
||||
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
|
||||
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
|
||||
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
|
||||
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
|
||||
const syncing = (status?.syncing ?? false) || syncMut.isPending
|
||||
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
|
||||
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
|
||||
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
|
||||
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
|
||||
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
|
||||
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
|
||||
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
|
||||
useEffect(() => {
|
||||
if (!syncing && syncStartedAt !== null) {
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
}
|
||||
}, [syncing, syncStartedAt])
|
||||
// 选中的个股(模糊搜索结果);null 时显示搜索引导
|
||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
|
||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||
const pick = (symbol: string, name: string) => {
|
||||
setSelected({ symbol, name })
|
||||
rememberStock(symbol, name)
|
||||
}
|
||||
|
||||
if (!hasFinancial) {
|
||||
// 等待 status 加载完, 判断是否有从 local 同步来的数据
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
|
||||
<div className="px-8 py-10">
|
||||
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
|
||||
<Lock className="h-6 w-6 text-warning" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">需要 Expert 套餐</h3>
|
||||
<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>
|
||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSync = (table: string) => {
|
||||
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
|
||||
if (syncing) return
|
||||
// 记录开始时间: 全量同步判断所有 4 张表, 单表同步只判断这一张
|
||||
setSyncStartedAt(Date.now())
|
||||
setSyncSingleTable(table === 'all' ? null : table)
|
||||
syncMut.mutate(table, {
|
||||
onSuccess: (r) => {
|
||||
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
|
||||
// 给用户明确反馈,并清空本次误设的记录。
|
||||
if (!r.synced?.started) {
|
||||
if (r.synced?.reason === 'already running') {
|
||||
toast('财务数据正在同步中,请稍候', 'success')
|
||||
} else if (r.synced?.reason === 'no FINANCIAL capability') {
|
||||
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
|
||||
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
|
||||
} else {
|
||||
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
|
||||
}
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// 请求失败:清空本次记录(request 已弹错误 toast)
|
||||
setSyncStartedAt(null)
|
||||
setSyncSingleTable(null)
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!status?.available) {
|
||||
// 无同步数据
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
|
||||
<div className="px-8 py-10">
|
||||
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 有同步数据, 继续展示
|
||||
|
||||
const tables = status?.tables ?? {}
|
||||
const available = status?.available ?? false
|
||||
const lastSync = status?.last_sync ?? {}
|
||||
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
|
||||
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
|
||||
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
|
||||
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow'] as const
|
||||
const tableDoneThisRound = (key: string): boolean => {
|
||||
if (!syncStartedAt || !syncing) return false
|
||||
// 单表同步: 只判断这一张表是否完成
|
||||
if (syncSingleTable && key !== syncSingleTable) return false
|
||||
const ls = lastSync[key]
|
||||
if (!ls) return false
|
||||
return new Date(ls).getTime() >= syncStartedAt
|
||||
}
|
||||
// 当前正在同步的表:
|
||||
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
|
||||
const currentSyncingTable = syncing && syncStartedAt
|
||||
? (syncSingleTable
|
||||
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
|
||||
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
|
||||
: null
|
||||
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
|
||||
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
|
||||
const isWaitingTable = (key: string): boolean =>
|
||||
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -161,38 +85,13 @@ export function Financials() {
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={pick} />
|
||||
{syncing && (
|
||||
<span className="text-xs text-accent/80 flex items-center gap-1.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{isFullSync
|
||||
? `已同步 ${syncedCount}/4 张表…`
|
||||
: isSingleSync
|
||||
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}…`
|
||||
: '同步中…'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => handleSync('all')}
|
||||
disabled={syncing}
|
||||
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
|
||||
>
|
||||
{syncing
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <RefreshCw className="h-3.5 w-3.5" />}
|
||||
{syncing ? '同步中…' : '全部同步'}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
{syncing && (
|
||||
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
正在从 TickFlow 拉取财务数据,请稍候…
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
|
||||
{!isLoading && available && (
|
||||
@@ -203,46 +102,22 @@ export function Financials() {
|
||||
const TIcon = TABLE_ICON[key] ?? Database
|
||||
const hasData = (info?.rows ?? 0) > 0
|
||||
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
|
||||
const doneThisRound = tableDoneThisRound(key)
|
||||
const isThisSyncing = currentSyncingTable === key
|
||||
const isWaiting = isWaitingTable(key)
|
||||
const lsTime = lastSync[key]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
|
||||
isThisSyncing
|
||||
? 'border-accent/40 bg-accent/[0.04]'
|
||||
: isWaiting
|
||||
? 'border-border/50 bg-elevated/15'
|
||||
: hasData
|
||||
? 'border-border bg-surface'
|
||||
: 'border-dashed border-border/60 bg-elevated/20'
|
||||
hasData
|
||||
? 'border-border bg-surface'
|
||||
: 'border-dashed border-border/60 bg-elevated/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{doneThisRound ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : isThisSyncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
|
||||
) : isWaiting ? (
|
||||
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
|
||||
) : (
|
||||
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
|
||||
)}
|
||||
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
|
||||
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
onClick={() => handleSync(key)}
|
||||
disabled={syncing}
|
||||
title={syncing ? '正在同步…' : `同步${label}`}
|
||||
>
|
||||
{syncing
|
||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
|
||||
{fmtBigNum(info?.rows ?? 0)}
|
||||
@@ -251,12 +126,6 @@ export function Financials() {
|
||||
<div className="text-[11px] text-muted mt-0.5">
|
||||
{fmtBigNum(info?.symbols ?? 0)} 只标的
|
||||
</div>
|
||||
<div className="mt-auto pt-2 border-t border-border/40 text-[10px] text-muted flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5 shrink-0" />
|
||||
{lsTime
|
||||
? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '尚未同步'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Activity, Loader2, Lock, RefreshCw, Search } from 'lucide-react'
|
||||
import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Search } from 'lucide-react'
|
||||
import { api, type IndexInstrument, type KlineRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
|
||||
import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick'
|
||||
import { EChartsIntraday } from '@/components/EChartsIntraday'
|
||||
|
||||
function defaultRange() {
|
||||
const now = new Date()
|
||||
@@ -56,7 +55,6 @@ function pinnedRank(item: IndexInstrument) {
|
||||
}
|
||||
|
||||
export function Indices() {
|
||||
const qc = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const symbolParam = searchParams.get('symbol') ?? ''
|
||||
@@ -65,10 +63,6 @@ export function Indices() {
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
|
||||
// 分时数据需 Pro+ (kline.minute.batch) 能力
|
||||
const caps = useCapabilities()
|
||||
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: QK.indexList,
|
||||
queryFn: api.indexList,
|
||||
@@ -109,38 +103,8 @@ export function Indices() {
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const minute = useQuery({
|
||||
queryKey: QK.indexMinute(selectedSymbol, selectedDate ?? ''),
|
||||
queryFn: () => api.indexMinute(selectedSymbol, selectedDate ?? undefined),
|
||||
enabled: !!selectedSymbol && !!selectedDate && hasMinuteCap,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
|
||||
const syncInstruments = useMutation({
|
||||
mutationFn: api.syncIndexInstruments,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.indexList })
|
||||
},
|
||||
})
|
||||
|
||||
const syncDaily = useMutation({
|
||||
mutationFn: () => api.syncIndexDaily(365),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.indexList })
|
||||
qc.invalidateQueries({ queryKey: ['index-daily'] })
|
||||
},
|
||||
})
|
||||
|
||||
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
|
||||
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
|
||||
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
|
||||
const selectedIdx = selectedDate ? chartRows.findIndex(r => r.date === selectedDate) : -1
|
||||
const prevClose = selectedIdx > 0
|
||||
? chartRows[selectedIdx - 1].close
|
||||
: chartRows.length >= 2
|
||||
? chartRows[chartRows.length - 2].close
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDate(null)
|
||||
setLinkedPrice(null)
|
||||
@@ -170,27 +134,6 @@ export function Indices() {
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">指数</h1>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
指数使用独立 kline_index_* parquet,不进入股票选股和策略链路。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => syncInstruments.mutate()}
|
||||
disabled={syncInstruments.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{syncInstruments.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
同步指数列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => syncDaily.mutate()}
|
||||
disabled={syncDaily.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-base hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{syncDaily.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
同步指数日K
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,9 +172,6 @@ export function Indices() {
|
||||
</h2>
|
||||
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted">
|
||||
日K来源 {daily.data?.source ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
@@ -258,52 +198,18 @@ export function Indices() {
|
||||
</div>
|
||||
)}
|
||||
{chartRows.length > 0 && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<EChartsCandlestick
|
||||
data={chartRows}
|
||||
height={620}
|
||||
showMA={true}
|
||||
showInfoBar={true}
|
||||
showMarkers={false}
|
||||
symbol={selectedSymbol}
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={setSelectedDate}
|
||||
visibleBars={48}
|
||||
activeIndicators={['vol', 'macd']}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-l border-border pl-3" style={{ height: 620 }}>
|
||||
{!hasMinuteCap ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<Lock className="h-5 w-5 text-muted" />
|
||||
<div className="text-xs text-secondary">分时数据权限需 Pro+</div>
|
||||
<div className="text-[10px] text-muted">升级套餐后可查看指数分时走势</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{minute.isLoading && <div className="py-2 text-xs text-muted">分时加载中…</div>}
|
||||
{!minute.isLoading && minuteRows.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted">
|
||||
暂无分时数据
|
||||
</div>
|
||||
)}
|
||||
{minuteRows.length > 0 && (
|
||||
<EChartsIntraday
|
||||
data={minuteRows}
|
||||
height={620}
|
||||
prevClose={prevClose}
|
||||
date={selectedDate ?? undefined}
|
||||
symbol={selectedSymbol}
|
||||
showLimitLines={false}
|
||||
showAvgLine={false}
|
||||
onPriceHover={setLinkedPrice}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EChartsCandlestick
|
||||
data={chartRows}
|
||||
height={620}
|
||||
showMA={true}
|
||||
showInfoBar={true}
|
||||
showMarkers={false}
|
||||
symbol={selectedSymbol}
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={setSelectedDate}
|
||||
visibleBars={48}
|
||||
activeIndicators={['vol', 'macd']}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity,
|
||||
Crown,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { DimensionHeatmap, type AnalysisFieldConfig } from '@/components/analysis-shared'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { api, type MarketSnapshotRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
@@ -68,9 +67,7 @@ interface IndustryStat {
|
||||
function loadConfig(): AnalysisFieldConfig {
|
||||
return storage.industryAnalysisConfig.get({}) as AnalysisFieldConfig
|
||||
}
|
||||
function saveConfig(c: AnalysisFieldConfig) {
|
||||
storage.industryAnalysisConfig.set(c)
|
||||
}
|
||||
|
||||
|
||||
// ===== 自动选取最佳数据源 =====
|
||||
|
||||
@@ -267,13 +264,13 @@ function groupByIndustryLevel(groups: DimensionGroup[], level: IndustryLevel): D
|
||||
// ===== 主页面 =====
|
||||
|
||||
export function IndustryAnalysis() {
|
||||
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [fieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [sortMode, setSortMode] = useState<SortMode>('heat')
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const [previewName, setPreviewName] = useState<string>('')
|
||||
const [asOf, setAsOf] = useState('')
|
||||
|
||||
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
|
||||
const availableConfigs = configsQuery.data?.items ?? []
|
||||
@@ -285,29 +282,14 @@ export function IndustryAnalysis() {
|
||||
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
|
||||
|
||||
const rowsQuery = useQuery({
|
||||
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
|
||||
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
|
||||
queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
|
||||
queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
|
||||
enabled: !!activeConfigId,
|
||||
})
|
||||
|
||||
// 内置行业预设 (ext_hy_ths) 手动获取数据
|
||||
const PRESET_INDUSTRY_ID = 'ext_hy_ths'
|
||||
const queryClient = useQueryClient()
|
||||
const fetchMutation = useMutation({
|
||||
mutationFn: () => api.extDataPresetFetch(PRESET_INDUSTRY_ID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QK.extData })
|
||||
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_INDUSTRY_ID, undefined, PAGE_LIMIT) })
|
||||
},
|
||||
})
|
||||
// 是否处于「内置行业预设存在但无数据」状态 → 显示获取按钮
|
||||
const needsIndustryFetch =
|
||||
!!activeConfig && activeConfig.id === PRESET_INDUSTRY_ID &&
|
||||
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
|
||||
|
||||
const marketQuery = useQuery({
|
||||
queryKey: QK.marketSnapshot,
|
||||
queryFn: api.marketSnapshot,
|
||||
queryKey: ['market-snapshot', asOf],
|
||||
queryFn: () => api.marketSnapshot(asOf || undefined),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
@@ -364,12 +346,6 @@ export function IndustryAnalysis() {
|
||||
return map
|
||||
}, [marketMap])
|
||||
|
||||
const handleSaveConfig = (c: AnalysisFieldConfig) => {
|
||||
setFieldConfig(c)
|
||||
saveConfig(c)
|
||||
setSelectedKey(null)
|
||||
}
|
||||
|
||||
if (configsQuery.isLoading) {
|
||||
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
@@ -379,25 +355,10 @@ export function IndustryAnalysis() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="行业分析"
|
||||
right={
|
||||
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PresetFetchState
|
||||
title="暂无行业数据"
|
||||
hint="从同花顺获取行业分类数据后即可使用行业分析"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
<PageHeader title="行业分析" />
|
||||
<EmptyState icon={Layers3} title="暂无行业数据" hint="请通过 local→serve 同步行业数据" />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
|
||||
</AnimatePresence>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -408,21 +369,9 @@ export function IndustryAnalysis() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="行业分析"
|
||||
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
|
||||
subtitle={`${industryLevelLabel} · ${asOf || marketQuery.data?.as_of || rowsQuery.data?.date || '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
|
||||
disabled={rowsQuery.isFetching || marketQuery.isFetching}
|
||||
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
|
||||
</button>
|
||||
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -464,23 +413,13 @@ export function IndustryAnalysis() {
|
||||
</div>
|
||||
) : rowsQuery.isLoading ? (
|
||||
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算行业强度...</div>
|
||||
) : needsIndustryFetch ? (
|
||||
<PresetFetchState
|
||||
title="未获取行业数据"
|
||||
hint="内置行业数据源已就绪,点击下方按钮从同花顺获取行业分类数据"
|
||||
isLoading={fetchMutation.isPending}
|
||||
error={fetchMutation.error}
|
||||
onFetch={() => fetchMutation.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
{previewSymbol && (
|
||||
<StockPreviewDialog
|
||||
|
||||
@@ -11,7 +11,6 @@ import { fmtPct, priceColorClass } from '@/lib/format'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
|
||||
|
||||
// ===== Ext 字段配置 =====
|
||||
@@ -1440,7 +1439,7 @@ export function LimitUpLadder() {
|
||||
|
||||
const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields])
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction],
|
||||
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction),
|
||||
staleTime: 5 * 60_000,
|
||||
@@ -1478,16 +1477,6 @@ export function LimitUpLadder() {
|
||||
title={direction === 'down' ? '连跌梯队' : '连板梯队'}
|
||||
titleExtra={
|
||||
<div className="flex items-center gap-2">
|
||||
<SealedBadge
|
||||
degraded={sealedDegrade.degraded}
|
||||
hasDepth={sealedDegrade.hasDepth}
|
||||
isHistorical={sealedDegrade.isHistorical}
|
||||
sealedReady={sealedDegrade.sealedReady}
|
||||
sealedCountsUp={data?.sealed_counts_up}
|
||||
sealedCountsDown={data?.sealed_counts_down}
|
||||
rawUp={data?.counts_raw?.up}
|
||||
rawDown={data?.counts_raw?.down}
|
||||
/>
|
||||
{/* 涨跌停切换(胶囊式): 点击切换方向, 当前方向有背景 */}
|
||||
<div className="flex items-center rounded-full bg-elevated/60 p-0.5">
|
||||
<button
|
||||
@@ -1606,13 +1595,6 @@ export function LimitUpLadder() {
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="p-1.5 hover:bg-surface text-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function Onboarding() {
|
||||
className="shrink-0"
|
||||
style={{ color: BRAND, filter: `drop-shadow(0 0 8px ${BRAND}55)` }}
|
||||
/>
|
||||
<span className="text-sm font-semibold tracking-tight">TickFlow Stock Panel</span>
|
||||
<span className="text-sm font-semibold tracking-tight">A股工作台</span>
|
||||
</div>
|
||||
{/* 步骤进度条 —— 胶囊式 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -179,7 +179,7 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
|
||||
</motion.div>
|
||||
|
||||
<h1 className="mt-6 text-3xl font-bold text-foreground tracking-tight">
|
||||
欢迎使用 TickFlow Stock Panel
|
||||
欢迎使用 A股工作台
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-secondary leading-relaxed max-w-md mx-auto">
|
||||
一个本地化的 A 股量化分析面板 —— 行情、选股、回测、监控、财务一体化。
|
||||
|
||||
@@ -9,20 +9,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
|
||||
Database, Wand2, Copy, Download, Clock, X, Check,
|
||||
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 { fmtBigNum } from '@/lib/format'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { useReviewState } from '@/lib/useReviewStore'
|
||||
import {
|
||||
startReviewGeneration, resetReview, isReviewGenerating,
|
||||
@@ -68,8 +68,7 @@ function fmtArchivedAt(iso: string): string {
|
||||
|
||||
export function Review() {
|
||||
const qc = useQueryClient()
|
||||
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
|
||||
const asOf: string | undefined = undefined
|
||||
const [asOf, setAsOf] = useState('')
|
||||
const [focus, setFocus] = useState('')
|
||||
// 生成状态走全局 store:切走页面流不中断,回来可恢复
|
||||
const { phase, content, error, meta } = useReviewState()
|
||||
@@ -78,8 +77,8 @@ export function Review() {
|
||||
|
||||
// 看板数据(与总览页同源)
|
||||
const marketQuery = useQuery<OverviewMarket>({
|
||||
queryKey: QK.overviewMarket(asOf),
|
||||
queryFn: () => api.overviewMarket(asOf),
|
||||
queryKey: QK.overviewMarket(asOf || undefined),
|
||||
queryFn: () => api.overviewMarket(asOf || undefined),
|
||||
staleTime: 5_000,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
@@ -99,45 +98,8 @@ export function Review() {
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
|
||||
// ===== 定时复盘 =====
|
||||
const [showSchedule, setShowSchedule] = useState(false)
|
||||
const prefs = usePreferences()
|
||||
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
|
||||
const feishuConfigured = !!((prefs.data as any)?.feishu_webhook_url)
|
||||
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
|
||||
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
|
||||
const reviewPushChannels = prefs.data?.review_push_channels ?? []
|
||||
// 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗)
|
||||
const [draft, setDraft] = useState(reviewSched)
|
||||
const openSchedule = useCallback(() => {
|
||||
setDraft(reviewSched) // 每次打开同步最新服务端值
|
||||
setShowSchedule(true)
|
||||
}, [reviewSched])
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: ({ enabled, hour, minute }: { enabled: boolean; hour: number; minute: number }) =>
|
||||
api.updateReviewSchedule(enabled, hour, minute),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
setShowSchedule(false)
|
||||
toast(vars.enabled ? '已开启定时复盘' : '已关闭定时复盘', 'success')
|
||||
},
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
// 推送渠道(多选): 独立常驻, 即时生效(勾选渠道即开关, 改了立刻提交)
|
||||
const pushMut = useMutation({
|
||||
mutationFn: (channels: string[]) => api.updateReviewPush(channels),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
toast(vars.length === 0 ? '已关闭复盘推送' : '已更新复盘推送渠道', 'success')
|
||||
},
|
||||
onError: () => { /* request() 已 toast */ },
|
||||
})
|
||||
const togglePushChannel = useCallback((ch: string) => {
|
||||
const next = reviewPushChannels.includes(ch)
|
||||
? reviewPushChannels.filter(c => c !== ch)
|
||||
: [...reviewPushChannels, ch]
|
||||
pushMut.mutate(next)
|
||||
}, [reviewPushChannels, pushMut])
|
||||
|
||||
|
||||
|
||||
// 自动滚动到报告底部(streaming 时)
|
||||
useEffect(() => {
|
||||
@@ -176,7 +138,7 @@ export function Review() {
|
||||
if (isReviewGenerating()) return
|
||||
setViewing(null)
|
||||
resetReview()
|
||||
startReviewGeneration(asOf, focus, (full, doneMeta) => {
|
||||
startReviewGeneration(asOf || undefined, focus, (full, doneMeta) => {
|
||||
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
|
||||
})
|
||||
}, [asOf, focus, onGenerationDone])
|
||||
@@ -228,6 +190,7 @@ export function Review() {
|
||||
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
|
||||
right={
|
||||
<div className="flex items-center gap-1">
|
||||
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
|
||||
<button
|
||||
onClick={() => { marketQuery.refetch() }}
|
||||
disabled={marketQuery.isFetching}
|
||||
@@ -236,18 +199,6 @@ export function Review() {
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={openSchedule}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-btn border px-2 py-1 text-[11px] transition-colors',
|
||||
reviewSched.enabled
|
||||
? 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20'
|
||||
: 'border-border bg-elevated text-secondary hover:text-foreground',
|
||||
)}
|
||||
title={reviewSched.enabled ? `定时复盘已开启 · 每日 ${String(reviewSched.hour).padStart(2,'0')}:${String(reviewSched.minute).padStart(2,'0')}` : '定时复盘'}
|
||||
>
|
||||
<Clock className="h-3 w-3" />定时
|
||||
</button>
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={isGenerating}
|
||||
@@ -344,149 +295,6 @@ export function Review() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 定时复盘设置弹窗 ===== */}
|
||||
<AnimatePresence>
|
||||
{showSchedule && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={() => setShowSchedule(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
className="w-full max-w-md rounded-card border border-border bg-surface p-5 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-medium text-foreground">定时复盘</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSchedule(false)}
|
||||
className="rounded p-1 text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-[11px] leading-relaxed text-muted">
|
||||
开启后,每个交易日到点自动生成大盘复盘报告并归档,静默执行。
|
||||
下次打开本页即可在历史列表看到新报告;也可选推送到飞书。
|
||||
</p>
|
||||
|
||||
{/* 开关(只改本地草稿, 不提交) */}
|
||||
<label className="flex items-center justify-between rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<span className="text-xs text-foreground">启用定时复盘</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.enabled}
|
||||
onClick={() => setDraft(d => ({ ...d, enabled: !d.enabled }))}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
||||
draft.enabled ? 'bg-accent' : 'bg-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform', draft.enabled ? 'translate-x-[18px]' : 'translate-x-1')} />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* 时间设置(仅开启时可编辑, 本地草稿) */}
|
||||
{draft.enabled && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<span className="text-[11px] text-muted">每日</span>
|
||||
<input
|
||||
type="number" min={0} max={23} value={draft.hour}
|
||||
onChange={e => setDraft(d => ({ ...d, hour: Math.max(0, Math.min(23, Number(e.target.value))) }))}
|
||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<span className="text-xs text-muted">:</span>
|
||||
<input
|
||||
type="number" min={0} max={59} value={draft.minute}
|
||||
onChange={e => setDraft(d => ({ ...d, minute: Math.max(0, Math.min(59, Number(e.target.value))) }))}
|
||||
className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<span className="text-[10px] text-muted/70">不早于 15:00 · 工作日执行</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 推送渠道(多选, 独立常驻, 与定时无关, 即时生效) */}
|
||||
<div className="mt-3 rounded-btn bg-elevated/40 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-foreground">生成后推送完整报告</span>
|
||||
<span className="text-[10px] text-muted/70">{reviewPushChannels.length === 0 ? '未开启' : `${reviewPushChannels.length} 个渠道`}</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* 飞书(可用, 多选) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pushMut.isPending}
|
||||
onClick={() => togglePushChannel('feishu')}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-btn border px-2.5 py-1.5 text-left transition-colors disabled:opacity-50',
|
||||
reviewPushChannels.includes('feishu')
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-border/60 bg-base/40 hover:bg-base/60',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex h-3 w-3 shrink-0 items-center justify-center rounded border', reviewPushChannels.includes('feishu') ? 'border-accent bg-accent text-white' : 'border-border')}>
|
||||
{reviewPushChannels.includes('feishu') && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground">飞书</span>
|
||||
<span className="text-[9px] text-muted">群机器人</span>
|
||||
<span className={cn('ml-auto text-[9px]', feishuConfigured ? 'text-emerald-500' : 'text-warning')}>
|
||||
{feishuConfigured ? '已配置' : '未配置'}
|
||||
</span>
|
||||
</button>
|
||||
{/* 微信(开发中, 占位不可选) */}
|
||||
<div className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-1.5 opacity-60">
|
||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center rounded border border-border" />
|
||||
<span className="text-[11px] text-secondary">微信</span>
|
||||
<span className="text-[9px] text-muted">公众号/企业微信</span>
|
||||
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">开发中</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
|
||||
手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。
|
||||
{reviewPushChannels.includes('feishu') && !feishuConfigured && (
|
||||
<Link to="/settings?tab=monitoring" className="ml-1 text-accent hover:underline" onClick={() => setShowSchedule(false)}>
|
||||
前往配置 →
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!draft.enabled && (
|
||||
<p className="mt-3 text-[10px] text-muted/70">
|
||||
当前: 已关闭。开启后将按设定时间自动复盘。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 操作区: 取消 + 保存(统一提交开关+时间) */}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowSchedule(false)}
|
||||
className="rounded-btn bg-elevated px-4 py-1.5 text-xs text-secondary transition-colors hover:text-foreground"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reviewMut.mutate({ enabled: draft.enabled, hour: draft.hour, minute: draft.minute })}
|
||||
disabled={reviewMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{reviewMut.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell } from 'lucide-react'
|
||||
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
|
||||
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
@@ -31,7 +31,7 @@ export function StockAnalysis() {
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
|
||||
const { remember: rememberStock } = useLastStock('stock-analysis')
|
||||
|
||||
const onSelect = (sym: string, nm: string) => {
|
||||
setSymbol(sym)
|
||||
@@ -68,26 +68,7 @@ export function StockAnalysis() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="个股分析"
|
||||
titleExtra={
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
Beta
|
||||
</span>
|
||||
}
|
||||
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={onSelect} />
|
||||
{symbol && (
|
||||
<button
|
||||
onClick={() => setShowHistory(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
历史报告
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
@@ -115,17 +96,6 @@ export function StockAnalysis() {
|
||||
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI 个股分析
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast('点位提醒功能开发中,敬请期待', 'error')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border/40 bg-elevated/40 text-muted text-xs font-medium hover:border-border/70 hover:text-secondary transition-all"
|
||||
title="当价格触及关键价位时提醒(开发中)"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
点位提醒
|
||||
<span className="rounded-full bg-amber-400/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
开发中
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Plus, Trash2, Key, Loader2, Shield, User, Check } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function UserManage() {
|
||||
const qc = useQueryClient()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newUsername, setNewUsername] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [newRole, setNewRole] = useState('viewer')
|
||||
const [resetTarget, setResetTarget] = useState<string | null>(null)
|
||||
const [resetPwd, setResetPwd] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['auth-users'],
|
||||
queryFn: () => api.authUsers(),
|
||||
})
|
||||
const users = data?.users ?? []
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => api.authCreateUser(newUsername, newPassword, newRole),
|
||||
onSuccess: () => {
|
||||
toast('用户已创建', 'success')
|
||||
setShowCreate(false)
|
||||
setNewUsername('')
|
||||
setNewPassword('')
|
||||
setNewRole('viewer')
|
||||
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||
},
|
||||
onError: (err: any) => toast(err?.message || '创建失败', 'error'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (username: string) => api.authDeleteUser(username),
|
||||
onSuccess: () => {
|
||||
toast('用户已删除', 'success')
|
||||
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||
},
|
||||
onError: (err: any) => toast(err?.message || '删除失败', 'error'),
|
||||
})
|
||||
|
||||
const resetMut = useMutation({
|
||||
mutationFn: () => api.authResetPassword(resetTarget!, resetPwd),
|
||||
onSuccess: () => {
|
||||
toast('密码已重置', 'success')
|
||||
setResetTarget(null)
|
||||
setResetPwd('')
|
||||
qc.invalidateQueries({ queryKey: ['auth-users'] })
|
||||
},
|
||||
onError: (err: any) => toast(err?.message || '重置失败', 'error'),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="用户管理"
|
||||
subtitle="管理面板访问账号"
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6 max-w-2xl space-y-4">
|
||||
{/* 操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted">{users.length} 个用户</span>
|
||||
<button
|
||||
onClick={() => setShowCreate(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 创建用户表单 */}
|
||||
<AnimatePresence>
|
||||
{showCreate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-card border border-border bg-surface p-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={e => setNewUsername(e.target.value)}
|
||||
placeholder="用户名"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="密码 (至少 6 位)"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={e => setNewRole(e.target.value)}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="viewer">普通用户 (viewer)</option>
|
||||
<option value="admin">管理员 (admin)</option>
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={createMut.isPending || !newUsername || newPassword.length < 6}
|
||||
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{createMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState icon={User} title="暂无用户" hint="添加第一个用户以允许访问" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{users.map(u => (
|
||||
<div key={u.username} className="flex items-center gap-3 rounded-card border border-border bg-surface p-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{u.username}</span>
|
||||
<span className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-px text-[9px] font-medium',
|
||||
u.role === 'admin' ? 'bg-purple-400/10 text-purple-400' : 'bg-accent/10 text-accent',
|
||||
)}>
|
||||
{u.role === 'admin' ? <><Shield className="h-2.5 w-2.5 mr-0.5" />admin</> : 'viewer'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setResetTarget(u.username); setResetPwd('') }}
|
||||
className="rounded p-1.5 text-muted hover:text-accent hover:bg-elevated transition-colors"
|
||||
title="重置密码"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{u.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确定删除用户「${u.username}」?`)) deleteMut.mutate(u.username)
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
className="rounded p-1.5 text-muted hover:text-danger hover:bg-danger/10 transition-colors disabled:opacity-50"
|
||||
title="删除用户"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 重置密码弹窗 */}
|
||||
<AnimatePresence>
|
||||
{resetTarget && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={() => setResetTarget(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
className="w-full max-w-sm rounded-card border border-border bg-surface p-5 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">重置 {resetTarget} 的密码</h3>
|
||||
<input
|
||||
type="password"
|
||||
value={resetPwd}
|
||||
onChange={e => setResetPwd(e.target.value)}
|
||||
placeholder="新密码 (至少 6 位)"
|
||||
autoFocus
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-sm text-foreground outline-none focus:border-accent/50"
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setResetTarget(null)}
|
||||
className="rounded-btn bg-elevated px-3 py-1.5 text-xs text-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resetMut.mutate()}
|
||||
disabled={resetMut.isPending || resetPwd.length < 6}
|
||||
className="inline-flex items-center gap-1 rounded-btn bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{resetMut.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
确认重置
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,6 @@ const CUSTOM_CODEX_MODEL = '__custom__'
|
||||
const CODEX_COMMAND = 'codex'
|
||||
|
||||
const CODEX_MODEL_OPTIONS = [
|
||||
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
|
||||
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
|
||||
{ label: 'gpt-5', value: 'gpt-5', hint: '通用模型' },
|
||||
]
|
||||
@@ -29,8 +28,6 @@ const PRESETS: { label: string; provider?: string; url: string; model: string; c
|
||||
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
|
||||
{ label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' },
|
||||
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
|
||||
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
|
||||
{ label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
|
||||
]
|
||||
|
||||
export function SettingsAIPanel() {
|
||||
@@ -226,7 +223,7 @@ export function SettingsAIPanel() {
|
||||
icon={Settings2}
|
||||
title="自定义配置"
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60">
|
||||
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
|
||||
{isCodexProvider ? 'CLI' : '接口'}
|
||||
</span>
|
||||
@@ -236,7 +233,7 @@ export function SettingsAIPanel() {
|
||||
{isCodexProvider ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
|
||||
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
|
||||
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`}>
|
||||
{CODEX_COMMAND}
|
||||
</div>
|
||||
</Field>
|
||||
@@ -352,7 +349,6 @@ export function SettingsAIPanel() {
|
||||
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">清空 AI 配置</h3>
|
||||
<p className="text-xs text-secondary mb-5 leading-relaxed">
|
||||
这会清空已保存的 provider、API Key、API 地址、模型和 Codex CLI 命令。之后可以重新配置。
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setConfirmClear(false)} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { Screener } from './pages/Screener'
|
||||
import { Financials } from './pages/Financials'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
import { Auth } from './pages/Auth'
|
||||
import { Data } from './pages/Data'
|
||||
import { Monitor } from './pages/Monitor'
|
||||
@@ -14,50 +13,16 @@ import { StockAnalysis } from './pages/StockAnalysis'
|
||||
import { Review } from './pages/Review'
|
||||
import { LimitUpLadder } from './pages/LimitUpLadder'
|
||||
import { Branding } from './pages/Branding'
|
||||
import { AiSettings } from './pages/AiSettings'
|
||||
import { UserManage } from './pages/UserManage'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Indices } from './pages/Indices'
|
||||
import { Dev } from './pages/Dev'
|
||||
import { useSettings } from './lib/useSharedQueries'
|
||||
import { Logo } from './components/Logo'
|
||||
|
||||
// 首次使用守卫 —— 未完成向导则重定向到 /onboarding
|
||||
// 只挂在根路由上;/onboarding 本身不被守卫,避免循环重定向。
|
||||
// settings 由 Layout 预取,守卫判定不产生额外请求。
|
||||
function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const settings = useSettings()
|
||||
|
||||
// 仅首次加载(本地无缓存)时显示占位。
|
||||
// 后台重取 (isFetching) 时本地已有上一份缓存可用, 直接放行, 避免切页时整屏 logo 闪烁。
|
||||
// 防误重定向已由 Onboarding/AI 等处 invalidate 前的 setQueryData 同步缓存兜底。
|
||||
if (settings.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-base grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-3 text-muted">
|
||||
<Logo size={28} className="text-foreground" />
|
||||
<div className="text-xs">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 查询出错或字段缺失时不拦截 —— 宁可放行,也不把用户卡在空白页
|
||||
if (settings.data && settings.data.onboarding_completed === false) {
|
||||
return <Navigate to="/onboarding" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/onboarding', element: <Onboarding /> },
|
||||
{ path: '/login', element: <Auth /> },
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<OnboardingGuard>
|
||||
<Layout />
|
||||
</OnboardingGuard>
|
||||
),
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'overview', element: <Navigate to="/" replace /> },
|
||||
@@ -79,7 +44,8 @@ export const router = createBrowserRouter([
|
||||
{ path: 'dev', element: <Dev /> },
|
||||
// 旧路由兼容重定向
|
||||
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> },
|
||||
{ path: 'settings/ai', element: <Navigate to="/settings?tab=ai" replace /> },
|
||||
{ path: 'settings/ai', element: <AiSettings /> },
|
||||
{ path: 'settings/users', element: <UserManage /> },
|
||||
{ path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user