Compare commits

..

32 Commits

Author SHA1 Message Date
fish 2f2b53e03e hjg 账号默认使用 DeepSeek Pro AI 配置 2026-07-07 22:17:00 +08:00
fish 490fb7ac0a 移除 serve 看板涨停梯队降级标签 2026-07-07 21:47:56 +08:00
fish 566a8be912 服务端同步改为按行合并去重 2026-07-07 21:30:54 +08:00
fish 476201abed 整理项目说明与许可文件 2026-07-07 21:15:01 +08:00
fish e21d2f69ee 更新项目说明文档 2026-07-07 21:13:55 +08:00
fish 0ad4b1f030 财务分析清理同步相关代码
移除能力检查、同步按钮、进度追踪等未用代码,
仅靠 status.available 判断数据是否存在。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:09:39 +08:00
fish 7a96d51f8a 财务分析移除 Expert 套餐能力门控
serve 只读模式,财务数据仅来自 local 同步,
不再依赖 TickFlow Expert 能力。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:04:47 +08:00
fish f408362c91 移除引导配置页面
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:53:00 +08:00
fish 008f92ffe3 登录页账号输入框默认改为空
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:45:29 +08:00
fish eae13737ff 登录页移除底部文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:45:13 +08:00
fish fcebba0a32 Docker容器改名
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:19:07 +08:00
fish e7cd30b30d 侧边栏增加 admin 用户管理入口
admin 用户可在侧边栏底部进入用户管理页,
支持新增/删除用户。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:05:35 +08:00
fish c1fa0461c1 复盘页添加日期选择功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 15:04:01 +08:00
fish 267b8557e8 概念/行业分析添加日期选择功能
- backend: market_snapshot 支持可选 as_of 参数
- frontend: 两页加 DatePicker,按选定日期查询数据

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:58:31 +08:00
fish 4cd74b06f1 指数页移除日K来源文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:54:37 +08:00
fish ef6e308ad1 指数页移除分时数据面板,K线图占满
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:51:50 +08:00
fish 26a7c02887 指数页隐藏副标题文案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:49:57 +08:00
fish 5c4ae36adc 指数页移除同步按钮及相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:48:21 +08:00
fish 22415d3f25 概念/行业分析移除配置图标及相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:42:22 +08:00
fish 6c770f828e 连板梯队移除 SealedBadge 降级标签
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:35:16 +08:00
fish 833e4e90b9 连板梯队/概念/行业分析移除右上角刷新按钮
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:31:41 +08:00
fish 3a963a130f AI 分析报告按用户隔离存储
个股/财务/复盘报告改为 data/user_data/reports/{username}/ 目录下,
API 端从 request.state.username 读取对应用户的报告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:29:10 +08:00
fish efdfe29ccc 概念/行业分析移除数据获取功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:26:06 +08:00
fish a415762a77 财务面板移除同步时间信息
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:21:31 +08:00
fish c08e7f2c54 AI 配置改为按用户独立存储
每个用户 AI 配置存 data/user_data/ai_settings/{username}.json,
读配置时用户级优先,无则回退到全局 secrets.json。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 14:10:32 +08:00
fish 9ecfc2bb71 侧边栏底部移除设置图标
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 13:00:00 +08:00
fish d2f428ec86 侧边栏底部改为登录用户名 + 暗夜模式开关
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:57:31 +08:00
fish fc69f51e23 serve 多用户认证体系
单密码→多用户:auth.json 格式迁移,login 支持用户名密码,
新增用户管理 API 和前端页面,会话关联 username,
auth middleware 将当前用户注入 request.state。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:49:20 +08:00
fish 22049582df AI 设置隐藏 Codex CLI 预设入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:42:10 +08:00
fish b2a4e90e74 AI 设置隐藏炸鸡中转站预设入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:40:07 +08:00
fish 6dfce96bb3 AI 设置独立为单独页面 /settings/ai
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:37:08 +08:00
fish 4384f5fa00 复盘隐藏定时入口并清理相关代码
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:35:03 +08:00
45 changed files with 1540 additions and 1420 deletions
+49 -1
View File
@@ -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)
-21
View File
@@ -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
View File
@@ -1,10 +1,10 @@
<div align="center"> <div align="center">
# 📈 A股智能量化工作台 # 📈 A股智能量化工作台(本地完整版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台** **自托管、满血功能的 A 股「选股 + 监控 + 回测」量化工作台**
**面向个人散户与量化爱好者而生** **面向个人散户与量化爱好者,所有功能本地运行**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
@@ -17,26 +17,23 @@
<div align="center"> <div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)** **[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[同步到服务端](#-同步到服务端)**
</div> </div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费** - 🆓 **开箱即用** — 留空 Key 即进 None 模式历史日 K 免费体验**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里 - 🏠 **自托管零运维** — Docker 单容器部署数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股 - 🔍 **三位一体** — 选股20 内置策略+ 实时监控 + 向量化回测Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭) - 🤖 **AI 加持** — 一句话生成策略代码任意 OpenAI 兼容接口均可接入留空即关闭
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析 - 📡 **实时行情与监控** — 自选股实时行情、五档盘口、监控规则命中后 SSE 弹窗 + 飞书推送
- 🇨🇳 **A 股专用**盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业 - 🔌 **自由扩展**自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动 AI 复盘并推送至飞书等;连板梯队、涨停动量、内置 ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到 tickflow 数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的 ths 概念/ths 行业(后续更新在这里)
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。 >
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.comq群 109338242
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
觉得有用可以点个 Star,蟹蟹 🌹 觉得有用可以点个 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` | | [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` | | `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐) ### 方式 ADev 模式二次开发推荐
```bash ```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式) cp .env.example .env # 按需填 TICKFLOW_API_KEY留空 = None 模式
./dev.sh # Windows: .\dev.ps1 ./dev.sh # Windows: .\dev.ps1
``` ```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认: 自动检查 / 下载依赖、释放端口、同时起前后端Ctrl-C 一并关闭。默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011> - 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh` - 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心) ### 方式 BDocker部署最省心
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -116,12 +115,14 @@ docker compose up --build
# 打开 http://localhost:3018 # 打开 http://localhost:3018
``` ```
容器名:`TickFlow_Local`。默认端口 `3018`,数据持久化到 `./data/`
<details> <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 ```bash
# 后端 # 后端
@@ -132,42 +133,42 @@ uv run uvicorn app.main:app --reload --port 3018
cd frontend && pnpm install && pnpm dev # http://localhost:3011 cd frontend && pnpm install && pnpm dev # http://localhost:3011
``` ```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。 **回测依赖**vectorbt → numba 体积较大作为可选 extras`uv sync --extra backtest`。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details> </details>
### 🔄 更新代码(已部署用户必读) ### 🔄 更新代码已部署用户必读
拉取新版本只需一条命令: 拉取新版本只需一条命令
```bash ```bash
git pull git pull
``` ```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。 **整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录乃至概念/行业扩展数据全部是程序运行时生成/拉取的用户数据`git pull` 物理上无法影响它们。新用户首次启动时概念/行业两份扩展数据会自动从远程接口拉取无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:** > ⚠️ **切勿使用以下命令"解决冲突"或"清理"它们会一次性删光 `data/` 下所有未被 git 跟踪的数据**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件) > - `git clean -fdx`最危险会删掉所有 `.gitignore` 忽略的文件
> - `git reset --hard` > - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone` > - 直接删除整个项目文件夹重新 `git clone`
> >
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。 > 若 `git pull` 报冲突通常是本地误改了被跟踪的文件请先 `git stash` 暂存再 pull或单独联系作者不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用 ### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签 1. **设置 → 凭据与能力** → 点 **重新检测**确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用) 2. **设置****立即跑盘后管道**拉日 K + 计算 enriched 表None / Free 走 free-api当日数据盘后 1-2 小时可用
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号 3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度) 4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细SSE 实时进度
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录 5. **监控中心**配规则策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
--- ---
## ✨ 核心功能 ## ✨ 核心功能
### 🔍 选股引擎(Screener) ### 🔍 选股引擎Screener
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`): **20 个内置策略**每个策略一个独立 Python 文件基于 Polars 表达式向量化实现`backend/app/strategy/builtin/`):
| 类型 | 代表策略 | | 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- | | :---------- | :------------------------------------------------------- |
@@ -175,79 +176,81 @@ git pull
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 | | 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 | | 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
**扩展策略的三种方式:** **扩展策略的三种方式**
| 方式 | 说明 | | 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- | | :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 | | **🎛️ 自定义信号** | 不写代码UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(`ast` 校验)→ 落入 `data/strategies/ai/` | | **🤖 AI 生成** | 一句话描述思路LLM 读 `strategy-guide.md` 生成完整策略文件`ast` 校验→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 | | **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`引擎自动发现 |
### 📊 指标流水线(Indicators) ### 📊 指标流水线Indicators
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet: 原生 Polars 向量化全 A 股一次扫表落盘 enriched Parquet
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带 - **均线 / 趋势**MA5-60· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅 - **震荡 / 波动**RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数 - **量能 / 涨跌停**量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破 - **原子信号**MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致 - **复权**基于除权因子自动前复权回测与指标口径一致
### 🧪 回测引擎(Backtest) ### 🧪 回测引擎Backtest
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。 基于 vectorbt**三种模式**个股 / 策略组合 / 自由信号组合),真实约束T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理最大持仓 · 敞口 · 等权 / 自定义仓位。SSE 流式进度支持切页重连输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
### 📡 监控中心(Monitor) ### 📡 监控中心Monitor
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动): 统一规则引擎一个页面管理**四类监控**策略 · 个股信号 · 价格涨跌 · 全市场异动):
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical) - 多条件 AND/OR + 冷却期去重 + 严重级别info/warn/critical
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启 - 多入口配置监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标 - 命中后右下角弹窗可配声效+ 持久化到 `alerts.jsonl`菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(`RSI>80`)与当前价位,一眼看清为何触发 - **触发记录详情**每条记录展示命中的具体条件`RSI>80`与当前价位一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填 - **飞书 Webhook 推送**全局一处配置飞书群机器人地址启用推送的规则命中即推送到飞书群支持签名校验);可在设置页设「默认推送渠道」新建规则自动预填
- **SSE 实时推送**:本地运行时,命中告警通过 Server-Sent Events 实时推送到前端
### 📈 个股分析(Beta) ### 📈 个股分析Beta
以「行情 + 关键价位」为主体的单标的决策页: 以「行情 + 关键价位」为主体的单标的决策页
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月 - **专用日 K 图表**主图 + 成交量 + 滑块默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口 - **9 类关键价位**纯函数实时计算毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角 - **AI 四维分析**技术 / 基本面 / 财务 / 消息面流式生成实战派交易员视角
### 🧰 数据与扩展 ### 🧰 数据与扩展
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情 - **TickFlow 多源数据**日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析** - **🔌 第三方接入重点**Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入自动 schema 发现 + 符号归一页面可视化配置**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控 - **盘后定时管道**APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取 - **令牌桶限流**适配各档位 rpm / batch批量合并 + 增量拉取
--- ---
## ⚙️ 配置 ## ⚙️ 配置
所有配置从根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。 所有配置从根目录 `.env` 读取复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源:TickFlow ### 数据源TickFlow
```ini ```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+ | 全市场实时行情 | | Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 | | Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 | | Expert | WebSocket + 财务数据 |
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。 > 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/)高等档位含较低档全部权益。
### AI(可选) ### AI可选
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口: 用于自然语言生成策略。**所有配置留空即跳过**不影响核心功能。支持任意 OpenAI 兼容接口
```ini ```ini
AI_PROVIDER=openai_compat # openai_compat | ollama 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 ```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 | | **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) | | **数据** | Polars计算· DuckDB查询· Parquet存储 |
| **回测** | vectorbt(全项目唯一 pandas 边界) | | **回测** | vectorbt全项目唯一 pandas 边界 |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 | | **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) | | **AI**可选 | OpenAI 兼容接口DeepSeek / 通义 / Ollama 等 |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit | | **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** | | **部署** | Docker 两阶段构建前端 dist 拷进后端镜像**单容器** |
| **桌面端** | PyInstaller + Inno SetupWindows 安装包) |
--- ---
@@ -301,28 +323,12 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ | | 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ | | 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ | | 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ | | 6 | 个股分析专用日 K + 9 类关键价位 + AI 四维分析 | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 | | **v2** | Webhook 推送QMT/掘金下单· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
--- ---
## 📚 文档与贡献 ## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范) - [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南AI 生成与手写规范
- [docs/](./docs) —— 策略构建步骤、示例 - [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)。
-21
View File
@@ -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
View File
@@ -1,94 +1,44 @@
<div align="center"> <div align="center">
# 📈 A股智能量化工作台 # 📈 A股智能量化工作台(服务端备份/查看版)
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台** **本地完整版的云端伴侣:数据备份 + 基础数据查看**
**面向个人散户与量化爱好者而生** **面向已部署 `local/` 完整版的用户,提供远程备份与轻量查看能力**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/) [![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/)
[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile) [![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers)
</div> </div>
<div align="center"> <div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)** **[快速开始](#-快速开始)** · **[功能范围](#-功能范围)** · **[接收本地同步](#-接收本地同步)** · **[部署说明](#-部署说明)**
</div> </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/) | | 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` | | [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` | | `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐) ### 方式 ADev 模式
```bash ```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式) cp .env.example .env # 配置监听端口、同步密钥等
./dev.sh # Windows: .\dev.ps1 ./dev.sh # Windows: .\dev.ps1
``` ```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认: 默认
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011> - 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh` - 自定义端口`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心) ### 方式 BDocker(推荐)
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -116,213 +66,151 @@ docker compose up --build
# 打开 http://localhost:3018 # 打开 http://localhost:3018
``` ```
<details> 容器名:`stock_panel`。默认端口 `3018`
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest` > 与 `local/` 不同,`serve/` 的 `data/` 目录**默认被 `.dockerignore` 忽略**,容器重启不会保留容器内的数据。请通过本地 `local/` 主动同步,或将 `data/` 挂载到持久化卷
**手动分别启动:**
```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. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
--- ---
## ✨ 核心功能 ## 📦 功能范围
### 🔍 选股引擎(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/`): ### 适用场景
| 类型 | 代表策略 | - **异地灾备**:local/ 每天收盘后自动把 enriched 数据、选股结果、复盘报告推送到 serve/
| :---------- | :------------------------------------------------------- | - **移动查看**:出差时用手机浏览器访问 serve/,查看本地已分析好的结果
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 | - **家庭共享**:家人用 viewer 账号查看,admin 账号管理同步密钥
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
**扩展策略的三种方式:** ---
| 方式 | 说明 | ## 🔄 接收本地同步
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,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 · 动量 · 布林带 ```ini
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅 SYNC_KEY=your-shared-secret
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数 ```
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
### 🧪 回测引擎(Backtest) 首次启动时建议通过 `AUTH_PASSWORD` 预置管理员密码:
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。 ```ini
AUTH_PASSWORD=你的密码
```
### 📡 监控中心(Monitor) ### 2. 本地端配置
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动): `local/.env` 中填写服务端地址和密钥:
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical) ```ini
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启 SYNC_SERVE_URL=https://your-serve.example.com
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标 SYNC_KEY=your-shared-secret
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发 ```
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
### 📈 个股分析(Beta) ### 3. 触发同步
以「行情 + 关键价位」为主体的单标的决策页: 在 local/ 的「设置 → 数据同步」页面手动触发,或等待盘后定时任务自动推送。
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月 ### 4. 安全建议
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
### 🧰 数据与扩展 - 务必通过 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 ```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 兼容接口: │ local/ 本地完整版 │
│ 选股 · 回测 · 实时监控 · AI 分析 │
```ini │ ↓ 定时/手动同步 │
AI_PROVIDER=openai_compat # openai_compat | ollama └─────────────────────────────────────┘
AI_BASE_URL=https://api.deepseek.com/v1
AI_API_KEY= # 留空 = 关闭 AI ▼ HTTPS + SYNC_KEY
AI_MODEL=deepseek-chat ┌─────────────────────────────────────┐
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 │ serve/ 服务端轻量版 │
│ 数据备份 · 基础查看 · 多用户 │
└─────────────────────────────────────┘
``` ```
### 服务与数据 - **local/**:功能核心,承担所有计算、实时行情、回测、监控
- **serve/**:数据落地与轻量展示,不承担计算,不直接调用 TickFlow 高配额接口
```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)。
+103 -25
View File
@@ -1,11 +1,15 @@
"""访问认证 API。 """访问认证 API。
端点: 端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效 GET /api/auth/status — 认证状态 + 当前用户信息
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占) POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流) POST /api/auth/login — 登录(用户名+密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话 POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录) 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。 - setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
@@ -101,6 +105,25 @@ def _clear_login_fails(ip: str) -> None:
_fail_counter.pop(ip, 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): 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) 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) 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") @router.get("/status")
def auth_status(request: Request) -> dict: 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 { return {
"configured": auth.is_configured(), "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") @router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict: def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。""" """登录: 用户名+密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
ip = _client_ip(request) ip = _client_ip(request)
_check_login_rate_limit(ip) _check_login_rate_limit(ip)
if not auth.is_configured(): if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码") raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password) result = auth.login(req.username, req.password)
if not token: if not result:
_record_login_fail(ip) _record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
token, username = result
_clear_login_fails(ip) _clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效 # HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie( response.set_cookie(
@@ -176,7 +215,7 @@ def login(req: LoginIn, request: Request, response: Response) -> dict:
path="/", path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS) secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
) )
return {"ok": True, "authenticated": True} return {"ok": True, "authenticated": True, "username": username}
@router.post("/logout") @router.post("/logout")
@@ -191,23 +230,62 @@ def logout(request: Request, response: Response) -> dict:
@router.post("/change-password") @router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict: def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)""" """修改密码: 需验证旧密码。"""
token = request.cookies.get(COOKIE_NAME) 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="请先登录") raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured(): if not auth.change_password(username, req.old_password, req.new_password):
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)
raise HTTPException(status_code=401, detail="旧密码错误") raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话) return {"ok": True, "message": "密码已修改"}
auth.set_password(req.new_password)
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}
+91 -3
View File
@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import tarfile import tarfile
import tempfile import tempfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
import polars as pl
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form
from app.config import settings from app.config import settings
@@ -29,6 +31,89 @@ SYNCABLE_PARTS = {
} }
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: def _verify_sync_key(request: Request) -> None:
"""简单的鉴权 — 校验 X-Sync-Key 头。""" """简单的鉴权 — 校验 X-Sync-Key 头。"""
key = request.headers.get("X-Sync-Key", "") key = request.headers.get("X-Sync-Key", "")
@@ -105,6 +190,7 @@ async def upload_sync(
raise HTTPException(status_code=400, detail="上传文件为空") raise HTTPException(status_code=400, detail="上传文件为空")
extracted = 0 extracted = 0
action_counter: dict[str, int] = {}
try: try:
with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar: with tarfile.open(fileobj=BytesIO(raw), mode="r:gz") as tar:
for member in tar.getmembers(): for member in tar.getmembers():
@@ -123,13 +209,15 @@ async def upload_sync(
with tar.extractfile(member) as src: with tar.extractfile(member) as src:
if src is None: if src is None:
continue continue
target.write_bytes(src.read()) ok, action = _merge_parquet(target, src.read())
if ok:
extracted += 1 extracted += 1
action_counter[action] = action_counter.get(action, 0) + 1
except tarfile.TarError as exc: except tarfile.TarError as exc:
raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc raise HTTPException(status_code=400, detail=f"压缩包解析失败: {exc}") from exc
# 刷新缓存 # 刷新缓存
_refresh_views(request) _refresh_views(request)
logger.info("sync uploaded: parts=%s files=%d", parts, extracted) logger.info("sync uploaded: parts=%s files=%d actions=%s", parts, extracted, action_counter)
return {"ok": True, "parts": sorted(requested), "file_count": extracted} return {"ok": True, "parts": sorted(requested), "file_count": extracted, "actions": action_counter}
+8 -5
View File
@@ -125,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
data_dir = request.app.state.repo.store.data_dir data_dir = request.app.state.repo.store.data_dir
async def stream_gen(): 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" yield chunk + "\n"
return StreamingResponse( return StreamingResponse(
@@ -151,11 +151,12 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。""" """获取当前用户的历史报告(按时间降序,后端已裁剪到上限)。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL): if not capset.has(Cap.FINANCIAL):
return {"reports": []} return {"reports": []}
return {"reports": ai_reports.list_reports()} username = getattr(request.state, "username", None)
return {"reports": ai_reports.list_reports(username)}
@router.post("/reports") @router.post("/reports")
@@ -163,6 +164,7 @@ def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。""" """保存一条报告。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL) capset.require(Cap.FINANCIAL)
username = getattr(request.state, "username", None)
report = ai_reports.save_report({ report = ai_reports.save_report({
"symbol": req.symbol, "symbol": req.symbol,
"name": req.name, "name": req.name,
@@ -170,7 +172,7 @@ def save_report(request: Request, req: SaveReportRequest):
"content": req.content, "content": req.content,
"periods": req.periods, "periods": req.periods,
"summary": req.summary, "summary": req.summary,
}) }, username)
return {"ok": True, "report": report} return {"ok": True, "report": report}
@@ -179,5 +181,6 @@ def delete_report(request: Request, report_id: str):
"""删除一条报告。""" """删除一条报告。"""
capset = request.app.state.capabilities capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL) 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} return {"ok": ok}
+8 -7
View File
@@ -55,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}") raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen(): 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" yield chunk + "\n"
return StreamingResponse( return StreamingResponse(
@@ -81,13 +81,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): 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") @router.post("/reports")
def save_report(request: Request, req: SaveReportRequest): def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。""" """保存一条复盘报告。"""
username = getattr(request.state, "username", None)
report = market_recap_reports.save_report({ report = market_recap_reports.save_report({
"as_of": req.as_of, "as_of": req.as_of,
"focus": req.focus, "focus": req.focus,
@@ -95,9 +97,7 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary, "summary": req.summary,
"emotion_score": req.emotion_score, "emotion_score": req.emotion_score,
"emotion_label": req.emotion_label, "emotion_label": req.emotion_label,
}) }, username)
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
# 内部 try/except 静默降级, 不影响归档返回值。
from app.jobs.daily_pipeline import _maybe_push_review from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, { _maybe_push_review(req.content, {
"as_of": req.as_of, "as_of": req.as_of,
@@ -109,5 +109,6 @@ def save_report(request: Request, req: SaveReportRequest):
@router.delete("/reports/{report_id}") @router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str): 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} return {"ok": ok}
+1
View File
@@ -55,6 +55,7 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
async def stream_gen(): async def stream_gen():
async for chunk in analyze_rotation_stream( async for chunk in analyze_rotation_stream(
repo, days, req.focus, repo, days, req.focus,
username=request.state.username,
): ):
yield chunk + "\n" yield chunk + "\n"
+3 -2
View File
@@ -296,12 +296,13 @@ def get_cached(
@router.get("/market-snapshot") @router.get("/market-snapshot")
def market_snapshot(request: Request): def market_snapshot(request: Request, as_of: date | None = None):
"""最新全市场轻量行情快照,供板块/概念聚合分析使用。""" """全市场轻量行情快照(可选指定日期),供板块/概念聚合分析使用。"""
import polars as pl import polars as pl
repo = request.app.state.repo repo = request.app.state.repo
svc = ScreenerService(repo) svc = ScreenerService(repo)
if as_of is None:
as_of = svc.latest_date() as_of = svc.latest_date()
if not as_of: if not as_of:
return {"as_of": None, "rows": []} return {"as_of": None, "rows": []}
+58 -20
View File
@@ -48,15 +48,50 @@ class TickflowKeyIn(BaseModel):
api_key: str 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("") @router.get("")
def get_settings() -> dict: def get_settings(request: Request) -> dict:
"""返回当前配置概况(Key 脱敏)。""" """返回当前配置概况(Key 脱敏)。"""
from app.config import settings from app.config import settings
from app.services import preferences from app.services import preferences
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command 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() 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 { return {
"mode": tf_client.current_mode(), "mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key), "tickflow_api_key_masked": secrets_store.mask(key),
@@ -68,15 +103,15 @@ def get_settings() -> dict:
"extras_caps": extras_caps(), "extras_caps": extras_caps(),
# 首次使用引导 # 首次使用引导
"onboarding_completed": preferences.get_onboarding_completed(), "onboarding_completed": preferences.get_onboarding_completed(),
# AI 配置 # AI 配置 (用户级优先)
"ai_provider": ai_provider, "ai_provider": ai_provider,
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url), "ai_base_url": _get_ai_config(username, "ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()), "ai_api_key_masked": secrets_store.mask(ai_api_key),
"has_ai_key": bool(secrets_store.get_ai_key()), "has_ai_key": bool(ai_api_key),
"ai_configured": ai_configured(ai_provider), "ai_configured": ai_configured(ai_provider),
"ai_model": current_ai_model(), "ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(), "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") @router.post("/ai")
def save_ai_settings(req: AiSettingsIn) -> dict: def save_ai_settings(req: AiSettingsIn, request: Request) -> dict:
"""保存 AI 配置(全部持久化到 secrets.json""" """保存 AI 配置(按用户独立存储)。"""
from app.config import settings 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 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 = {} updates: dict = {}
if req.provider: if req.provider:
updates["ai_provider"] = 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 updates["ai_api_key"] = req.api_key
settings.ai_api_key = req.api_key settings.ai_api_key = req.api_key
else: else:
secrets_store.clear("ai_api_key") secrets_store.clear_ai_config(target, "ai_api_key")
settings.ai_api_key = "" settings.ai_api_key = ""
if req.provider == "codex_cli" and not req.model: 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 = "" settings.ai_model = ""
elif req.model: elif req.model:
updates["ai_model"] = 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 raise HTTPException(status_code=400, detail=str(exc)) from exc
updates["ai_codex_command"] = codex_command updates["ai_codex_command"] = codex_command
settings.ai_codex_command = codex_command settings.ai_codex_command = codex_command
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化 # user_agent 允许清空,故无条件持久化
updates["ai_user_agent"] = req.user_agent updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent settings.ai_user_agent = req.user_agent
if updates: if updates:
secrets_store.save(updates) secrets_store.save_ai_config(target, updates)
provider = current_ai_provider() provider = current_ai_provider()
return { return {
@@ -288,15 +326,15 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
@router.delete("/ai") @router.delete("/ai")
def clear_ai_settings() -> dict: def clear_ai_settings(request: Request) -> dict:
"""一键清空 AI 配置(provider / base_url / api_key / model)。 """一键清空当前用户的 AI 配置"""
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
"""
from app.config import settings from app.config import settings
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command") username = getattr(request.state, "username", None)
# 同步重置运行时内存(provider 回默认值,其余置空) 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_provider = "openai_compat"
settings.ai_base_url = "" settings.ai_base_url = ""
settings.ai_api_key = "" settings.ai_api_key = ""
+8 -5
View File
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
data_dir = repo.store.data_dir data_dir = repo.store.data_dir
async def stream_gen(): 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" yield chunk + "\n"
return StreamingResponse( return StreamingResponse(
@@ -191,13 +191,15 @@ class SaveReportRequest(BaseModel):
@router.get("/reports") @router.get("/reports")
def list_reports(request: Request): 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") @router.post("/reports")
def save_report(request: Request, req: SaveReportRequest): def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。""" """保存一条报告。"""
username = getattr(request.state, "username", None)
report = stock_reports.save_report({ report = stock_reports.save_report({
"symbol": req.symbol, "symbol": req.symbol,
"name": req.name, "name": req.name,
@@ -206,12 +208,13 @@ def save_report(request: Request, req: SaveReportRequest):
"summary": req.summary, "summary": req.summary,
"close": req.close, "close": req.close,
"levels": req.levels, "levels": req.levels,
}) }, username)
return {"ok": True, "report": report} return {"ok": True, "report": report}
@router.delete("/reports/{report_id}") @router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str): 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} return {"ok": ok}
+2 -2
View File
@@ -345,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}") raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
try: try:
result = await gen.generate(prompt) result = await gen.generate(prompt, username=request.state.username)
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
return result return result
@@ -356,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
async def ai_generate(req: AIGenerateRequest, request: Request): async def ai_generate(req: AIGenerateRequest, request: Request):
try: try:
gen = AIStrategyGenerator() gen = AIStrategyGenerator()
result = await gen.generate(req.prompt) result = await gen.generate(req.prompt, username=request.state.username)
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e: except Exception as e:
+3 -1
View File
@@ -153,7 +153,9 @@ async def auth_middleware(request: Request, call_next):
# 情况 3: 已设密码, 检查会话 # 情况 3: 已设密码, 检查会话
token = request.cookies.get(auth_api.COOKIE_NAME) 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) return await call_next(request)
# 未登录: 401(前端跳登录页) # 未登录: 401(前端跳登录页)
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"}) return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
+50
View File
@@ -94,3 +94,53 @@ def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
if len(key) <= prefix + suffix: if len(key) <= prefix + suffix:
return "" * len(key) return "" * len(key)
return f"{key[:prefix]}{'' * 6}{key[-suffix:]}" 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
+81 -17
View File
@@ -25,16 +25,74 @@ Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]") _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 return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str: def current_ai_model(username: str | None = None) -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER: if current_ai_provider(username) == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or "")) 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) 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: def current_codex_command() -> str:
return normalize_codex_command( return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_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 return False
def ai_configured(provider: str | None = None) -> bool: def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
provider = provider or current_ai_provider() provider = provider or current_ai_provider(username)
if is_codex_cli_provider(provider): if is_codex_cli_provider(provider):
return codex_cli_available() return codex_cli_available()
return bool(secrets_store.get_ai_key()) return bool(current_ai_api_key(username))
async def generate_ai_text( async def generate_ai_text(
@@ -95,15 +153,17 @@ async def generate_ai_text(
temperature: float = 0.3, temperature: float = 0.3,
max_tokens: int = 3000, max_tokens: int = 3000,
timeout: float = 180.0, timeout: float = 180.0,
username: str | None = None,
) -> str: ) -> str:
"""Return a complete AI response from the currently configured provider.""" """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_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once( return await _run_openai_once(
messages, messages,
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
timeout=timeout, timeout=timeout,
username=username,
) )
@@ -113,13 +173,14 @@ async def stream_ai_text(
temperature: float = 0.5, temperature: float = 0.5,
max_tokens: int = 4000, max_tokens: int = 4000,
timeout: float = 180.0, timeout: float = 180.0,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider. """Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits. 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)) yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return return
@@ -128,6 +189,7 @@ async def stream_ai_text(
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
timeout=timeout, timeout=timeout,
username=username,
): ):
yield chunk yield chunk
@@ -138,14 +200,15 @@ async def _run_openai_once(
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
timeout: float, timeout: float,
username: str | None = None,
) -> str: ) -> str:
ai_key = secrets_store.get_ai_key() ai_key = current_ai_api_key(username)
if not ai_key: if not ai_key:
raise RuntimeError("AI API 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( resp = await client.chat.completions.create(
model=current_ai_model(), model=current_ai_model(username),
messages=list(messages), messages=list(messages),
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
@@ -161,14 +224,15 @@ async def _stream_openai(
temperature: float, temperature: float,
max_tokens: int, max_tokens: int,
timeout: float, timeout: float,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key() ai_key = current_ai_api_key(username)
if not ai_key: if not ai_key:
raise RuntimeError("AI API 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( stream = await client.chat.completions.create(
model=current_ai_model(), model=current_ai_model(username),
messages=list(messages), messages=list(messages),
temperature=temperature, temperature=temperature,
max_tokens=max_tokens, max_tokens=max_tokens,
@@ -181,13 +245,13 @@ async def _stream_openai(
yield delta.content 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 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( return AsyncOpenAI(
api_key=api_key, 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, timeout=timeout,
max_retries=2, max_retries=2,
default_headers={"User-Agent": user_agent}, default_headers={"User-Agent": user_agent},
+25 -39
View File
@@ -1,20 +1,8 @@
"""AI 财务分析报告持久化存储。 """AI 财务分析报告持久化存储 — 按用户名隔离
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序) 存储位置: data/user_data/reports/{username}/ai_reports.json
保留最近 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"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -27,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20 MAX_REPORTS = 20
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
if username:
p = settings.data_dir / "user_data" / "reports" / username / "ai_reports.json"
else:
p = settings.data_dir / "user_data" / "ai_reports.json" p = settings.data_dir / "user_data" / "ai_reports.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -48,54 +39,49 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
# 保持降序
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = 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", 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)。 """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports(username)
自动补全 id 与 created_at(若缺),并裁剪到上限。
"""
reports = list_reports()
if not report.get("id"): if not report.get("id"):
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) logger.info("AI report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False
def clear_reports() -> int: def clear_reports(username: str | None = None) -> int:
"""清空全部报告。返回删除数量。""" """清空指定用户的全部报告。返回删除数量。"""
reports = list_reports() reports = list_reports(username)
n = len(reports) n = len(reports)
if n > 0: if n > 0:
_save_all([]) _save_all([], username)
return n return n
def _now_iso() -> str: def _now_iso() -> str:
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
from datetime import datetime from datetime import datetime
return datetime.now().isoformat(timespec="seconds") return datetime.now().isoformat(timespec="seconds")
+276 -61
View File
@@ -1,15 +1,15 @@
"""访问密码认证 — 单用户, 自托管场景。 """多用户访问密码认证 — 自托管场景。
设计: 设计:
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。 - 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
即使 auth.json 泄露, 也无法逆向出明文密码。 即使 auth.json 泄露, 也无法逆向出明文密码。
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。 - 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。 - 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
- 多用户: 支持多个账号, 数据共享不隔离; role=admin 可管理用户。
安全要点: 安全要点:
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。 - 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。 - 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
- 单密码, 不做多用户(避免重构全项目数据层)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,13 +29,20 @@ _PBKDF2_ITER = 200_000
_SALT_LEN = 16 _SALT_LEN = 16
_TOKEN_BYTES = 32 _TOKEN_BYTES = 32
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率) # 会话有效期: 30 天(自托管, 长一点减少重登频率)
SESSION_TTL = 30 * 24 * 3600 SESSION_TTL = 30 * 24 * 3600
_lock = threading.Lock() # schema 版本
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。 _CURRENT_SCHEMA_VERSION = 2
_sessions: dict[str, float] = {}
_lock = threading.Lock()
# 内存中的有效会话: { token: {username, expire} }。进程重启后从磁盘恢复。
_sessions: dict[str, dict] = {}
# ================================================================
# 存储
# ================================================================
def _path() -> Path: def _path() -> Path:
from app.config import settings from app.config import settings
@@ -63,6 +70,10 @@ def _save(data: dict) -> None:
pass pass
# ================================================================
# 密码工具
# ================================================================
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。""" """返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
if salt is None: 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: def is_configured() -> bool:
"""是否已设置访问密码""" """是否已有用户"""
d = _load() data = _ensure_loaded()
return bool(d.get("password_hash")) 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: if len(password) < 6:
raise ValueError("密码至少 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) salt_hex, hash_hex = _hash_password(password)
with _lock: with _lock:
_sessions.clear() # 改密码 = 旧会话全部失效 data = _ensure_loaded()
_save({ users = data.setdefault("users", {})
if username in users:
raise ValueError(f"用户 '{username}' 已存在")
users[username] = {
"password_hash": hash_hex, "password_hash": hash_hex,
"password_salt": salt_hex, "password_salt": salt_hex,
"updated_at": int(time.time()), "role": role,
"sessions": {}, # 清空持久化会话 "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: def bootstrap_from_env() -> bool:
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码 """首次初始化: 若环境变量 AUTH_PASSWORD 已配置且无用户, 则创建 admin 用户
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。 公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。 一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
Returns: Returns:
@@ -123,74 +326,86 @@ def bootstrap_from_env() -> bool:
pwd = (settings.auth_password or "").strip() pwd = (settings.auth_password or "").strip()
if not pwd: if not pwd:
return False return False
if is_configured(): data = _ensure_loaded()
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码) if data.get("users"):
return False return False
try: try:
set_password(pwd) create_user("admin", pwd, role="admin")
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)") logger.info("admin user bootstrapped from AUTH_PASSWORD env (one-time)")
return True return True
except ValueError as e: except ValueError as e:
# 密码不合规 (< 6 位), 记日志但不阻断启动
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e) logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
return False 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
# set_password 保留为 create_user 别名(旧 api/auth.py 中可能使用)
def revoke_session(token: str) -> None: def set_password(password: str) -> None:
"""注销会话(登出)""" """兼容旧版: 创建/更新 admin 用户密码"""
with _lock: with _lock:
_sessions.pop(token, None) data = _ensure_loaded()
_persist_sessions_locked() 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: def is_valid_session(token: str) -> bool:
"""检查会话是否有效(存在且未过期)。过期则清理""" """兼容旧版: 检查会话是否有效。"""
if not token: return get_session_user(token) is not None
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
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: def _persist_sessions_locked() -> None:
"""把当前内存会话写回 auth.json(需持锁调用)。""" """把当前内存会话写回 auth.json(需持锁调用)。"""
d = _load() 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) _save(d)
def _restore_sessions() -> None: def _restore_sessions() -> None:
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。""" """启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
with _lock: with _lock:
d = _load() data = _ensure_loaded()
now = time.time() now = time.time()
saved = d.get("sessions") or {} saved = data.get("sessions") or {}
for token, expire in saved.items(): for token, session in saved.items():
if isinstance(expire, (int, float)) and expire > now: if isinstance(session, dict):
_sessions[token] = expire 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): if len(_sessions) != len(saved):
# 有过期会话被清理, 落盘一次
_persist_sessions_locked() _persist_sessions_locked()
@@ -285,6 +285,8 @@ async def analyze_rotation_stream(
repo, repo,
days: int = 12, days: int = 12,
focus: str = "", focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。 """流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -329,7 +331,7 @@ async def analyze_rotation_stream(
try: try:
from app.services.ai_provider import stream_ai_text, ai_configured from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured(): if not ai_configured(username=username):
yield json.dumps({ yield json.dumps({
"type": "error", "type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址", "message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
@@ -344,6 +346,7 @@ async def analyze_rotation_stream(
], ],
temperature=0.5, temperature=0.5,
max_tokens=4000, max_tokens=4000,
username=username,
): ):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
data_dir: Path, data_dir: Path,
symbol: str, symbol: str,
focus: str = "", focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。 """流式分析:yield 出每个文本 chunk。
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
], ],
temperature=0.4, temperature=0.4,
max_tokens=4000, max_tokens=4000,
username=username,
): ):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -255,6 +255,8 @@ async def recap_market_stream(
as_of: date | None = None, as_of: date | None = None,
focus: str = "", focus: str = "",
news: list[dict] | None = None, news: list[dict] | None = None,
*,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。 """流式大盘复盘:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def recap_market_stream(
], ],
temperature=0.5, temperature=0.5,
max_tokens=4500, max_tokens=4500,
username=username,
): ):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -1,24 +1,8 @@
"""AI 大盘复盘报告持久化存储。 """AI 大盘复盘报告持久化存储 — 按用户名隔离
与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 —— 存储位置: data/user_data/reports/{username}/ai_market_recaps.json
单独的文件、字段、上限,互不影响。刻意不复用,避免引入 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"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 20 MAX_REPORTS = 20
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
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 = settings.data_dir / "user_data" / "ai_market_recaps.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -52,37 +39,37 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = 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", 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)。""" """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports() reports = list_reports(username)
if not report.get("id"): if not report.get("id"):
report["id"] = f"mkr_{int(time.time() * 1000)}" report["id"] = f"mkr_{int(time.time() * 1000)}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("Market recap saved: %s (as_of=%s), total %d", logger.info("Market recap saved by %s: %s (as_of=%s), total %d",
report.get("id"), report.get("as_of"), len(reports)) username or "?", report.get("id"), report.get("as_of"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
data_dir: Path, data_dir: Path,
symbol: str, symbol: str,
focus: str = "", focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""流式个股分析:yield 出每个 NDJSON 事件。 """流式个股分析:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
], ],
temperature=0.5, temperature=0.5,
max_tokens=4500, max_tokens=4500,
username=username,
): ):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+20 -33
View File
@@ -1,24 +1,8 @@
"""AI 个股分析报告持久化存储。 """AI 个股分析报告持久化存储 — 按用户名隔离
与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限, 存储位置: data/user_data/reports/{username}/ai_stock_reports.json
互不影响。刻意不复用,避免引入 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"
}
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -31,16 +15,19 @@ logger = logging.getLogger(__name__)
MAX_REPORTS = 50 MAX_REPORTS = 50
def _path() -> Path: def _path(username: str | None = None) -> Path:
from app.config import settings from app.config import settings
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 = settings.data_dir / "user_data" / "ai_stock_reports.json"
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
return p return p
def list_reports() -> list[dict]: def list_reports(username: str | None = None) -> list[dict]:
"""返回全部报告(按 created_at 降序)。""" """返回指定用户的全部报告(按 created_at 降序)。"""
p = _path() p = _path(username)
if not p.exists(): if not p.exists():
return [] return []
try: try:
@@ -52,36 +39,36 @@ def list_reports() -> list[dict]:
return [] return []
def _save_all(reports: list[dict]) -> None: def _save_all(reports: list[dict], username: str | None = None) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。""" """全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS: if len(reports) > MAX_REPORTS:
reports = 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", 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)。""" """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports() reports = list_reports(username)
if not report.get("id"): if not report.get("id"):
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"): if not report.get("created_at"):
report["created_at"] = _now_iso() report["created_at"] = _now_iso()
reports.append(report) reports.append(report)
_save_all(reports) _save_all(reports, username)
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) logger.info("Stock report saved by %s: %s (%s), total %d", username or "?", report.get("symbol"), report.get("id"), len(reports))
return report return report
def delete_report(report_id: str) -> bool: def delete_report(report_id: str, username: str | None = None) -> bool:
"""删除指定报告。返回是否删除成功。""" """删除指定用户的报告。返回是否删除成功。"""
reports = list_reports() reports = list_reports(username)
before = len(reports) before = len(reports)
reports = [r for r in reports if r.get("id") != report_id] reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before: if len(reports) < before:
_save_all(reports) _save_all(reports, username)
return True return True
return False return False
+4 -3
View File
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
self._guide_cache = "" self._guide_cache = ""
return 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} Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
guide = self._get_guide() guide = self._get_guide()
# 调用 LLM # 调用 LLM
code = await self._call_llm(user_prompt, guide) code = await self._call_llm(user_prompt, guide, username=username)
# 验证 # 验证
try: try:
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
return {"code": code, "meta": meta, "valid": True, "error": None} 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.""" """Call the configured AI provider and return generated strategy code."""
from app.services.ai_provider import generate_ai_text from app.services.ai_provider import generate_ai_text
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
], ],
temperature=0.3, temperature=0.3,
max_tokens=3000, max_tokens=3000,
username=username,
) )
# Extract fenced code if the model wrapped the answer in Markdown. # Extract fenced code if the model wrapped the answer in Markdown.
if "```python" in content: if "```python" in content:
+1 -1
View File
@@ -2491,7 +2491,7 @@ all = [
[[package]] [[package]]
name = "tickflow-stock-panel-backend" name = "tickflow-stock-panel-backend"
version = "0.1.66" version = "0.1.70"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
+1 -1
View File
@@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
BACKEND_EXTRAS: ${BACKEND_EXTRAS:-} BACKEND_EXTRAS: ${BACKEND_EXTRAS:-}
container_name: TickFlow_Stock_Panel container_name: stock_panel
ports: ports:
- "${PORT:-3018}:3018" - "${PORT:-3018}:3018"
env_file: env_file:
+78 -21
View File
@@ -1,5 +1,6 @@
import { NavLink, Outlet } from 'react-router-dom' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ToastContainer } from '@/components/Toast' import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast' import { AlertToastContainer } from '@/components/AlertToast'
@@ -10,7 +11,6 @@ import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBu
import { import {
useSettings, useSettings,
usePreferences, usePreferences,
useVersion,
} from '@/lib/useSharedQueries' } from '@/lib/useSharedQueries'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { import {
@@ -25,6 +25,12 @@ import {
Layers3, Layers3,
Landmark, Landmark,
BookOpenCheck, BookOpenCheck,
Moon,
Sun,
User,
Users,
Shield,
LogOut,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { api } from '@/lib/api' import { api } from '@/lib/api'
@@ -47,7 +53,7 @@ const nav = [
function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) { function AIConfigBadge({ configured, model }: { configured?: boolean; model?: string }) {
return ( return (
<NavLink <NavLink
to="/settings?tab=ai" to="/settings/ai"
className="mt-2 group block -mx-2.5" className="mt-2 group block -mx-2.5"
title="AI 配置" title="AI 配置"
> >
@@ -76,14 +82,43 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
export function Layout() { export function Layout() {
// ===== 共享 hooks (替代内联 useQuery) ===== // ===== 共享 hooks (替代内联 useQuery) =====
const { data: settingsState } = useSettings() const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences() const { data: prefs } = usePreferences()
const { data: analysisMenus } = useQuery({ const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus, queryKey: QK.analysisMenus,
queryFn: api.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 ?? []) const analysisNav = (analysisMenus?.items ?? [])
@@ -161,26 +196,48 @@ export function Layout() {
))} ))}
</nav> </nav>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0"> <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 <NavLink
to="/settings" to="/settings/users"
className={({ isActive }) => className={({ isActive }) =>
cn( 'flex items-center gap-2 px-3 py-1.5 text-xs text-foreground/70 hover:bg-elevated hover:text-foreground transition-colors' +
'flex items-center justify-between gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth', (isActive ? ' bg-accent/10 text-accent' : '')
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
} }
> >
<span className="flex items-center gap-3"> <Users className="h-3.5 w-3.5" />
<Settings className="h-4 w-4 shrink-0" /> <Shield className="h-3 w-3 text-amber-400" />
<span></span> <span className="flex-1"></span>
</span>
<span className="font-mono text-[10px] text-muted/50 select-none">
{version ?? ''}
</span>
</NavLink> </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> </div>
</aside> </aside>
@@ -177,7 +177,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
<div className="text-sm font-medium text-foreground"></div> <div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div> <div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && ( {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"> 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 <Settings2 className="h-3.5 w-3.5" /> AI
</button> </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"> <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" /> <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> <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"> 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" /> <Settings2 className="h-3 w-3" />
</button> </button>
@@ -170,7 +170,7 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) {
<div className="text-sm font-medium text-foreground"></div> <div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div> <div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && ( {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"> 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 <Settings2 className="h-3.5 w-3.5" /> AI
</button> </button>
+22 -6
View File
@@ -589,16 +589,16 @@ export const api = {
// ===== Auth (访问认证) ===== // ===== Auth (访问认证) =====
authStatus: () => 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) => authSetup: (password: string) =>
request<{ ok: boolean }>('/api/auth/setup', { request<{ ok: boolean }>('/api/auth/setup', {
method: 'POST', method: 'POST',
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
}), }),
authLogin: (password: string) => authLogin: (username: string, password: string) =>
request<{ ok: boolean }>('/api/auth/login', { request<{ ok: boolean; username?: string }>('/api/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ password }), body: JSON.stringify({ username, password }),
}), }),
authLogout: () => authLogout: () =>
request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }), request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }),
@@ -607,6 +607,22 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }), 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'), settings: () => request<SettingsState>('/api/settings'),
saveTickflowKey: (api_key: string) => saveTickflowKey: (api_key: string) =>
@@ -866,8 +882,8 @@ export const api = {
? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}` ? `/api/screener/cached?ext_columns=${encodeURIComponent(extColumns)}`
: '/api/screener/cached', : '/api/screener/cached',
), ),
marketSnapshot: () => marketSnapshot: (asOf?: string) =>
request<{ as_of: string | null; rows: MarketSnapshotRow[] }>('/api/screener/market-snapshot'), 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}` : ''}`), overviewMarket: (asOf?: string) => request<OverviewMarket>(`/api/overview/market${asOf ? `?as_of=${asOf}` : ''}`),
// 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序 // 概念涨幅轮动矩阵: 每列(日期)各自把所有概念按当天涨幅从高到低排序
+13
View File
@@ -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>
</>
)
}
+15 -8
View File
@@ -13,13 +13,14 @@ import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { motion } from 'framer-motion' 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 { api } from '@/lib/api'
import { Logo } from '@/components/Logo' import { Logo } from '@/components/Logo'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
export function Auth() { export function Auth() {
const navigate = useNavigate() const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用 const [confirmPassword, setConfirmPassword] = useState('') // 仅设密码时用
const [showPwd, setShowPwd] = useState(false) const [showPwd, setShowPwd] = useState(false)
@@ -43,7 +44,7 @@ export function Auth() {
if (isSetup) { if (isSetup) {
return api.authSetup(password) return api.authSetup(password)
} }
return api.authLogin(password) return api.authLogin(username, password)
}, },
onSuccess: () => { onSuccess: () => {
// 成功: 跳回原页面(或首页) // 成功: 跳回原页面(或首页)
@@ -112,6 +113,17 @@ export function Auth() {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-3"> <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"> <div className="relative">
<input <input
@@ -119,7 +131,7 @@ export function Auth() {
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
placeholder="访问密码" 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" 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 <button
@@ -184,11 +196,6 @@ export function Auth() {
</div> </div>
)} )}
</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> </motion.div>
</div> </div>
) )
+15 -74
View File
@@ -1,5 +1,5 @@
import { useMemo, useState, type ReactNode } from 'react' 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 { AnimatePresence } from 'framer-motion'
import { import {
Activity, Activity,
@@ -8,14 +8,14 @@ import {
RefreshCw, RefreshCw,
Repeat, Repeat,
Search, Search,
Settings2,
TrendingDown, TrendingDown,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState' 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 { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { DatePicker } from '@/components/DatePicker'
import { RpsRotationDialog } from '@/components/RpsRotationDialog' import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api' import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
@@ -68,9 +68,7 @@ function loadConfig(): AnalysisFieldConfig {
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
} }
function saveConfig(c: AnalysisFieldConfig) {
storage.conceptAnalysisConfig.set(c)
}
function pickBestConfig( function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[], configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
@@ -234,14 +232,14 @@ function statSort(mode: SortMode) {
} }
export function ConceptAnalysis() { export function ConceptAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig) const [fieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat') const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null) const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('') const [previewName, setPreviewName] = useState<string>('')
const [showRps, setShowRps] = useState(false) const [showRps, setShowRps] = useState(false)
const [asOf, setAsOf] = useState('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? [] const availableConfigs = configsQuery.data?.items ?? []
@@ -253,29 +251,14 @@ export function ConceptAnalysis() {
const activeConfig = availableConfigs.find(c => c.id === activeConfigId) const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({ const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT), queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }), queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
enabled: !!activeConfigId, 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({ const marketQuery = useQuery({
queryKey: QK.marketSnapshot, queryKey: ['market-snapshot', asOf],
queryFn: api.marketSnapshot, queryFn: () => api.marketSnapshot(asOf || undefined),
staleTime: 60_000, staleTime: 60_000,
}) })
@@ -316,12 +299,6 @@ export function ConceptAnalysis() {
return set.size return set.size
}, [stats]) }, [stats])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) { if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div> 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 ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader <PageHeader title="概念分析" />
title="概念分析" <EmptyState icon={Layers3} title="暂无概念数据" hint="请通过 local→serve 同步概念数据" />
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()}
/>
</div> </div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
</> </>
) )
} }
@@ -358,9 +319,10 @@ export function ConceptAnalysis() {
<> <>
<PageHeader <PageHeader
title="概念分析" 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={ right={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */} {/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button <button
onClick={() => setShowRps(true)} onClick={() => setShowRps(true)}
@@ -369,17 +331,6 @@ export function ConceptAnalysis() {
> >
<Repeat className="h-3.5 w-3.5" />RPS轮动分析 <Repeat className="h-3.5 w-3.5" />RPS轮动分析
</button> </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> </div>
} }
/> />
@@ -411,23 +362,13 @@ export function ConceptAnalysis() {
</div> </div>
) : rowsQuery.isLoading ? ( ) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div> <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 || '请检查扩展数据是否包含概念/题材相关字段'} /> <EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)} )}
</div> </div>
</div> </div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
{previewSymbol && ( {previewSymbol && (
<StockPreviewDialog <StockPreviewDialog
+3 -8
View File
@@ -7,8 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api' import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { fmtBigNum } from '@/lib/format' import { fmtBigNum } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries' import { useDataStatus, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { SettingsModal } from '@/components/data/SettingsModal' import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard' import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
@@ -371,11 +370,7 @@ export function Dashboard() {
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}) })
const data = overview.data const data = overview.data
const caps = useCapabilities()
const settings = useSettings() const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限 // none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
const isNoKey = settings.data?.mode === 'none' const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片 // 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
@@ -541,7 +536,7 @@ export function Dashboard() {
<div className="mb-3 grid grid-cols-6 gap-2"> <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">{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="强势 / 弱势" 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={`${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={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" /> <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" />
@@ -611,7 +606,7 @@ export function Dashboard() {
<aside className="min-w-0 space-y-3"> <aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-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} /> <LadderMini limit={data.limit} />
</section> </section>
<section className="rounded-card border border-border bg-surface/80 p-3"> <section className="rounded-card border border-border bg-surface/80 p-3">
+13 -164
View File
@@ -1,16 +1,14 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { RefreshCw, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink } from 'lucide-react' import { Loader2, Search, FileText, Database, Lightbulb, ExternalLink, X } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { useCapabilities } from '@/lib/useSharedQueries' import { useFinancialStatus } from '@/lib/useFinancials'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch' import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail' import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel' import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
import { LastStockChip } from '@/components/LastStockChip' import { LastStockChip } from '@/components/LastStockChip'
import { useLastStock } from '@/lib/useLastStock' import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format' import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
const TABLE_LABELS: Record<string, string> = { const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标', metrics: '核心指标',
@@ -27,42 +25,19 @@ const TABLE_ICON: Record<string, typeof FileText> = {
} }
export function Financials() { export function Financials() {
const { data: caps } = useCapabilities()
const hasFinancial = caps?.capabilities?.['financial'] != null
const { data: status, isLoading } = useFinancialStatus() 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 { last: lastStock, remember: rememberStock } = useLastStock('financials')
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
const pick = (symbol: string, name: string) => { const pick = (symbol: string, name: string) => {
setSelected({ symbol, name }) setSelected({ symbol, name })
rememberStock(symbol, name) rememberStock(symbol, name)
} }
// 无 Expert 能力时: 等 status 加载完, 判断是否有从 local 同步来的数据 // 等 status 加载完, 判断是否有从 local 同步来的数据
if (!hasFinancial) {
if (isLoading) { if (isLoading) {
return ( return (
<> <>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" /> <PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" /> <Loader2 className="h-5 w-5 animate-spin text-muted" />
</div> </div>
@@ -70,20 +45,12 @@ export function Financials() {
) )
} }
if (!status?.available) { if (!status?.available) {
// 既无 Expert 能力, 也无同步数据 → 锁定页 // 无同步数据
return ( return (
<> <>
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" /> <PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析" />
<div className="px-8 py-10"> <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 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>
<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"> <div className="flex items-center gap-1.5 text-xs font-medium text-accent">
<Lightbulb className="h-3.5 w-3.5 shrink-0" /> <Lightbulb className="h-3.5 w-3.5 shrink-0" />
@@ -102,70 +69,13 @@ export function Financials() {
</a> </a>
</div> </div>
</div> </div>
</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)
},
})
}
const tables = status?.tables ?? {} const tables = status?.tables ?? {}
const available = status?.available ?? false 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 ( return (
<> <>
@@ -175,40 +85,13 @@ export function Financials() {
right={ right={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} /> <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>
)}
{hasFinancial && (
<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>
} }
/> />
<div className="px-8 py-6 space-y-6 max-w-7xl"> <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 && ( {!isLoading && available && (
@@ -219,50 +102,22 @@ export function Financials() {
const TIcon = TABLE_ICON[key] ?? Database const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0 const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待") // 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
const lsTime = lastSync[key]
return ( return (
<div <div
key={key} key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${ className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing hasData
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface' ? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20' : 'border-dashed border-border/60 bg-elevated/20'
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5"> <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> <span className="text-xs font-medium text-foreground">{label}</span>
</div> </div>
{hasFinancial ? (
<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 className="h-3.5 w-3.5" />
)}
</div> </div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground"> <div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)} {fmtBigNum(info?.rows ?? 0)}
@@ -271,12 +126,6 @@ export function Financials() {
<div className="text-[11px] text-muted mt-0.5"> <div className="text-[11px] text-muted mt-0.5">
{fmtBigNum(info?.symbols ?? 0)} {fmtBigNum(info?.symbols ?? 0)}
</div> </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> </div>
) )
})} })}
+4 -98
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Activity, Loader2, Lock, RefreshCw, Search } from 'lucide-react' import { Activity, Search } from 'lucide-react'
import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api' import { api, type IndexInstrument, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { useCapabilities } from '@/lib/useSharedQueries'
import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick' import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick'
import { EChartsIntraday } from '@/components/EChartsIntraday'
function defaultRange() { function defaultRange() {
const now = new Date() const now = new Date()
@@ -56,7 +55,6 @@ function pinnedRank(item: IndexInstrument) {
} }
export function Indices() { export function Indices() {
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const symbolParam = searchParams.get('symbol') ?? '' const symbolParam = searchParams.get('symbol') ?? ''
@@ -65,10 +63,6 @@ export function Indices() {
const [selectedDate, setSelectedDate] = useState<string | null>(null) const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [linkedPrice, setLinkedPrice] = useState<number | 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({ const list = useQuery({
queryKey: QK.indexList, queryKey: QK.indexList,
queryFn: api.indexList, queryFn: api.indexList,
@@ -109,38 +103,8 @@ export function Indices() {
placeholderData: (prev) => prev, 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 chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info 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(() => { useEffect(() => {
setSelectedDate(null) setSelectedDate(null)
setLinkedPrice(null) setLinkedPrice(null)
@@ -170,27 +134,6 @@ export function Indices() {
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div> <div>
<h1 className="text-lg font-semibold text-foreground"></h1> <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>
</div> </div>
@@ -229,9 +172,6 @@ export function Indices() {
</h2> </h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>} {selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
</div> </div>
<div className="mt-1 text-xs text-muted">
K来源 {daily.data?.source ?? '--'}
</div>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<input <input
@@ -258,8 +198,6 @@ export function Indices() {
</div> </div>
)} )}
{chartRows.length > 0 && ( {chartRows.length > 0 && (
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<EChartsCandlestick <EChartsCandlestick
data={chartRows} data={chartRows}
height={620} height={620}
@@ -272,38 +210,6 @@ export function Indices() {
visibleBars={48} visibleBars={48}
activeIndicators={['vol', 'macd']} 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>
)} )}
</main> </main>
</div> </div>
+16 -77
View File
@@ -1,19 +1,18 @@
import { useMemo, useState, type ReactNode } from 'react' 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 { import {
Activity, Activity,
Crown, Crown,
Layers3, Layers3,
RefreshCw, RefreshCw,
Search, Search,
Settings2,
TrendingDown, TrendingDown,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState' 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 { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { api, type MarketSnapshotRow } from '@/lib/api' import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
@@ -68,9 +67,7 @@ interface IndustryStat {
function loadConfig(): AnalysisFieldConfig { function loadConfig(): AnalysisFieldConfig {
return storage.industryAnalysisConfig.get({}) as 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() { export function IndustryAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig) const [fieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat') const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null) const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('') const [previewName, setPreviewName] = useState<string>('')
const [asOf, setAsOf] = useState('')
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? [] const availableConfigs = configsQuery.data?.items ?? []
@@ -285,29 +282,14 @@ export function IndustryAnalysis() {
const activeConfig = availableConfigs.find(c => c.id === activeConfigId) const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({ const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT), queryKey: QK.extDataRows(activeConfigId, asOf || undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }), queryFn: () => api.extDataRows(activeConfigId, { date: asOf || undefined, limit: PAGE_LIMIT }),
enabled: !!activeConfigId, 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({ const marketQuery = useQuery({
queryKey: QK.marketSnapshot, queryKey: ['market-snapshot', asOf],
queryFn: api.marketSnapshot, queryFn: () => api.marketSnapshot(asOf || undefined),
staleTime: 60_000, staleTime: 60_000,
}) })
@@ -364,12 +346,6 @@ export function IndustryAnalysis() {
return map return map
}, [marketMap]) }, [marketMap])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) { if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div> 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 ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<PageHeader <PageHeader title="行业分析" />
title="行业分析" <EmptyState icon={Layers3} title="暂无行业数据" hint="请通过 local→serve 同步行业数据" />
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()}
/>
</div> </div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
</> </>
) )
} }
@@ -408,21 +369,9 @@ export function IndustryAnalysis() {
<> <>
<PageHeader <PageHeader
title="行业分析" 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={ right={
<div className="flex items-center gap-1"> <DatePicker value={asOf} onChange={setAsOf} className="w-28" />
<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>
} }
/> />
@@ -464,23 +413,13 @@ export function IndustryAnalysis() {
</div> </div>
) : rowsQuery.isLoading ? ( ) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div> <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 || '请检查扩展数据是否包含行业/板块相关字段'} /> <EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)} )}
</div> </div>
</div> </div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
{previewSymbol && ( {previewSymbol && (
<StockPreviewDialog <StockPreviewDialog
+1 -19
View File
@@ -11,7 +11,6 @@ import { fmtPct, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' import { useCapabilities, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import type { ExtColumnDisplayConfig } from '@/lib/list-columns' import type { ExtColumnDisplayConfig } from '@/lib/list-columns'
// ===== Ext 字段配置 ===== // ===== Ext 字段配置 =====
@@ -1440,7 +1439,7 @@ export function LimitUpLadder() {
const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields]) const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields])
const { data, isLoading, refetch, isFetching } = useQuery({ const { data, isLoading } = useQuery({
queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction], queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction],
queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction), queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
@@ -1478,16 +1477,6 @@ export function LimitUpLadder() {
title={direction === 'down' ? '连跌梯队' : '连板梯队'} title={direction === 'down' ? '连跌梯队' : '连板梯队'}
titleExtra={ titleExtra={
<div className="flex items-center gap-2"> <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"> <div className="flex items-center rounded-full bg-elevated/60 p-0.5">
<button <button
@@ -1606,13 +1595,6 @@ export function LimitUpLadder() {
> >
<Settings2 className="h-3.5 w-3.5" /> <Settings2 className="h-3.5 w-3.5" />
</button> </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> </div>
} }
/> />
+10 -202
View File
@@ -9,20 +9,20 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion' import { motion } from 'framer-motion'
import { import {
BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle, BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
Database, Wand2, Copy, Download, Clock, X, Check, Database, Wand2, Copy, Download,
} from 'lucide-react' } from 'lucide-react'
import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api' import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
import { QK } from '@/lib/queryKeys' import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { fmtBigNum } from '@/lib/format' import { fmtBigNum } from '@/lib/format'
import { DatePicker } from '@/components/DatePicker'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
import { toast } from '@/components/Toast' import { toast } from '@/components/Toast'
import { usePreferences } from '@/lib/useSharedQueries'
import { useReviewState } from '@/lib/useReviewStore' import { useReviewState } from '@/lib/useReviewStore'
import { import {
startReviewGeneration, resetReview, isReviewGenerating, startReviewGeneration, resetReview, isReviewGenerating,
@@ -68,8 +68,7 @@ function fmtArchivedAt(iso: string): string {
export function Review() { export function Review() {
const qc = useQueryClient() const qc = useQueryClient()
// 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState) const [asOf, setAsOf] = useState('')
const asOf: string | undefined = undefined
const [focus, setFocus] = useState('') const [focus, setFocus] = useState('')
// 生成状态走全局 store:切走页面流不中断,回来可恢复 // 生成状态走全局 store:切走页面流不中断,回来可恢复
const { phase, content, error, meta } = useReviewState() const { phase, content, error, meta } = useReviewState()
@@ -78,8 +77,8 @@ export function Review() {
// 看板数据(与总览页同源) // 看板数据(与总览页同源)
const marketQuery = useQuery<OverviewMarket>({ const marketQuery = useQuery<OverviewMarket>({
queryKey: QK.overviewMarket(asOf), queryKey: QK.overviewMarket(asOf || undefined),
queryFn: () => api.overviewMarket(asOf), queryFn: () => api.overviewMarket(asOf || undefined),
staleTime: 5_000, staleTime: 5_000,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
}) })
@@ -99,45 +98,8 @@ export function Review() {
onError: () => { /* request() 已 toast */ }, 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 时) // 自动滚动到报告底部(streaming 时)
useEffect(() => { useEffect(() => {
@@ -176,7 +138,7 @@ export function Review() {
if (isReviewGenerating()) return if (isReviewGenerating()) return
setViewing(null) setViewing(null)
resetReview() resetReview()
startReviewGeneration(asOf, focus, (full, doneMeta) => { startReviewGeneration(asOf || undefined, focus, (full, doneMeta) => {
onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ }) onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ })
}) })
}, [asOf, focus, onGenerationDone]) }, [asOf, focus, onGenerationDone])
@@ -228,6 +190,7 @@ export function Review() {
subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`} subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
right={ right={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<DatePicker value={asOf} onChange={setAsOf} className="w-28" />
<button <button
onClick={() => { marketQuery.refetch() }} onClick={() => { marketQuery.refetch() }}
disabled={marketQuery.isFetching} disabled={marketQuery.isFetching}
@@ -236,18 +199,6 @@ export function Review() {
> >
<RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} /> <RefreshCw className={cn('h-3 w-3', marketQuery.isFetching && 'animate-spin')} />
</button> </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 <button
onClick={generate} onClick={generate}
disabled={isGenerating} disabled={isGenerating}
@@ -344,149 +295,6 @@ export function Review() {
</div> </div>
</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>
</> </>
) )
} }
+228
View File
@@ -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>
</>
)
}
+2 -6
View File
@@ -19,7 +19,6 @@ const CUSTOM_CODEX_MODEL = '__custom__'
const CODEX_COMMAND = 'codex' const CODEX_COMMAND = 'codex'
const CODEX_MODEL_OPTIONS = [ const CODEX_MODEL_OPTIONS = [
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' }, { label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
{ label: 'gpt-5', value: 'gpt-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: '通义千问', 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: '智谱 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: '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() { export function SettingsAIPanel() {
@@ -226,7 +223,7 @@ export function SettingsAIPanel() {
icon={Settings2} icon={Settings2}
title="自定义配置" title="自定义配置"
right={ 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> <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' : '接口'} {isCodexProvider ? 'CLI' : '接口'}
</span> </span>
@@ -236,7 +233,7 @@ export function SettingsAIPanel() {
{isCodexProvider ? ( {isCodexProvider ? (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。"> <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} {CODEX_COMMAND}
</div> </div>
</Field> </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"> <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> <h3 className="text-sm font-medium text-foreground mb-2"> AI </h3>
<p className="text-xs text-secondary mb-5 leading-relaxed"> <p className="text-xs text-secondary mb-5 leading-relaxed">
providerAPI KeyAPI Codex CLI
</p> </p>
<div className="flex items-center justify-end gap-2"> <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"> <button onClick={() => setConfirmClear(false)} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
+5 -39
View File
@@ -2,7 +2,6 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout' import { Layout } from './components/Layout'
import { Screener } from './pages/Screener' import { Screener } from './pages/Screener'
import { Financials } from './pages/Financials' import { Financials } from './pages/Financials'
import { Onboarding } from './pages/Onboarding'
import { Auth } from './pages/Auth' import { Auth } from './pages/Auth'
import { Data } from './pages/Data' import { Data } from './pages/Data'
import { Monitor } from './pages/Monitor' import { Monitor } from './pages/Monitor'
@@ -14,50 +13,16 @@ import { StockAnalysis } from './pages/StockAnalysis'
import { Review } from './pages/Review' import { Review } from './pages/Review'
import { LimitUpLadder } from './pages/LimitUpLadder' import { LimitUpLadder } from './pages/LimitUpLadder'
import { Branding } from './pages/Branding' import { Branding } from './pages/Branding'
import { AiSettings } from './pages/AiSettings'
import { UserManage } from './pages/UserManage'
import { Settings } from './pages/Settings' import { Settings } from './pages/Settings'
import { Indices } from './pages/Indices' import { Indices } from './pages/Indices'
import { Dev } from './pages/Dev' 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([ export const router = createBrowserRouter([
{ path: '/onboarding', element: <Onboarding /> },
{ path: '/login', element: <Auth /> }, { path: '/login', element: <Auth /> },
{ {
path: '/', path: '/',
element: ( element: <Layout />,
<OnboardingGuard>
<Layout />
</OnboardingGuard>
),
children: [ children: [
{ index: true, element: <Dashboard /> }, { index: true, element: <Dashboard /> },
{ path: 'overview', element: <Navigate to="/" replace /> }, { path: 'overview', element: <Navigate to="/" replace /> },
@@ -79,7 +44,8 @@ export const router = createBrowserRouter([
{ path: 'dev', element: <Dev /> }, { path: 'dev', element: <Dev /> },
// 旧路由兼容重定向 // 旧路由兼容重定向
{ path: 'settings/keys', element: <Navigate to="/settings?tab=account" replace /> }, { 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 /> }, { path: 'settings/queries', element: <Navigate to="/settings?tab=queries" replace /> },
], ],
}, },