From 2015ef0c78da3379f5d6fe6bc84d5a0314919c9a Mon Sep 17 00:00:00 2001
From: shy3130 <415333856@qq.com>
Date: Fri, 26 Jun 2026 15:36:52 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=AA=E8=82=A1=E5=88=86=E6=9E=90(Be?=
=?UTF-8?q?ta)=20=E2=80=94=20=E4=B8=93=E7=94=A8=E6=97=A5K=20+=209=E7=B1=BB?=
=?UTF-8?q?=E5=85=B3=E9=94=AE=E4=BB=B7=E4=BD=8D=20+=20AI=E5=9B=9B=E7=BB=B4?=
=?UTF-8?q?=E5=88=86=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增个股分析页,以行情与关键价位为视觉主体,与财务分析(财务质量评级)定位互补:
后端:
- indicators/levels.py: 9 类关键价位纯函数计算
· 压力支撑(布林带)/ 成交密集区(POC)/ 枢轴点(可配档位)/ 前高前低
· Keltner通道(MA±n×ATR 短/中/长)/ ATR止损(close±nATR)
· 缺口位(未回补跳空)/ 斐波那契回撤 / 整数关口(自适应步长)
- services/stock_analyzer.py: AI 四维(技术/基本面/财务/消息面)流式分析,NDJSON
- services/stock_reports.py: 报告持久化(最多50条),独立 JSON 存储
- api/stock_analysis.py: levels/analyze/reports 端点
前端:
- AnalysisKChart: 专用 ECharts 日K(主图+成交量+滑块),9 类价位开关
- 蓝色胶囊(Bubble/Host/Dialog),与财务分析紫色主题并存
- stockAnalysisStore: useSyncExternalStore 全局存储,后台流式
- useLastStock + LastStockChip: 记忆最近查看个股(财务/个股分析页)
其他优化:
- AI 设置页:一键清空配置(保留UA) + 保存后乐观更新连接状态/菜单
- MarkdownRenderer: 去除 space-y-0 遮蔽,各块间距与分隔线可见性优化
- 个股分析页 Beta 标识(标题 + 菜单)
- 版本号 0.1.45 → 0.1.50
---
README.md | 26 +-
backend/app/__init__.py | 2 +-
backend/app/api/settings.py | 18 +
backend/app/api/stock_analysis.py | 133 ++++
backend/app/indicators/levels.py | 580 ++++++++++++++++++
backend/app/main.py | 3 +-
backend/app/services/stock_analyzer.py | 337 ++++++++++
backend/app/services/stock_reports.py | 91 +++
backend/pyproject.toml | 2 +-
frontend/package.json | 2 +-
frontend/src/components/LastStockChip.tsx | 31 +
frontend/src/components/Layout.tsx | 10 +
.../financials/MarkdownRenderer.tsx | 22 +-
.../stock-analysis/AnalysisKChart.tsx | 447 ++++++++++++++
.../stock-analysis/StockAnalysisBubble.tsx | 207 +++++++
.../stock-analysis/StockAnalysisDialog.tsx | 256 ++++++++
.../stock-analysis/StockAnalysisHost.tsx | 12 +
frontend/src/lib/api.ts | 102 +++
frontend/src/lib/queryKeys.ts | 1 +
frontend/src/lib/stockAnalysisStore.ts | 268 ++++++++
frontend/src/lib/useLastStock.ts | 52 ++
frontend/src/pages/Financials.tsx | 12 +-
frontend/src/pages/StockAnalysis.tsx | 290 ++++++++-
frontend/src/pages/settings/AI.tsx | 85 ++-
24 files changed, 2959 insertions(+), 30 deletions(-)
create mode 100644 backend/app/api/stock_analysis.py
create mode 100644 backend/app/indicators/levels.py
create mode 100644 backend/app/services/stock_analyzer.py
create mode 100644 backend/app/services/stock_reports.py
create mode 100644 frontend/src/components/LastStockChip.tsx
create mode 100644 frontend/src/components/stock-analysis/AnalysisKChart.tsx
create mode 100644 frontend/src/components/stock-analysis/StockAnalysisBubble.tsx
create mode 100644 frontend/src/components/stock-analysis/StockAnalysisDialog.tsx
create mode 100644 frontend/src/components/stock-analysis/StockAnalysisHost.tsx
create mode 100644 frontend/src/lib/stockAnalysisStore.ts
create mode 100644 frontend/src/lib/useLastStock.ts
diff --git a/README.md b/README.md
index 9deee2a..cb658dd 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
| 配置项 | 说明 | 是否必填 |
| :--- | :--- | :--- |
| **TickFlow API Key** | 数据源凭证,留空启用 None 模式,获取免费key后开启free模式可定制策略+回测 | 可选 |
-| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中)等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
+| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
@@ -143,6 +143,29 @@
- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达
- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能
+### 📈 个股分析(Beta)
+
+**以「行情 + 关键价位」为视觉主体的单标的决策页**,与「财务分析」(财务质量评级)定位互补:
+
+- **专用日 K 图表**:不复用行情浏览图表,主图 + 成交量 + 滑块三段布局,默认展示近 6 个月,9 类关键价位可逐组开关
+- **9 类关键价位**(均纯函数实时计算,毫秒级):
+
+| 类型 | 算法 | 说明 |
+| :--- | :--- | :--- |
+| 压力支撑 | 布林带上下轨 | 近期波动边界 |
+| 成交密集区 | 成交量分布 POC + 高成交带 | 筹码密集价位 |
+| 枢轴点 | 经典 Pivot P/R1~R3/S1~S3 | 可配档位(1~3 档) |
+| 前高前低 | 60/250 日极值 + swing 高低点 | 历史转折参照 |
+| Keltner 通道 | MA20/60/120 ± n×ATR(短/中/长) | 波动自适应趋势边界 |
+| ATR 止损 | close ± 1.5/2×ATR | 动态止盈止损位 |
+| 缺口位 | 近 120 日未回补跳空缺口 | 天然支撑/阻力 |
+| 斐波那契 | 近期波段 0.236~0.786 回撤 | 经典回撤位 |
+| 整数关口 | 当前价附近心理整数位 | 自适应步长 |
+
+- **AI 四维分析**:技术面 / 基本面 / 财务面 / 消息面流式生成,NDJSON 推送,「实战派交易员」视角输出买卖区间与操作建议
+- **蓝色胶囊**:与财务分析(紫色)并存的全局气泡,支持最小化后台生成、复制全文、历史报告(最多 50 条)
+- **记忆最近查看**:进入页面自动回显上次查看的个股
+
### 🧰 数据与扩展
- **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情
@@ -317,6 +340,7 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录
| **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ |
| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ |
+| **6** | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析 + 报告持久化) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index b2c6a87..f8df33d 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -2,7 +2,7 @@
import sys
-__version__ = "0.1.45"
+__version__ = "0.1.50"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 1e0700a..4157fbe 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -246,6 +246,24 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
return {"ok": True}
+@router.delete("/ai")
+def clear_ai_settings() -> dict:
+ """一键清空 AI 配置(provider / base_url / api_key / model)。
+
+ 保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
+ """
+ from app.config import settings
+
+ secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model")
+ # 同步重置运行时内存(provider 回默认值,其余置空)
+ settings.ai_provider = "openai_compat"
+ settings.ai_base_url = ""
+ settings.ai_api_key = ""
+ settings.ai_model = ""
+
+ return {"ok": True}
+
+
# ===== 偏好设置 =====
def _realtime_allowed() -> bool:
diff --git a/backend/app/api/stock_analysis.py b/backend/app/api/stock_analysis.py
new file mode 100644
index 0000000..d6791a5
--- /dev/null
+++ b/backend/app/api/stock_analysis.py
@@ -0,0 +1,133 @@
+"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
+
+路由前缀: /api/stock-analysis
+
+端点:
+ GET /levels?symbol= 4 类关键价位(图表 markLine 数据源)
+ POST /analyze AI 流式四维分析(NDJSON)
+ GET /reports 历史报告列表
+ POST /reports 保存一条报告
+ DELETE /reports/{report_id} 删除一条报告
+"""
+from __future__ import annotations
+
+import logging
+from datetime import date, timedelta
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from app.indicators.levels import compute_levels, summarize_levels
+from app.services import stock_reports
+from app.services.stock_analyzer import analyze_stock_stream
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
+
+
+@router.get("/levels")
+def get_levels(
+ request: Request,
+ symbol: str = Query(..., description="标的代码,如 000001.SZ"),
+ days: int = Query(120, ge=30, le=500, description="计算样本天数"),
+):
+ """计算 4 类关键价位(压力支撑 / 成交密集区 / 枢轴点 / 前高前低)。
+
+ 返回 {levels: {sr, profile, pivot, extreme}, close, summary}。
+ 前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine。
+ """
+ if not symbol:
+ raise HTTPException(400, "symbol 不能为空")
+
+ repo = request.app.state.repo
+ end = date.today()
+ start = end - timedelta(days=days * 2)
+ df = repo.get_daily(symbol, start, end)
+ if df.is_empty():
+ return {"levels": {"sr": [], "profile": [], "pivot": [], "extreme": [],
+ "keltner": [], "atr_stop": [], "gap": [], "fib": [], "round": []},
+ "close": None, "summary": "无数据", "symbol": symbol}
+
+ levels = compute_levels(df)
+ close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
+ return {
+ "levels": levels,
+ "close": close,
+ "summary": summarize_levels(levels, close),
+ "symbol": symbol,
+ }
+
+
+class AnalyzeRequest(BaseModel):
+ """AI 个股分析请求。"""
+ symbol: str
+ focus: str = "" # 可选:用户追加的分析关注点
+
+
+@router.post("/analyze")
+async def analyze_stock(request: Request, req: AnalyzeRequest):
+ """AI 个股四维分析 — NDJSON 流式返回。
+
+ 组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
+ 流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
+ """
+ if not req.symbol:
+ raise HTTPException(400, "symbol 不能为空")
+
+ repo = request.app.state.repo
+ data_dir = repo.store.data_dir
+
+ async def stream_gen():
+ async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
+ yield chunk + "\n"
+
+ return StreamingResponse(
+ stream_gen(),
+ media_type="application/x-ndjson",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+# ================================================================
+# 报告 CRUD(历史报告持久化)
+# ================================================================
+
+class SaveReportRequest(BaseModel):
+ """保存一条 AI 个股分析报告。"""
+ symbol: str
+ name: str = ""
+ focus: str = ""
+ content: str
+ summary: str = ""
+ close: float | None = None
+ levels: dict | None = None
+
+
+@router.get("/reports")
+def list_reports(request: Request):
+ """获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
+ return {"reports": stock_reports.list_reports()}
+
+
+@router.post("/reports")
+def save_report(request: Request, req: SaveReportRequest):
+ """保存一条报告。"""
+ report = stock_reports.save_report({
+ "symbol": req.symbol,
+ "name": req.name,
+ "focus": req.focus,
+ "content": req.content,
+ "summary": req.summary,
+ "close": req.close,
+ "levels": req.levels,
+ })
+ return {"ok": True, "report": report}
+
+
+@router.delete("/reports/{report_id}")
+def delete_report(request: Request, report_id: str):
+ """删除一条报告。"""
+ ok = stock_reports.delete_report(report_id)
+ return {"ok": ok}
diff --git a/backend/app/indicators/levels.py b/backend/app/indicators/levels.py
new file mode 100644
index 0000000..4af1bdd
--- /dev/null
+++ b/backend/app/indicators/levels.py
@@ -0,0 +1,580 @@
+"""关键价位计算 —— 独立模块,纯函数,无 IO / 无存储。
+
+输入: 已经包含 OHLCV 的 polars 日 K DataFrame(内存中,通常来自 KlineRepository 缓存)。
+输出: 4 类结构化价位点,供:
+ - 图表 markLine 渲染(压力位 / 支撑位 / 成交密集区 / 枢轴点 / 前高前低)
+ - AI 个股分析提示词(价位上下文)
+
+设计:
+ - 纯函数 + polars 向量化,毫秒级,无需落盘。
+ - 每个点位带 {value, label, type, side, strength?},前端直接画水平价格线。
+ - NaN/Inf 全部过滤,空数据返回空列表,不抛异常。
+"""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import polars as pl
+
+logger = logging.getLogger(__name__)
+
+
+# ================================================================
+# 输出结构
+# ================================================================
+
+class PriceLevel:
+ """单个价位点的数据结构(用 dict 表达,这里只作文档说明)。
+
+ {
+ "value": 12.34, # 价格
+ "label": "压力位 R1", # 显示标签
+ "type": "pivot", # 类型分组(同类型用一个开关按钮控制显隐)
+ "side": "resistance", # 方向:resistance(压力) / support(支撑) / neutral
+ "strength": "medium", # 强度:strong / medium / weak(可选,影响线型)
+ "rank": 1, # 档位(仅 pivot 有):0=P,1=R1/S1,2=R2/S2,3=R3/S3
+ # 前端按"显示到第几档"过滤,非 pivot 点位无此字段
+ }
+ """
+
+
+# 价位分组 → 开关 key。前端按这个 type 显隐。
+LEVEL_TYPES = {
+ "sr": "压力支撑", # 布林带 + swing 高低点
+ "profile": "成交密集区", # 成交量分布 POC + 密集区
+ "pivot": "枢轴点", # 经典 Pivot P/R/S
+ "extreme": "前高前低", # 60/120/250 日极值
+ "keltner": "Keltner通道", # 短/中/长三档 MA±n×ATR
+ "atr_stop": "ATR止损", # close±nATR 动态止盈止损
+ "gap": "缺口位", # 未回补跳空缺口
+ "fib": "斐波那契", # 回撤位 0.236~0.786
+ "round": "整数关口", # 心理整数位
+}
+
+
+# ================================================================
+# 1. 压力位 / 支撑位 —— 布林带上下轨 + 局部 swing 高低点
+# ================================================================
+
+def _support_resistance(df: pl.DataFrame) -> list[dict]:
+ """布林带上下轨(压力/支撑带)。
+
+ 本组只放"通道型"压力支撑 —— 即布林带的上下轨,代表近期波动边界。
+ 局部 swing 高低点 / 前高前低 等点位归到 extreme 组,避免与本组重叠。
+ """
+ if df.is_empty() or df.height < 20:
+ return []
+
+ out: list[dict] = []
+ last = df.tail(1)
+
+ if "boll_upper" in df.columns:
+ bu = last["boll_upper"][0]
+ if _ok(bu):
+ out.append({"value": round(float(bu), 2), "label": "压力位(布林上轨)",
+ "type": "sr", "side": "resistance", "strength": "medium"})
+ if "boll_lower" in df.columns:
+ bl = last["boll_lower"][0]
+ if _ok(bl):
+ out.append({"value": round(float(bl), 2), "label": "支撑位(布林下轨)",
+ "type": "sr", "side": "support", "strength": "medium"})
+
+ return out
+
+
+# ================================================================
+# 2. 成交密集区 —— 成交量分布 (Volume Profile)
+# ================================================================
+
+def _volume_profile(df: pl.DataFrame, bins: int = 40) -> list[dict]:
+ """按价格分桶统计成交量,找 POC(控制点)+ 高成交密集区。
+
+ 密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带。
+ """
+ if df.is_empty() or "volume" not in df.columns or df.height < 20:
+ return []
+
+ hi = float(df["high"].max())
+ lo = float(df["low"].min())
+ if not (hi > lo > 0):
+ return []
+
+ # 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型)
+ df2 = df.select([
+ ((pl.col("high") + pl.col("low")) / 2).alias("mid"),
+ pl.col("volume").alias("vol"),
+ ]).drop_nulls()
+
+ # 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段
+ step = (hi - lo) / bins
+ edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值
+ breaks = edges[1:-1] # 内部 break,bins-1 个
+ bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1
+ # 至少要有 1 个不同的内部 break
+ if len(set(f"{b:.6f}" for b in breaks)) < 1:
+ return []
+
+ df2 = df2.with_columns(
+ pl.col("mid").cut(breaks, labels=bin_labels).alias("bin")
+ )
+ prof = df2.group_by("bin").agg(pl.col("vol").sum())
+ if prof.is_empty():
+ return []
+
+ # 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引
+ prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi")
+ bin_ids = prof["bi"].to_list()
+ vols = prof["vol"].to_list()
+ mean_vol = sum(vols) / len(vols) if vols else 0
+
+ def bin_mid(bin_id: int) -> float:
+ return (edges[bin_id] + edges[bin_id + 1]) / 2
+
+ close = float(df.tail(1)["close"][0])
+
+ out: list[dict] = []
+ # POC:成交量最大的桶
+ poc_pos = max(range(len(vols)), key=lambda i: vols[i])
+ poc_mid = bin_mid(bin_ids[poc_pos])
+ out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)",
+ "type": "profile", "side": _side(poc_mid, close), "strength": "strong"})
+
+ # 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个
+ candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos]
+ candidates.sort(key=lambda x: x[1], reverse=True)
+ for i, _v in candidates[:2]:
+ mid = bin_mid(bin_ids[i])
+ out.append({"value": round(mid, 2), "label": "成交密集区",
+ "type": "profile", "side": _side(mid, close), "strength": "medium"})
+ return out
+
+
+# ================================================================
+# 3. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日
+# ================================================================
+
+def _pivot_points(df: pl.DataFrame) -> list[dict]:
+ """经典 Pivot:P = (H+L+C)/3, R1/R2/R3, S1/S2/S3。
+
+ 基准:最后 1 根 K(代表"上一交易日")。实务中常用前一日,这里取最后一根。
+ """
+ if df.is_empty():
+ return []
+ last = df.tail(1)
+ h = last["high"][0]
+ l = last["low"][0]
+ c = last["close"][0]
+ if not _ok(h) or not _ok(l) or not _ok(c):
+ return []
+
+ h, l, c = float(h), float(l), float(c)
+ p = (h + l + c) / 3
+ r1 = 2 * p - l
+ s1 = 2 * p - h
+ r2 = p + (h - l)
+ s2 = p - (h - l)
+ r3 = h + 2 * (p - l)
+ s3 = l - 2 * (h - p)
+
+ def lv(v: float, label: str, side: str, strength: str, rank: int) -> dict:
+ # rank:档位标记,前端据此按"显示到第几档"过滤
+ # 0 = 枢轴位 P(始终显示)
+ # 1 = R1/S1(第一档压力/支撑)
+ # 2 = R2/S2(第二档)
+ # 3 = R3/S3(第三档,极端,实际很少触及)
+ return {"value": round(v, 2), "label": label, "type": "pivot",
+ "side": side, "strength": strength, "rank": rank}
+
+ return [
+ lv(p, "枢轴位 P", "neutral", "strong", 0),
+ lv(r1, "压力位 R1", "resistance", "medium", 1),
+ lv(r2, "压力位 R2", "resistance", "medium", 2),
+ lv(r3, "压力位 R3", "resistance", "weak", 3),
+ lv(s1, "支撑位 S1", "support", "medium", 1),
+ lv(s2, "支撑位 S2", "support", "medium", 2),
+ lv(s3, "支撑位 S3", "support", "weak", 3),
+ ]
+
+
+# ================================================================
+# 4. 前高 / 前低 —— 60 / 120 / 250 日极值
+# ================================================================
+
+def _extreme_levels(df: pl.DataFrame) -> list[dict]:
+ """关键前高 / 前低 —— 历史极值 + 近期 swing 高低点(收敛后)。
+
+ 设计:把所有"前高前低"类点位集中在本组,与 sr(通道)区分:
+ - 60 日极值:近一季度高低点(短期参照)
+ - 250 日极值:年度高低点(牛熊分界参照);跳过 120 日(被 250 日包含,信息冗余)
+ - swing 高低点:近期局部转折点,每侧只取距当前价最近的 2 个
+ """
+ if df.is_empty():
+ return []
+ close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
+ out: list[dict] = []
+
+ # —— 历史极值(只取 60 / 250,避免中间档冗余)——
+ for n in (60, 250):
+ if df.height < n:
+ continue
+ sub = df.tail(n)
+ hi = float(sub["high"].max())
+ lo = float(sub["low"].min())
+ if _ok(hi):
+ out.append({"value": round(hi, 2), "label": f"{n}日新高",
+ "type": "extreme", "side": "resistance", "strength": "strong"})
+ if _ok(lo):
+ out.append({"value": round(lo, 2), "label": f"{n}日新低",
+ "type": "extreme", "side": "support", "strength": "strong"})
+
+ # —— 近期 swing 高低点(每侧只取距当前价最近的 2 个,避免点位爆炸)——
+ win = 5
+ if df.height > win * 2 and close:
+ highs = df["high"].to_list()
+ lows = df["low"].to_list()
+ swing_highs: list[float] = []
+ swing_lows: list[float] = []
+ for i in range(win, len(highs) - win):
+ if highs[i] == max(highs[i - win:i + win + 1]):
+ swing_highs.append(float(highs[i]))
+ if lows[i] == min(lows[i - win:i + win + 1]):
+ swing_lows.append(float(lows[i]))
+
+ # 聚合 ±1% 相近价位,再按距当前价排序取最近 2 个
+ agg_h = _aggregate_levels(swing_highs, 0.01)
+ agg_h = [v for v in agg_h if v > close * 1.001]
+ agg_h.sort(key=lambda v: abs(v - close))
+ for v in agg_h[:2]:
+ out.append({"value": round(v, 2), "label": "前高",
+ "type": "extreme", "side": "resistance", "strength": "medium"})
+
+ agg_l = _aggregate_levels(swing_lows, 0.01)
+ agg_l = [v for v in agg_l if v < close * 0.999]
+ agg_l.sort(key=lambda v: abs(v - close))
+ for v in agg_l[:2]:
+ out.append({"value": round(v, 2), "label": "前低",
+ "type": "extreme", "side": "support", "strength": "medium"})
+
+ return out
+
+
+# ================================================================
+# 5. Keltner 通道 —— MA ± n × ATR,短/中/长三档
+# ================================================================
+
+def _keltner_channels(df: pl.DataFrame) -> list[dict]:
+ """三档 Keltner 通道(波动自适应边界)。
+
+ 基于 ATR 的通道:均线 ± n×ATR。ATR 自适应波动,通道宽度随行情自动收缩/扩张,
+ 比布林带(基于标准差)更稳定,实战常用作趋势边界与突破参照。
+
+ 三档:
+ - 短期:MA20 ± 2×ATR (近期波动带,约一个月)
+ - 中期:MA60 ± 2.5×ATR (季度波动带)
+ - 长期:MA120 ± 3×ATR (半年波动带,牛熊趋势边界)
+ """
+ if df.is_empty() or df.height < 20:
+ return []
+ out: list[dict] = []
+ last = df.tail(1)
+ close = float(last["close"][0]) if "close" in df.columns else 0
+ if not close:
+ return []
+
+ # ATR 列由 compute_indicators 生成(atr_14);缺失则跳过
+ if "atr_14" not in df.columns:
+ return []
+ atr = float(last["atr_14"][0])
+ if not _ok(atr):
+ return []
+
+ def _band(ma_col: str | None, n: int, label_short: str, window: int) -> None:
+ """单档通道:优先取预计算列,缺失则现场 rolling_mean。"""
+ ma_val: float | None = None
+ if ma_col and ma_col in df.columns:
+ v = last[ma_col][0]
+ ma_val = float(v) if _ok(v) else None
+ elif df.height >= window:
+ # ma120 等未预计算列:现场算
+ v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0]
+ ma_val = float(v) if _ok(v) else None
+ if ma_val is None:
+ return
+ upper = ma_val + n * atr
+ lower = ma_val - n * atr
+ out.append({"value": round(upper, 2), "label": f"{label_short}通道上轨",
+ "type": "keltner", "side": _side(upper, close), "strength": "medium"})
+ out.append({"value": round(lower, 2), "label": f"{label_short}通道下轨",
+ "type": "keltner", "side": _side(lower, close), "strength": "medium"})
+
+ _band("ma20", 2, "短期", 20)
+ _band("ma60", 2.5, "中期", 60)
+ _band(None, 3, "长期", 120) # ma120 未预计算,现场 rolling_mean
+ return out
+
+
+# ================================================================
+# 6. ATR 止损位 —— close ± n × ATR,动态止盈止损
+# ================================================================
+
+def _atr_stops(df: pl.DataFrame) -> list[dict]:
+ """基于 ATR 的动态止损/止盈位。
+
+ ATR 衡量平均真实波幅,close ± n×ATR 是交易者最常用的止损位算法:
+ - 止损位:close - 2×ATR (跌破即趋势破坏)
+ - 止盈位:close + 2×ATR (突破即顺势扩展)
+ - 近端波动带:close ± 1.5×ATR (中短期风控参考)
+ """
+ if df.is_empty() or "atr_14" not in df.columns:
+ return []
+ last = df.tail(1)
+ close = float(last["close"][0])
+ atr = float(last["atr_14"][0])
+ if not _ok(close) or not _ok(atr):
+ return []
+
+ def lv(v: float, label: str, side: str, strength: str) -> dict:
+ return {"value": round(v, 2), "label": label, "type": "atr_stop",
+ "side": side, "strength": strength}
+
+ return [
+ lv(close + 2 * atr, "ATR 止盈(+2)", "resistance", "medium"),
+ lv(close + 1.5 * atr, "ATR 上轨(+1.5)", "resistance", "weak"),
+ lv(close - 1.5 * atr, "ATR 下轨(-1.5)", "support", "weak"),
+ lv(close - 2 * atr, "ATR 止损(-2)", "support", "medium"),
+ ]
+
+
+# ================================================================
+# 7. 缺口位 (Gap) —— 未回补的跳空缺口
+# ================================================================
+
+def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
+ """近期未回补的向上/向下跳空缺口。
+
+ 向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补)
+ 向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补)
+
+ 缺口是天然的支撑/阻力位。只保留"未回补"的(后续价格未回到缺口区间内),
+ 并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。
+ """
+ if df.is_empty() or df.height < 5:
+ return []
+ sub = df.tail(lookback) if df.height > lookback else df
+ close = float(df.tail(1)["close"][0])
+ highs = sub["high"].to_list()
+ lows = sub["low"].to_list()
+
+ up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点)
+ dn_gaps: list[tuple[float, float]] = []
+ for i in range(1, len(highs)):
+ if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]):
+ if lows[i] > highs[i - 1]: # 向上缺口
+ up_gaps.append((highs[i - 1], lows[i]))
+ elif highs[i] < lows[i - 1]: # 向下缺口
+ dn_gaps.append((highs[i], lows[i - 1]))
+
+ def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]:
+ """过滤掉已被后续价格回补的缺口,取缺口价位中点。"""
+ mids: list[float] = []
+ for g_lo, g_hi in gaps:
+ # 未回补判定:当前价不在缺口区间内
+ if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方)
+ mids.append((g_lo + g_hi) / 2)
+ elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补
+ mids.append((g_lo + g_hi) / 2)
+ # 聚合相近缺口 + 按距当前价排序取最近 3 个
+ agg = _aggregate_levels(mids, 0.005)
+ agg.sort(key=lambda v: abs(v - close))
+ return agg[:3]
+
+ out: list[dict] = []
+ for mid in _filter_unfilled(up_gaps, True):
+ out.append({"value": round(mid, 2), "label": "向上缺口",
+ "type": "gap", "side": _side(mid, close), "strength": "medium"})
+ for mid in _filter_unfilled(dn_gaps, False):
+ out.append({"value": round(mid, 2), "label": "向下缺口",
+ "type": "gap", "side": _side(mid, close), "strength": "medium"})
+ return out
+
+
+# ================================================================
+# 8. 斐波那契回撤 —— 基于近期波段的回撤位
+# ================================================================
+
+def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]:
+ """基于近期一段明确趋势的斐波那契回撤位。
+
+ 取近 window 个交易日的最高/最低点:
+ - 若高点出现在低点之后(上涨波段):从低到高,回撤 = high - range × ratio
+ - 若低点出现在高点之后(下跌波段):从高到低,回撤 = low + range × ratio
+ 比率:0.236 / 0.382 / 0.5 / 0.618 / 0.786
+ """
+ if df.is_empty() or df.height < 10:
+ return []
+ sub = df.tail(window) if df.height > window else df
+ close = float(df.tail(1)["close"][0])
+
+ highs = sub["high"].to_list()
+ lows = sub["low"].to_list()
+ hi_pos = highs.index(max(highs))
+ lo_pos = lows.index(min(lows))
+ hi_val = float(highs[hi_pos])
+ lo_val = float(lows[lo_pos])
+ if not _ok(hi_val) or not _ok(lo_val) or hi_val <= lo_val:
+ return []
+
+ ratios = [0.236, 0.382, 0.5, 0.618, 0.786]
+ rng = hi_val - lo_val
+
+ out: list[dict] = []
+ # 判断波段方向:高点在低点之后 = 上涨波段(从低回撤)
+ up_trend = hi_pos > lo_pos
+ for r in ratios:
+ if up_trend:
+ val = hi_val - rng * r # 从高点向下回撤
+ else:
+ val = lo_val + rng * r # 从低点向上回撤
+ out.append({"value": round(val, 2), "label": f"Fib {int(r * 1000) / 10:.1f}%",
+ "type": "fib", "side": _side(val, close), "strength": "medium"})
+ return out
+
+
+# ================================================================
+# 9. 整数关口 —— 心理支撑/阻力位
+# ================================================================
+
+def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> list[dict]:
+ """当前价附近的心理整数关口。
+
+ 整数位(如 10/11/12元,或 60/65/70元)是天然的心理支撑/阻力,
+ 低价股尤其明显。按价格量级自适应步长:
+ - 价格 < 10: 步长 0.5 (如 6.5, 7.0, 7.5)
+ - 价格 < 20: 步长 1 (如 11, 12, 13)
+ - 价格 < 100: 步长 5 (如 60, 65, 70)
+ - 价格 < 500: 步长 10 (如 110, 120, 130)
+ - 价格 >= 500: 步长 50 (如 1100, 1150, 1200)
+ 过滤掉距当前价 <1% 的(太近,无分析价值),最多 max_count 个。
+ """
+ if df.is_empty():
+ return []
+ close = float(df.tail(1)["close"][0])
+ if not _ok(close):
+ return []
+
+ if close < 10:
+ step = 0.5
+ elif close < 20:
+ step = 1.0
+ elif close < 100:
+ step = 5.0
+ elif close < 500:
+ step = 10.0
+ else:
+ step = 50.0
+
+ lo = close * (1 - pct)
+ hi = close * (1 + pct)
+ # 找区间 [lo, hi] 内所有 step 的整数倍(严格限定在区间内)
+ start = (int(lo / step) + (1 if lo % step > 0 else 0)) * step
+ candidates: list[float] = []
+ v = start
+ while v <= hi:
+ if v > 0:
+ candidates.append(round(v, 2))
+ v += step
+
+ # 按距当前价从近到远排序,取前 max_count 个
+ candidates.sort(key=lambda x: abs(x - close))
+ out: list[dict] = []
+ for v in candidates[:max_count]:
+ # 过滤距当前价 <1% 的(太近,无分析价值)
+ if abs(v - close) / close < 0.01:
+ continue
+ out.append({"value": round(v, 2), "label": f"整数关口 {v:g}",
+ "type": "round", "side": _side(v, close), "strength": "weak"})
+ return out
+
+def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]:
+ """计算 9 类价位点,返回 {分组key: [点位...]}。
+
+ 分组 key 与 LEVEL_TYPES 一致(sr / profile / pivot / extreme /
+ keltner / atr_stop / gap / fib / round),前端按 key 渲染开关按钮,逐组显隐。
+ """
+ if df.is_empty():
+ return {k: [] for k in LEVEL_TYPES}
+
+ try:
+ return {
+ "sr": _support_resistance(df),
+ "profile": _volume_profile(df),
+ "pivot": _pivot_points(df),
+ "extreme": _extreme_levels(df),
+ "keltner": _keltner_channels(df),
+ "atr_stop": _atr_stops(df),
+ "gap": _gap_levels(df),
+ "fib": _fibonacci_levels(df),
+ "round": _round_numbers(df),
+ }
+ except Exception as e: # noqa: BLE001
+ logger.warning("compute_levels failed: %s", e)
+ return {k: [] for k in LEVEL_TYPES}
+
+
+def summarize_levels(levels: dict[str, list[dict]], close: float | None) -> str:
+ """生成给 AI 提示词的价位摘要文本(紧凑,供上下文)。"""
+ if not close:
+ return "无价位数据"
+ parts: list[str] = []
+ # 当前价
+ parts.append(f"当前价 {close:.2f}")
+ # 每组取前 2 个最相关的(距当前价近的优先)
+ for key, label in LEVEL_TYPES.items():
+ pts = levels.get(key, [])
+ if not pts:
+ continue
+ # 按距当前价排序,取前 2
+ ranked = sorted(pts, key=lambda p: abs(p["value"] - close))[:2]
+ desc = "、".join(
+ f"{p['label']}={p['value']}" for p in ranked
+ )
+ parts.append(f"{label}: {desc}")
+ return " · ".join(parts)
+
+
+# ================================================================
+# 内部工具
+# ================================================================
+
+def _ok(v: Any) -> bool:
+ """数值有效(非空/非 NaN/非 Inf/正数)。"""
+ try:
+ f = float(v)
+ except (TypeError, ValueError):
+ return False
+ import math
+ return math.isfinite(f) and f > 0
+
+
+def _side(level: float, close: float) -> str:
+ """价位相对当前价的方向。"""
+ if level > close * 1.001:
+ return "resistance"
+ if level < close * 0.999:
+ return "support"
+ return "neutral"
+
+
+def _aggregate_levels(values: list[float], tol: float) -> list[float]:
+ """把相近的价位聚合(±tol),返回去重后的代表值(保留最新)。"""
+ if not values:
+ return []
+ values = sorted(values)
+ out: list[float] = [values[0]]
+ for v in values[1:]:
+ if abs(v - out[-1]) / out[-1] <= tol:
+ out[-1] = v # 聚合到最新(更近期)
+ else:
+ out.append(v)
+ return out
diff --git a/backend/app/main.py b/backend/app/main.py
index e9dc5c0..f8fc073 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
-from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
+from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
@@ -192,6 +192,7 @@ app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(ext_data.router)
app.include_router(financials.router)
+app.include_router(stock_analysis.router)
app.include_router(settings_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py
new file mode 100644
index 0000000..7538303
--- /dev/null
+++ b/backend/app/services/stock_analyzer.py
@@ -0,0 +1,337 @@
+"""AI 个股分析服务 — 技术面 / 基本面 / 财务面 / 消息面 四维综合分析。
+
+职责:
+ 组合一只股票的 K 线(含已算好的技术指标)+ 财务表 + 关键价位 →
+ 拼装"实战派交易员"级系统提示词 → 流式调用 LLM → 逐 chunk 吐给前端。
+
+与 financial_analyzer.py 的区别(刻意区分,非复用):
+ - 角色:A 股实战派交易员 / 技术分析师(非 CFA 财务分析师)
+ - 数据源:K 线 + 技术指标为主,财务表为辅(财务分析以财务表为主)
+ - 输出框架:技术面→基本面→财务面→消息面(四维),落点是买卖区间与操作建议
+ (财务分析的落点是财务质量评级)
+
+不知道: HTTP、前端、配置持久化。
+"""
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import AsyncIterator
+
+import polars as pl
+
+from app.indicators.levels import compute_levels, summarize_levels
+from app.services.financial_sync import get_financial_df
+
+logger = logging.getLogger(__name__)
+
+# 注入最近多少根日 K(技术面分析样本)
+_KLINE_WINDOW = 90
+# 注入财务表的最近期数
+_MAX_PERIODS = 4
+
+
+# ================================================================
+# 数据加载
+# ================================================================
+
+def _load_kline(repo, symbol: str) -> pl.DataFrame:
+ """读取该标的最近 N 根日 K(已含技术指标 / 信号)。
+
+ repo: KlineRepository;走内存缓存,性能可控。
+ """
+ from datetime import date, timedelta
+
+ end = date.today()
+ start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够
+ df = repo.get_daily(symbol, start, end)
+ if df.is_empty():
+ return df
+ return df.tail(_KLINE_WINDOW)
+
+
+def _clean_rows(df: pl.DataFrame, keep_cols: list[str]) -> list[dict]:
+ """把 DataFrame 转成 JSON 安全的 dict 列表(只保留关键列 + 清洗 NaN/Inf + date→字符串)。
+
+ polars 的 date 列会变成 Python datetime.date,json.dumps 无法直接序列化,
+ 必须转成 ISO 字符串,否则 json.dumps 抛 TypeError 让整个流静默失败。
+ """
+ import datetime
+ import math
+ cols = [c for c in keep_cols if c in df.columns]
+ sub = df.select(cols)
+ rows = []
+ for rec in sub.to_dicts():
+ clean = {}
+ for k, v in rec.items():
+ if isinstance(v, float):
+ clean[k] = None if not math.isfinite(v) else round(v, 4)
+ elif isinstance(v, (datetime.date, datetime.datetime)):
+ clean[k] = v.isoformat()
+ else:
+ clean[k] = v
+ rows.append(clean)
+ return rows
+
+
+def _load_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
+ """读取该标的核心财务指标 + 利润表(只取最有信息量的两张表)。
+
+ 财务面只需要关键指标(ROE / 增速 / 毛利率 等),不需要把 4 张表全塞进上下文
+ (那是 financial_analyzer 的职责)。这里取轻量,留给技术面更多 token。
+ """
+ out: dict[str, list[dict]] = {}
+ for table in ("metrics", "income"):
+ df = get_financial_df(data_dir, table)
+ if df.is_empty():
+ out[table] = []
+ continue
+ df = df.filter(pl.col("symbol") == symbol)
+ if df.is_empty():
+ out[table] = []
+ continue
+ if "period_end" in df.columns:
+ df = df.sort("period_end", descending=True).head(2) # 只取最近 2 期
+ import math
+ rows = []
+ for rec in df.to_dicts():
+ clean = {}
+ for k, v in rec.items():
+ if k == "symbol":
+ continue
+ if isinstance(v, float):
+ clean[k] = None if not math.isfinite(v) else v
+ else:
+ clean[k] = v
+ rows.append(clean)
+ out[table] = rows
+ return out
+
+
+# ================================================================
+# 系统提示词 —— 实战派交易员四维框架(与财务分析明确区分)
+# ================================================================
+
+_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深交易员兼技术分析师,擅长从 K 线、量价、关键价位与基本面交叉验证中把握买卖时机。你的任务是:基于提供的个股数据,产出一份**实战、可直接指导交易决策**的综合分析报告。
+
+## 输出规范
+
+用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
+
+### 1. 🎯 一句话定调(1-2 句)
+用一句话概括该股当前的**技术状态与交易属性**(如"高位放量滞涨,需警惕回调"/"底部筹码集中,放量突破在即")。结尾用【操作建议:观望 / 轻仓试探 / 逢低吸纳 / 持有 / 减仓 / 规避】给出明确倾向。
+
+### 2. 📈 技术面分析(核心维度)
+这是你的主战场,务必深入:
+- **趋势判断**:均线多头/空头排列、20/60 日均线方向、价格在均线之上/下
+- **形态结构**:近期是否有突破/破位/双底/双顶/旗形等关键形态
+- **指标信号**:MACD 金叉/死叉/背离、KDJ 超买超卖、RSI 强弱、布林通道位置
+- **量价配合**:放量上涨/缩量回调/量价背离/换手率异动
+每条结论必须引用具体数值(如"MACD 在 6/12 出现金叉,DIF 0.32 上穿 DEA 0.18")。
+
+### 3. 💰 关键价位(买卖区间)
+基于提供的关键价位数据,明确指出:
+- **上方压力位**(逐档列出,标注强度):第一压力、第二压力
+- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑
+- 给出**建议买入区间**与**止损位**(基于支撑位)
+用数据说话,引用提供的压力/支撑/密集区/枢轴点数值。
+
+### 4. 🏭 基本面与财务面(辅助验证)
+简要点评(2-4 句,不展开长篇):
+- 盈利质量(ROE / 毛利率水平)、成长性(营收/利润增速)
+- 与技术面的**交叉验证**:好公司 + 技术面走坏 → 仍需谨慎;差公司 + 技术面强势 → 警惕炒作风险
+
+**当用户消息中标注了"该标的暂无财务数据"时**,本节请输出:
+> 📌 财务面分析能力正在接入中。当前版本(Free)未同步该标的的财务报表,基本面维度暂无法评估。
+> 技术面分析不依赖财务数据,以下结论依然有效;升级套餐或等待财务数据同步后可补充本维度。
+
+**绝对不要**在无数据时编造 ROE / 增速等数字。
+
+### 5. 📰 消息面(价量异动推断)
+**注意:本期无直接新闻数据输入。** 请基于 K 线的**异动信号**进行推断(如:
+- 涨停/连板/炸板 → 可能有利好或资金炒作
+- 放量暴跌 → 可能有未公开利空
+- 突破放量 → 可能有催化剂
+明确标注"[推断]",告诉用户这是基于价量的推测,真实消息面数据待接入。若无明显异动,直说"近期价量平稳,无明显消息面信号"。
+
+### 6. ⚖️ 综合研判与操作建议
+2-3 段:
+- 该股当前处于(底部启动 / 上升途中 / 高位震荡 / 下跌趋势 / 底部企稳)哪个阶段
+- 风险收益比评估(距支撑位的空间 vs 距压力位的空间)
+- **明确操作建议**:激进型 / 稳健型 / 保守型 分别怎么应对
+- **需要重点盯的信号**(如跌破 X 支撑止损、站上 Y 压力加仓)
+
+## 分析准则(务必遵守)
+
+1. **技术面优先**:作为交易员,技术面和量价是主要依据,基本面是验证手段,主次分明
+2. **数据说话**:每个判断引用具体数值,严禁空泛套话("走势良好"必须改成"连续 3 日站稳 20 日均线且放量")
+3. **诚实中立**:看多就写多,看空就写空,不要模棱两可骑墙;数据不支持时直言无法判断
+4. **价位精确**:买卖区间必须落到具体价格,基于提供的关键价位数据推演
+5. **风险前置**:任何买入建议都要配止损位;提示潜在风险不回避
+6. **简明实战**:用交易员能扫读的密度输出,总字数 1000-1800 字,重在可执行
+
+## 重要免责
+报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开行情与财务数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
+
+现在请基于下方数据进行分析。"""
+
+
+# ================================================================
+# 用户消息构建
+# ================================================================
+
+def _build_user_prompt(
+ kline_tail: list[dict],
+ fins: dict[str, list[dict]],
+ levels: dict[str, list[dict]],
+ close: float | None,
+ symbol: str,
+ focus: str,
+) -> str:
+ """构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。"""
+ parts: list[str] = [
+ f"标的标准代码: {symbol}",
+ f"关键价位概览: {summarize_levels(levels, close)}",
+ "",
+ "以下是该标的最近日 K 数据(JSON,含 OHLCV 与已计算的技术指标。"
+ f"最近 {_KLINE_WINDOW} 个交易日,升序):",
+ "```json",
+ json.dumps(kline_tail, ensure_ascii=False),
+ "```",
+ ]
+
+ has_fin = any(fins.values())
+ if has_fin:
+ parts.extend([
+ "",
+ "以下是该标的最新财务数据(JSON,核心指标 + 利润表,金额单位为元):",
+ "```json",
+ json.dumps(fins, ensure_ascii=False),
+ "```",
+ ])
+ else:
+ parts.extend([
+ "",
+ "(该标的暂无财务数据:当前为 Free 模式或尚未同步财务报表。"
+ "请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)",
+ ])
+
+ if focus.strip():
+ parts.extend(["", f"本次分析请特别关注: {focus.strip()}"])
+ return "\n".join(parts)
+
+
+# ================================================================
+# 关键列筛选(控制上下文体积)
+# ================================================================
+
+_KLINE_KEEP_COLS = [
+ "date", "open", "high", "low", "close", "volume", "change_pct",
+ "ma5", "ma10", "ma20", "ma60",
+ "macd_dif", "macd_dea", "macd_hist",
+ "kdj_k", "kdj_d", "kdj_j",
+ "rsi_6", "rsi_14", "rsi_24",
+ "boll_upper", "boll_mid", "boll_lower",
+ "atr_14", "vol_ratio_5d", "turnover_rate",
+ "consecutive_limit_ups",
+ # 信号类(布尔)——只挑对消息面推断有用的几个
+ "signal_limit_up", "signal_broken_limit_up", "signal_macd_golden",
+ "signal_macd_death", "signal_ma_golden_5_20", "signal_volume_surge",
+ "signal_boll_breakout_upper", "signal_boll_breakout_lower",
+]
+
+
+# ================================================================
+# 流式分析入口
+# ================================================================
+
+async def analyze_stock_stream(
+ repo,
+ data_dir: Path,
+ symbol: str,
+ focus: str = "",
+) -> AsyncIterator[str]:
+ """流式个股分析:yield 出每个 NDJSON 事件。
+
+ 协议(与 financial_analyzer 一致,前端解析无差异):
+ {"type":"meta","symbol","summary","levels"} 数据 + 价位摘要
+ {"type":"delta","content":"..."} 逐 chunk 文本
+ {"type":"error","message":"..."}
+ {"type":"done"}
+ """
+ # 1. 加载 K 线
+ df = _load_kline(repo, symbol)
+ if df.is_empty():
+ yield json.dumps({
+ "type": "error",
+ "message": f"标的 {symbol} 暂无日 K 数据,请先同步",
+ }, ensure_ascii=False)
+ return
+
+ # 2. 价位计算(基于 K 线)
+ levels = compute_levels(df)
+ close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
+
+ # 3. 财务(辅助)
+ fins = _load_financials(data_dir, symbol)
+
+ # 4. meta
+ yield json.dumps({
+ "type": "meta",
+ "symbol": symbol,
+ "summary": summarize_levels(levels, close),
+ "levels": levels,
+ "close": close,
+ }, ensure_ascii=False)
+
+ # 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死)
+ try:
+ from openai import AsyncOpenAI
+ from app import secrets_store
+ from app.config import settings
+
+ # 5. 构建提示词
+ kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
+ user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus)
+
+ # 6. 流式调用 LLM
+ ai_key = secrets_store.get_ai_key()
+ if not ai_key:
+ yield json.dumps({
+ "type": "error",
+ "message": "AI API Key 未配置,请在「设置 → AI」中配置",
+ }, ensure_ascii=False)
+ return
+
+ user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
+ client = AsyncOpenAI(
+ api_key=ai_key,
+ base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
+ timeout=180.0,
+ max_retries=2,
+ default_headers={"User-Agent": user_agent},
+ )
+
+ stream = await client.chat.completions.create(
+ model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
+ messages=[
+ {"role": "system", "content": _SYSTEM_PROMPT},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=0.5,
+ max_tokens=4500,
+ stream=True,
+ )
+
+ async for chunk in stream:
+ delta = chunk.choices[0].delta if chunk.choices else None
+ if delta and delta.content:
+ yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False)
+
+ except Exception as e: # noqa: BLE001
+ logger.exception("AI stock analysis failed for %s: %s", symbol, e)
+ yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
+ return
+
+ yield json.dumps({"type": "done"}, ensure_ascii=False)
diff --git a/backend/app/services/stock_reports.py b/backend/app/services/stock_reports.py
new file mode 100644
index 0000000..d522d23
--- /dev/null
+++ b/backend/app/services/stock_reports.py
@@ -0,0 +1,91 @@
+"""AI 个股分析报告持久化存储。
+
+与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限,
+互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。
+
+存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序)
+保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
+
+每条报告结构:
+{
+ "id": "sar_xxx", # 唯一 id(stock-analysis-report)
+ "symbol": "600519.SH",
+ "name": "贵州茅台",
+ "focus": "", # 用户追加的关心点(可为空)
+ "content": "# ...markdown", # 报告正文
+ "summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
+ "levels": {...}, # 报告生成时的关键价位(供图表回放)
+ "close": 12.3, # 报告生成时的收盘价
+ "created_at": "2026-06-26T10:00:00"
+}
+"""
+from __future__ import annotations
+
+import json
+import logging
+import time
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+MAX_REPORTS = 50
+
+
+def _path() -> Path:
+ from app.config import settings
+ p = settings.data_dir / "user_data" / "ai_stock_reports.json"
+ p.parent.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def list_reports() -> list[dict]:
+ """返回全部报告(按 created_at 降序)。"""
+ p = _path()
+ if not p.exists():
+ return []
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
+ except Exception as e: # noqa: BLE001
+ logger.warning("ai_stock_reports.json malformed: %s", e)
+ return []
+
+
+def _save_all(reports: list[dict]) -> None:
+ """全量写入(裁剪到 MAX_REPORTS)。"""
+ reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
+ if len(reports) > MAX_REPORTS:
+ reports = reports[:MAX_REPORTS]
+ _path().write_text(
+ json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
+ )
+
+
+def save_report(report: dict) -> dict:
+ """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
+ reports = list_reports()
+ if not report.get("id"):
+ report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
+ if not report.get("created_at"):
+ report["created_at"] = _now_iso()
+ reports.append(report)
+ _save_all(reports)
+ logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
+ return report
+
+
+def delete_report(report_id: str) -> bool:
+ """删除指定报告。返回是否删除成功。"""
+ reports = list_reports()
+ before = len(reports)
+ reports = [r for r in reports if r.get("id") != report_id]
+ if len(reports) < before:
+ _save_all(reports)
+ return True
+ return False
+
+
+def _now_iso() -> str:
+ from datetime import datetime
+ return datetime.now().isoformat(timespec="seconds")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 0c7fa66..24f0c88 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "tickflow-stock-panel-backend"
-version = "0.1.45"
+version = "0.1.50"
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
readme = "../README.md"
requires-python = ">=3.11"
diff --git a/frontend/package.json b/frontend/package.json
index 9dbef2b..1b76dd8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
- "version": "0.1.45",
+ "version": "0.1.50",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/LastStockChip.tsx b/frontend/src/components/LastStockChip.tsx
new file mode 100644
index 0000000..dd29f58
--- /dev/null
+++ b/frontend/src/components/LastStockChip.tsx
@@ -0,0 +1,31 @@
+import { Clock } from 'lucide-react'
+import type { StockRef } from '@/lib/useLastStock'
+
+/**
+ * "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。
+ * 上方名称、下方代码,小字体二排;点击恢复该个股的查看。
+ */
+export function LastStockChip({
+ stock,
+ onSelect,
+}: {
+ stock: StockRef | null
+ onSelect?: (symbol: string, name: string) => void
+}) {
+ if (!stock) return null
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index e12b714..cd87948 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -7,6 +7,8 @@ import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
import { AiReportBubble } from '@/components/financials/AiReportBubble'
+import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost'
+import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble'
import {
useCapabilities,
useSettings,
@@ -420,6 +422,12 @@ export function Layout() {
<>
{label}
+ {/* 个股分析 Beta 标识 */}
+ {to === '/stock-analysis' && (
+
+ Beta
+
+ )}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
@@ -533,6 +541,8 @@ export function Layout() {
+
+
)
}
diff --git a/frontend/src/components/financials/MarkdownRenderer.tsx b/frontend/src/components/financials/MarkdownRenderer.tsx
index 8f85df3..8fdfc93 100644
--- a/frontend/src/components/financials/MarkdownRenderer.tsx
+++ b/frontend/src/components/financials/MarkdownRenderer.tsx
@@ -88,7 +88,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
- blocks.push(
)
+ blocks.push(
)
i++
continue
}
@@ -99,9 +99,9 @@ export function MarkdownRenderer({ content }: { content: string }) {
const level = hMatch[1].length
const text = hMatch[2]
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
- const mtCls = level <= 2 ? 'mt-4' : 'mt-3'
+ const mtCls = level <= 2 ? 'mt-6' : 'mt-5'
blocks.push(
-
+
{renderInline(text, `h-${key}`)}
,
)
@@ -117,7 +117,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
-
+
{renderInline(quoteLines.join(' '), `q-${key}`)}
,
)
@@ -131,7 +131,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
-
+
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
@@ -176,10 +176,10 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
-
+
{items.map((item, ii) => (
-
-
+
{renderInline(item, `li-${key}-${ii}`)}
))}
@@ -196,7 +196,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
-
+
{items.map((item, ii) => (
-
@@ -212,12 +212,14 @@ export function MarkdownRenderer({ content }: { content: string }) {
// 普通段落
blocks.push(
-
+
{renderInline(trimmed, `p-${key}`)}
,
)
i++
}
- return {blocks}
+ // 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
+ // 让各块的 my-* 自然叠加(margin collapse),间距更可控。
+ return {blocks}
}
diff --git a/frontend/src/components/stock-analysis/AnalysisKChart.tsx b/frontend/src/components/stock-analysis/AnalysisKChart.tsx
new file mode 100644
index 0000000..98e9960
--- /dev/null
+++ b/frontend/src/components/stock-analysis/AnalysisKChart.tsx
@@ -0,0 +1,447 @@
+import { useEffect, useRef, useMemo, useState } from 'react'
+import * as echarts from 'echarts'
+import type { ECharts, EChartsOption } from 'echarts'
+import type { KlineRow } from '@/lib/api'
+
+/**
+ * 个股分析专用日 K 图表。
+ *
+ * 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
+ * - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
+ * - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
+ * 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
+ *
+ * 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
+ * - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
+ * - ranges: 区间高亮(事件区间 → markArea)
+ * - onDateClick: 点击日期回调(后续接消息面时间轴)
+ * - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
+ */
+
+// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
+const THEME = {
+ bull: '#C74040',
+ bear: '#2D9B65',
+ text: '#A1A1AA',
+ grid: 'rgba(255,255,255,0.04)',
+ volUp: 'rgba(240,68,56,0.5)',
+ volDown: 'rgba(18,183,106,0.5)',
+}
+
+// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
+export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
+
+export interface PriceLevel {
+ value: number
+ label: string
+ type: LevelType
+ side: 'resistance' | 'support' | 'neutral'
+ strength?: 'strong' | 'medium' | 'weak'
+ /** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
+ rank?: number
+}
+
+/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
+export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
+ { key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙
+ { key: 'profile', label: '成交密集', color: '#3B82F6' }, // 蓝
+ { key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
+ { key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
+ { key: 'keltner', label: 'Keltner', color: '#06B6D4' }, // 青
+ { key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
+ { key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
+ { key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
+ { key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
+]
+
+// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
+export interface ChartMarker {
+ date: string
+ label?: string
+ color?: string
+ above?: boolean
+}
+export interface ChartRange {
+ start: string
+ end: string
+ label?: string
+ color?: string
+}
+
+interface Props {
+ rows: KlineRow[]
+ levels?: Record
+ /** 默认开启的价位组 */
+ defaultLevelTypes?: LevelType[]
+ /** 预留:新闻/暴雷/利好日期标记 */
+ markers?: ChartMarker[]
+ /** 预留:事件区间高亮 */
+ ranges?: ChartRange[]
+ /** 预留:点击某根 K 线 */
+ onDateClick?: (date: string) => void
+ height?: number
+ className?: string
+}
+
+const VOL_PANE_H = 90
+
+export function AnalysisKChart({
+ rows,
+ levels,
+ defaultLevelTypes = ['sr', 'pivot', 'keltner'],
+ markers,
+ ranges,
+ onDateClick,
+ height = 460,
+ className,
+}: Props) {
+ const chartRef = useRef(null)
+ const chartInstRef = useRef(null)
+ const [activeTypes, setActiveTypes] = useState>(new Set(defaultLevelTypes))
+ /** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
+ const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
+
+ // 数据预处理
+ const { dates, candle, vols, dateIndex, zoomStart } = useMemo(() => {
+ const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
+ const candle = rows.map(r => [r.open, r.close, r.low, r.high])
+ const vols = rows.map(r => ({
+ value: r.volume ?? 0,
+ itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
+ }))
+ const dateIndex = new Map(dates.map((d, i) => [d, i]))
+ // 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
+ const showBars = 120
+ const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
+ return { dates, candle, vols, dateIndex, zoomStart }
+ }, [rows])
+
+ // 构建 option
+ const buildOption = (): EChartsOption => {
+ const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
+
+ // 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
+ // [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
+ const SLIDER_H = 22
+ const PAD_TOP = 16
+ const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
+ const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
+ const PAD_BOTTOM = 8
+ const volH = VOL_PANE_H
+ const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
+ const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
+ const sliderBottom = PAD_BOTTOM
+
+ // 主图 markLine(关键价位)
+ const markLineData: any[] = priceLines.map(p => ({
+ yAxis: p.value,
+ lineStyle: { color: p.color, type: 'dashed', width: 1, opacity: 0.85 },
+ label: {
+ show: true,
+ formatter: `${p.label} ${p.value.toFixed(2)}`,
+ position: 'insideEndTop',
+ color: p.color,
+ fontSize: 10,
+ fontFamily: 'JetBrains Mono, monospace',
+ backgroundColor: 'rgba(15,23,42,0.72)',
+ padding: [1, 5],
+ borderRadius: 3,
+ },
+ }))
+
+ // 预留:markPoint(新闻标记)
+ const markPointData: any[] = (markers ?? [])
+ .filter(m => dateIndex.has(m.date))
+ .map(m => ({
+ coord: [m.date, rows[dateIndex.get(m.date)!].high],
+ symbol: 'pin', symbolSize: 32,
+ itemStyle: { color: m.color ?? '#EAB308' },
+ label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
+ }))
+
+ // 预留:markArea(事件区间)
+ const markAreaData: any[] = (ranges ?? [])
+ .filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
+ .map(r => [{
+ xAxis: r.start, name: r.label ?? '',
+ itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
+ label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
+ }, { xAxis: r.end }])
+
+ const series: any[] = [
+ {
+ name: 'K', type: 'candlestick', data: candle, animation: false,
+ itemStyle: {
+ color: THEME.bull, color0: THEME.bear,
+ borderColor: THEME.bull, borderColor0: THEME.bear,
+ },
+ markLine: markLineData.length ? { silent: true, symbol: 'none', animation: false, data: markLineData } : undefined,
+ markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
+ markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
+ },
+ {
+ name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
+ data: vols, animation: false,
+ },
+ ]
+
+ return {
+ animation: false,
+ backgroundColor: 'transparent',
+ grid: [
+ { left: 56, right: 64, top: 16, height: mainH },
+ { left: 56, right: 64, top: volTop, height: volH },
+ ],
+ xAxis: [
+ {
+ type: 'category', data: dates, boundaryGap: true,
+ axisLine: { lineStyle: { color: THEME.grid } },
+ axisLabel: { color: THEME.text, fontSize: 10 },
+ splitLine: { show: false },
+ axisPointer: { show: true, label: { show: false } },
+ },
+ {
+ type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
+ axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
+ },
+ ],
+ yAxis: [
+ { scale: true, splitLine: { lineStyle: { color: THEME.grid } },
+ axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
+ { scale: true, gridIndex: 1, splitNumber: 2,
+ // 成交量区不画背景横线
+ splitLine: { show: false },
+ axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
+ formatter: (v: number) => fmtVol(v) } },
+ ],
+ dataZoom: [
+ { type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
+ { type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
+ borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
+ handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
+ ],
+ // 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
+ tooltip: { show: false },
+ axisPointer: { link: [{ xAxisIndex: 'all' }] },
+ series,
+ }
+ }
+
+ // 初始化 + 数据更新
+ useEffect(() => {
+ if (!chartRef.current) return
+ if (!chartInstRef.current) {
+ chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
+ chartInstRef.current.on('click', (params: any) => {
+ // 预留:点击 K 线(非 markPoint/markLine)回调
+ if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
+ onDateClick(dates[params.dataIndex])
+ }
+ })
+ }
+ chartInstRef.current.setOption(buildOption(), true)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rows, levels, activeTypes, pivotRank, markers, ranges, height])
+
+ // resize
+ useEffect(() => {
+ const inst = chartInstRef.current
+ if (!inst) return
+ const onResize = () => inst.resize()
+ window.addEventListener('resize', onResize)
+ return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
+ }, [])
+
+ const toggleType = (t: LevelType) => {
+ setActiveTypes(prev => {
+ const next = new Set(prev)
+ if (next.has(t)) next.delete(t)
+ else next.add(t)
+ return next
+ })
+ }
+
+ return (
+
+ {/* 价位开关按钮组 */}
+ {levels && (
+
+
关键价位
+ {LEVEL_GROUPS.map(g => {
+ const active = activeTypes.has(g.key)
+ // 枢轴点数量按当前档位过滤显示;其他组显示原始数量
+ const raw = levels[g.key] ?? []
+ const count = g.key === 'pivot'
+ ? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
+ : raw.length
+ return (
+
+ )
+ })}
+
+ {/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
+ {activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
+
+ 档位
+ {([1, 2, 3] as const).map(r => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* 价位概览:把当前开启的点位按"压力 / 支撑"结构化列出 */}
+ {levels && (
+
+ )}
+
+ )
+}
+
+// ===== 价位概览面板(结构化文本展示) =====
+function LevelOverview({
+ levels, activeTypes, pivotRank, close,
+}: {
+ levels: Record
+ activeTypes: Set
+ pivotRank: 1 | 2 | 3
+ close?: number
+}) {
+ // 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
+ const visible: PriceLevel[] = []
+ for (const g of LEVEL_GROUPS) {
+ if (!activeTypes.has(g.key)) continue
+ for (const p of levels[g.key] ?? []) {
+ if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
+ visible.push(p)
+ }
+ }
+ if (visible.length === 0) return null
+
+ // 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
+ const cur = close ?? visible[0].value
+ const resistances = visible
+ .filter(p => p.side === 'resistance')
+ .sort((a, b) => a.value - b.value) // 由近及远(低→高)
+ const supports = visible
+ .filter(p => p.side === 'support')
+ .sort((a, b) => b.value - a.value) // 由近及远(高→低)
+ const neutrals = visible.filter(p => p.side === 'neutral')
+
+ const fmtPct = (v: number) => {
+ if (!cur) return ''
+ const pct = ((v - cur) / cur) * 100
+ const sign = pct >= 0 ? '+' : ''
+ return `${sign}${pct.toFixed(1)}%`
+ }
+
+ const Row = ({ p }: { p: PriceLevel }) => {
+ const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
+ return (
+
+
+ {p.label}
+ {p.value.toFixed(2)}
+ {fmtPct(p.value)}
+
+ )
+ }
+
+ return (
+
+ {/* 当前价 */}
+
+ 当前价
+ {cur.toFixed(2)}
+
+ {/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
+ {resistances.length > 0 && (
+
+
压力位 ↑
+ {[...resistances].reverse().map((p, i) =>
|
)}
+
+ )}
+ {/* 支撑位 + 中性(枢轴位 P) */}
+
+ {supports.length > 0 && (
+ <>
+
支撑位 ↓
+ {supports.map((p, i) =>
|
)}
+ >
+ )}
+ {neutrals.length > 0 && (
+
0 ? 'mt-2' : ''}>
+ {supports.length === 0 &&
枢轴位
}
+ {neutrals.map((p, i) =>
|
)}
+
+ )}
+
+
+ )
+}
+
+// ===== 工具:收集要画的价位线(按开启的组 + 档位 + 强度配色) =====
+function collectPriceLines(
+ levels: Record | undefined,
+ active: Set,
+ pivotRank: 1 | 2 | 3,
+): { value: number; label: string; color: string }[] {
+ if (!levels) return []
+ const out: { value: number; label: string; color: string }[] = []
+ for (const g of LEVEL_GROUPS) {
+ if (!active.has(g.key)) continue
+ for (const p of levels[g.key] ?? []) {
+ // 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
+ if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
+ out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
+ }
+ }
+ return out
+}
+
+function strengthColor(strength: string | undefined, base: string): string {
+ // strong 用实色,medium 用 0.85,weak 用 0.55 透明
+ if (strength === 'weak') return base + '8C'
+ if (strength === 'medium') return base + 'D9'
+ return base
+}
+
+function fmtVol(v: number): string {
+ if (!v) return '0'
+ if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
+ if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
+ return v.toFixed(0)
+}
diff --git a/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx b/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx
new file mode 100644
index 0000000..e5927b7
--- /dev/null
+++ b/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx
@@ -0,0 +1,207 @@
+import { useState, useRef, useEffect, useCallback } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Loader2, Check, AlertCircle } from 'lucide-react'
+import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
+import type { ActiveTask } from '@/lib/stockAnalysisStore'
+
+/**
+ * AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
+ * 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。
+ *
+ * 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。
+ */
+
+const BUBBLE_W = 148
+const EDGE_MARGIN = 12
+
+export function StockAnalysisBubble() {
+ const activeTasks = useBubbleTasks()
+ const containerRef = useRef(null)
+ const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
+
+ const draggingRef = useRef(false)
+ const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
+ const movedRef = useRef(false)
+ const clickTargetRef = useRef<(() => void) | null>(null)
+
+ const applyTransform = useCallback((x: number, y: number) => {
+ const el = containerRef.current
+ if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
+ }, [])
+
+ const clamp = useCallback((x: number, y: number) => {
+ const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
+ const maxY = window.innerHeight - 80
+ return {
+ x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
+ y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
+ }
+ }, [])
+
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
+ draggingRef.current = true
+ movedRef.current = false
+ dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
+ const el = containerRef.current
+ if (el) el.classList.add('dragging')
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
+ }, [pos.x, pos.y])
+
+ const onPointerMove = useCallback((e: React.PointerEvent) => {
+ if (!draggingRef.current) return
+ const dx = e.clientX - dragData.current.mx
+ const dy = e.clientY - dragData.current.my
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
+ const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
+ applyTransform(c.x, c.y)
+ }, [clamp, applyTransform])
+
+ const onPointerUp = useCallback(() => {
+ if (!draggingRef.current) return
+ draggingRef.current = false
+ const el = containerRef.current
+ if (el) el.classList.remove('dragging')
+ if (movedRef.current) {
+ setPos(prev => {
+ const transform = el?.style.transform ?? ''
+ const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
+ const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
+ savePos(finalPos)
+ return finalPos
+ })
+ } else {
+ const fn = clickTargetRef.current
+ clickTargetRef.current = null
+ fn?.()
+ }
+ }, [])
+
+ useEffect(() => {
+ const onResize = () => {
+ setPos(prev => {
+ const c = clamp(prev.x, prev.y)
+ if (c.x !== prev.x || c.y !== prev.y) {
+ applyTransform(c.x, c.y)
+ return c
+ }
+ return prev
+ })
+ }
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [clamp, applyTransform])
+
+ useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform])
+
+ if (activeTasks.length === 0) return null
+
+ // 默认位置偏下,避免与财务胶囊(右下)重叠
+ return (
+
+
+ {activeTasks.map((task, i) => (
+ { clickTargetRef.current = () => restoreDialog(task.id) }}
+ />
+ ))}
+
+
+
+ )
+}
+
+function BubbleItem({ task, isLast, onPointerDown }: {
+ task: ActiveTask
+ isLast: boolean
+ onPointerDown: () => void
+}) {
+ const isWorking = task.phase === 'loading' || task.phase === 'streaming'
+ const isError = task.phase === 'error'
+
+ // 蓝色系(区别于财务分析的紫色)
+ const accent = isWorking
+ ? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]'
+ : isError
+ ? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
+ : 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
+
+ return (
+
+
+ {isWorking && (
+
+ )}
+
+ {isWorking ?
+ : isError ?
+ : }
+
+
+ {task.name || task.symbol}
+
+
+ {isWorking ? 个股分析
+ : isError ? 失败
+ : 点击查看}
+
+
+
+
+ )
+}
+
+const POS_KEY = 'sa_bubble_pos'
+function loadPos(): { x: number; y: number } {
+ // 默认右下,偏上一点(避开财务胶囊的右下位置)
+ const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
+ const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
+ try {
+ const v = localStorage.getItem(POS_KEY)
+ if (v) {
+ const p = JSON.parse(v)
+ if (typeof p.x === 'number' && typeof p.y === 'number') {
+ return {
+ x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
+ y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
+ }
+ }
+ }
+ } catch { /* ignore */ }
+ return { x: defaultX, y: defaultY }
+}
+function savePos(p: { x: number; y: number }) {
+ try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
+}
diff --git a/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx b/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx
new file mode 100644
index 0000000..73bdb35
--- /dev/null
+++ b/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx
@@ -0,0 +1,256 @@
+import { useEffect, useRef, useState, useCallback } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import {
+ X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
+ Settings2, Send, Wand2, Minimize2, History, LineChart,
+} from 'lucide-react'
+import { cn } from '@/lib/cn'
+import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
+import {
+ type ActiveTask, type HistoryReport,
+ minimizeDialog, closeDialog, startAnalysis,
+} from '@/lib/stockAnalysisStore'
+
+/**
+ * AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
+ * 复用 MarkdownRenderer(通用 markdown 渲染);标题/配色/文案独立。
+ */
+
+interface Props {
+ task: ActiveTask | HistoryReport | null
+ mode: 'active' | 'history' | null
+ minimized: boolean
+}
+
+type Phase = 'loading' | 'streaming' | 'done' | 'error'
+
+function getPhase(task: ActiveTask | HistoryReport | null): Phase {
+ if (!task) return 'loading'
+ if ('phase' in task) return task.phase
+ return 'done'
+}
+function getContent(task: ActiveTask | HistoryReport | null): string {
+ return task?.content ?? ''
+}
+function getMeta(task: ActiveTask | HistoryReport | null) {
+ if (!task) return null
+ if ('meta' in task) return task.meta
+ return { summary: task.summary, close: task.close, levels: task.levels }
+}
+
+export function StockAnalysisDialog({ task, mode, minimized }: Props) {
+ const scrollRef = useRef(null)
+ const [focus, setFocus] = useState('')
+ const [copied, setCopied] = useState(false)
+
+ const phase = getPhase(task)
+ const content = getContent(task)
+ const meta = getMeta(task)
+ const isHistory = mode === 'history'
+ const isWorking = phase === 'loading' || phase === 'streaming'
+ const open = !!task && !minimized
+
+ useEffect(() => {
+ if (open && phase === 'streaming' && scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+ }
+ }, [content, phase, open])
+
+ useEffect(() => {
+ setFocus(task && 'focus' in task ? task.focus : '')
+ }, [task])
+
+ const handleStartNew = useCallback(async () => {
+ if (!task) return
+ const name = 'name' in task ? task.name : ''
+ await startAnalysis(task.symbol, name, focus.trim())
+ }, [task, focus])
+
+ const handleCopy = async () => {
+ if (!content) return
+ try {
+ await navigator.clipboard.writeText(content)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch { /* ignore */ }
+ }
+
+ if (!open) return null
+
+ const error = task && 'error' in task ? task.error : ''
+
+ return (
+
+ { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
+ >
+
+ {/* 头部 —— 蓝色主题 */}
+
+
+
+ {isHistory
+ ?
+ : }
+
+
+
+
+ {isHistory ? '历史分析报告' : 'AI 个股分析'}
+
+ {task && {task.name}}
+ {task && {task.symbol}}
+
+
+ {meta?.summary ? (
+
+
+ {meta.summary}
+
+ ) : isWorking ? 正在读取行情与价位数据… : null}
+ {phase === 'streaming' && (
+
+ 生成中
+
+ )}
+ {isHistory && task && 'created_at' in task && (
+ {fmtRelative(task.created_at)}
+ )}
+
+
+
+ {content && !isWorking && (
+
+ )}
+ {!isHistory && isWorking && (
+
+ )}
+ {(!isWorking || isHistory) && (
+
+ )}
+
+
+
+
+ {/* 内容区 */}
+
+ {phase === 'loading' && !content && (
+
+
+
AI 正在分析行情与关键价位…
+
读取日 K / 技术指标 / 压力支撑 / 财务,生成四维分析
+
+ )}
+
+ {phase === 'error' && (
+
+
+
分析失败
+
{error}
+ {error.includes('AI') && (
+
+ )}
+
+
+ )}
+
+ {(content || phase === 'streaming') && (
+
+
+ {phase === 'streaming' && (
+
+ )}
+
+ )}
+
+
+ {/* 底部:关注点输入 */}
+
+
+
+
+ 关注重点
+
+
setFocus(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
+ disabled={isWorking}
+ placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看能否突破压力位…回车重新分析' : '可留空,留空则全面分析')}
+ className={cn(
+ 'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
+ 'focus:outline-none focus:ring-2 focus:ring-sky-400/30 transition-shadow disabled:opacity-50',
+ )}
+ />
+ {isHistory ? (
+
+ ) : (
+
+ )}
+
+
+ {isHistory
+ ? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
+ : '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
+
+
+
+
+
+ )
+}
+
+function fmtRelative(iso: string): string {
+ try {
+ const t = new Date(iso).getTime()
+ const diff = Date.now() - t
+ if (diff < 60_000) return '刚刚'
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
+ if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
+ return new Date(iso).toLocaleDateString('zh-CN')
+ } catch { return '' }
+}
diff --git a/frontend/src/components/stock-analysis/StockAnalysisHost.tsx b/frontend/src/components/stock-analysis/StockAnalysisHost.tsx
new file mode 100644
index 0000000..2cb7707
--- /dev/null
+++ b/frontend/src/components/stock-analysis/StockAnalysisHost.tsx
@@ -0,0 +1,12 @@
+import { useDialogTask, useDialogState } from '@/lib/stockAnalysisStore'
+import { StockAnalysisDialog } from './StockAnalysisDialog'
+
+/**
+ * AI 个股分析对话框宿主 —— 单点挂载在 Layout。
+ * 与财务分析的 AiAnalysisHost 并列,独立 store,蓝色主题。
+ */
+export function StockAnalysisHost() {
+ const { task, mode } = useDialogTask()
+ const { minimized } = useDialogState()
+ return
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 9a5a0d0..46f1e7e 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -116,6 +116,38 @@ export interface AiFinancialReport {
created_at: string
}
+// ===== 个股分析 =====
+export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
+
+export interface PriceLevel {
+ value: number
+ label: string
+ type: LevelType
+ side: 'resistance' | 'support' | 'neutral'
+ strength?: 'strong' | 'medium' | 'weak'
+ /** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3。前端按"显示到第几档"过滤。 */
+ rank?: number
+}
+
+export interface StockLevels {
+ levels: Record
+ close: number | null
+ summary: string
+ symbol: string
+}
+
+export interface AiStockReport {
+ id: string
+ symbol: string
+ name: string
+ focus: string
+ content: string
+ summary?: string
+ close?: number | null
+ levels?: Record
+ created_at: string
+}
+
// ===== Kline =====
export interface MinuteKlineRow {
datetime: string
@@ -654,6 +686,10 @@ export const api = {
body: JSON.stringify(ai),
}),
+ /** 一键清空 AI 配置(保留自定义 UA) */
+ clearAiSettings: () =>
+ request<{ ok: boolean }>('/api/settings/ai', { method: 'DELETE' }),
+
preferences: () => request('/api/settings/preferences'),
updateMinuteSync: (enabled: boolean, days: number) =>
request('/api/settings/preferences/minute-sync', {
@@ -1258,6 +1294,72 @@ export const api = {
}
},
+ // ===== 个股分析 =====
+ stockAnalysisLevels: (symbol: string, days = 120) =>
+ request(`/api/stock-analysis/levels?symbol=${encodeURIComponent(symbol)}&days=${days}`),
+
+ stockAnalysisReportsList: () =>
+ request<{ reports: AiStockReport[] }>('/api/stock-analysis/reports'),
+
+ stockAnalysisReportSave: (r: {
+ symbol: string; name?: string; focus?: string; content: string
+ summary?: string; close?: number | null
+ levels?: Record
+ }) =>
+ request<{ ok: boolean; report: AiStockReport }>('/api/stock-analysis/reports', {
+ method: 'POST', body: JSON.stringify(r),
+ }),
+
+ stockAnalysisReportDelete: (reportId: string) =>
+ request<{ ok: boolean }>(`/api/stock-analysis/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
+
+ /**
+ * AI 个股四维分析 — 流式调用(NDJSON,与财务分析同协议)。
+ * meta 里额外带 levels(关键价位)供图表回放。
+ */
+ async *stockAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{
+ type: 'meta' | 'delta' | 'error' | 'done'
+ symbol?: string
+ summary?: string
+ levels?: Record
+ close?: number | null
+ content?: string
+ message?: string
+ }> {
+ const res = await fetch('/api/stock-analysis/analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ symbol, focus: focus ?? '' }),
+ })
+ if (!res.ok) {
+ let detail = ''
+ try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
+ const msg = detail || `${res.status} ${res.statusText}`
+ toast(msg, 'error')
+ throw new Error(msg)
+ }
+ if (!res.body) throw new Error('响应无 body')
+
+ const reader = res.body.getReader()
+ const decoder = new TextDecoder()
+ let buf = ''
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buf += decoder.decode(value, { stream: true })
+ const lines = buf.split('\n')
+ buf = lines.pop() ?? ''
+ for (const line of lines) {
+ const s = line.trim()
+ if (!s) continue
+ try { yield JSON.parse(s) } catch { /* ignore */ }
+ }
+ }
+ if (buf.trim()) {
+ try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
+ }
+ },
+
// ===== Strategy Engine =====
strategyList: () =>
request<{ strategies: StrategyDetail[] }>('/api/strategies'),
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 8b37f86..cfcbd8e 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -50,6 +50,7 @@ export const QK = {
// Kline
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
['kline', symbol, start, end, extColumns ?? ''] as const,
+ stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
klineMinute: (symbol: string, date: string) =>
['kline-minute', symbol, date] as const,
indexDaily: (symbol: string, start: string, end: string) =>
diff --git a/frontend/src/lib/stockAnalysisStore.ts b/frontend/src/lib/stockAnalysisStore.ts
new file mode 100644
index 0000000..7c31645
--- /dev/null
+++ b/frontend/src/lib/stockAnalysisStore.ts
@@ -0,0 +1,268 @@
+import { useSyncExternalStore } from 'react'
+import { api, type PriceLevel, type LevelType } from './api'
+
+/**
+ * AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
+ *
+ * 与财务分析 store 的区别:
+ * - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
+ * - meta 额外带 levels(关键价位),供图表回放
+ * - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
+ *
+ * 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
+ */
+
+export type Phase = 'loading' | 'streaming' | 'done' | 'error'
+
+export interface ActiveTask {
+ id: string
+ symbol: string
+ name: string
+ focus: string
+ phase: Phase
+ content: string
+ error: string
+ meta: {
+ summary?: string
+ levels?: Record
+ close?: number | null
+ } | null
+ createdAt: number
+ savedReportId?: string
+ doneAt?: number
+ dismissed?: boolean
+}
+
+export interface HistoryReport {
+ id: string
+ symbol: string
+ name: string
+ focus: string
+ content: string
+ summary?: string
+ close?: number | null
+ levels?: Record
+ created_at: string
+}
+
+const MAX_ACTIVE = 3
+
+let activeTasks: ActiveTask[] = []
+let history: HistoryReport[] = []
+let historyLoaded = false
+const listeners = new Set<() => void>()
+
+let activeDialogTaskId: string | null = null
+let dialogMinimized = false
+
+function emit() { listeners.forEach(fn => fn()) }
+function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
+
+let _activeSnap: ActiveTask[] = []
+let _historySnap: HistoryReport[] = []
+interface DialogSnap { taskId: string | null; minimized: boolean }
+let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
+
+function rebuildSnap() {
+ _activeSnap = activeTasks
+ _historySnap = history
+ _dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
+}
+
+function getActiveSnapshot() { return _activeSnap }
+function getHistorySnapshot() { return _historySnap }
+function getDialogSnapshot() { return _dialogSnap }
+
+function patchTask(id: string, patch: Partial) {
+ activeTasks = activeTasks.map(t => {
+ if (t.id !== id) return t
+ const next = { ...t, ...patch }
+ if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
+ next.doneAt = Date.now()
+ }
+ return next
+ })
+ rebuildSnap()
+ emit()
+}
+
+// ===== 查询 hooks =====
+
+export function useBubbleTasks(): ActiveTask[] {
+ const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
+ useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
+ const ds = _dialogSnap
+ return all.filter(t => {
+ if (t.phase === 'loading' || t.phase === 'streaming') {
+ return !(ds.taskId === t.id && !ds.minimized)
+ }
+ if (t.dismissed) return false
+ if (!ds.minimized && ds.taskId === t.id) return false
+ return true
+ })
+}
+
+export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
+ const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
+ return { reports, loaded: historyLoaded }
+}
+
+export function useDialogState() {
+ return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
+}
+
+export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
+ const ds = useDialogState()
+ const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
+ const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
+ if (!ds.taskId) return { task: null, mode: null }
+ if (ds.taskId.startsWith('history:')) {
+ const rid = ds.taskId.slice('history:'.length)
+ return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
+ }
+ return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
+}
+
+// ===== 动作 =====
+
+export async function loadHistory(): Promise {
+ try {
+ const res = await api.stockAnalysisReportsList()
+ history = res.reports ?? []
+ historyLoaded = true
+ rebuildSnap()
+ emit()
+ } catch { /* 静默 */ }
+}
+
+export async function findLatestHistoryReport(symbol: string): Promise {
+ if (!historyLoaded) await loadHistory()
+ return history.find(r => r.symbol === symbol) ?? null
+}
+
+/**
+ * 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
+ * 判断依据:created_at 的日期部分 == 本地今天。
+ * @returns 当天最近一条报告,或 null
+ */
+export async function findTodayReport(symbol: string): Promise {
+ if (!historyLoaded) await loadHistory()
+ const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
+ return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
+}
+
+export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
+ const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
+ if (existing) {
+ activeDialogTaskId = existing.id
+ dialogMinimized = false
+ rebuildSnap()
+ emit()
+ return { id: existing.id }
+ }
+ const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
+ if (ongoing.length >= MAX_ACTIVE) {
+ return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
+ }
+
+ const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
+ const task: ActiveTask = {
+ id, symbol, name, focus,
+ phase: 'loading', content: '', error: '',
+ meta: null, createdAt: Date.now(),
+ }
+ activeTasks = [...activeTasks, task]
+ activeDialogTaskId = id
+ dialogMinimized = false
+ rebuildSnap()
+ emit()
+
+ runStream(id, symbol, name, focus)
+ return { id }
+}
+
+async function runStream(id: string, symbol: string, _name: string, focus: string) {
+ try {
+ let firstDelta = true
+ for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
+ const cur = activeTasks.find(t => t.id === id)
+ if (!cur) return
+ switch (chunk.type) {
+ case 'meta':
+ patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
+ break
+ case 'delta':
+ if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
+ patchTask(id, { content: cur.content + (chunk.content ?? '') })
+ break
+ case 'error':
+ patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
+ return
+ case 'done':
+ patchTask(id, { phase: 'done' })
+ break
+ }
+ }
+ const final = activeTasks.find(t => t.id === id)
+ if (final && final.phase !== 'error') {
+ // 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
+ if (!final.content) {
+ patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
+ return
+ }
+ try {
+ const res = await api.stockAnalysisReportSave({
+ symbol: final.symbol, name: final.name, focus: final.focus,
+ content: final.content, summary: final.meta?.summary ?? '',
+ close: final.meta?.close ?? null, levels: final.meta?.levels,
+ })
+ if (res.report) {
+ patchTask(id, { savedReportId: res.report.id })
+ history = [res.report, ...history.filter(r => r.id !== res.report.id)]
+ historyLoaded = true
+ rebuildSnap()
+ emit()
+ }
+ } catch { /* 持久化失败不影响展示 */ }
+ }
+ } catch (e: any) {
+ const msg = String(e?.message ?? '分析失败')
+ patchTask(id, {
+ phase: 'error',
+ error: msg.includes('API Key') || msg.includes('api_key')
+ ? 'AI API Key 未配置或无效,请在「设置 → AI」中配置'
+ : msg,
+ })
+ }
+}
+
+export function openDialog(taskId: string) {
+ activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
+}
+export function minimizeDialog() {
+ dialogMinimized = true; rebuildSnap(); emit()
+}
+export function closeDialog() {
+ activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
+}
+export function restoreDialog(taskId: string) {
+ const t = activeTasks.find(x => x.id === taskId)
+ if (t && (t.phase === 'done' || t.phase === 'error')) {
+ patchTask(taskId, { dismissed: true })
+ }
+ activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
+}
+export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
+ return startAnalysis(task.symbol, task.name, task.focus)
+}
+export async function deleteReport(reportId: string): Promise {
+ try {
+ await api.stockAnalysisReportDelete(reportId)
+ history = history.filter(r => r.id !== reportId)
+ rebuildSnap()
+ emit()
+ } catch { /* 静默 */ }
+}
+export function openHistoryReport(reportId: string) {
+ activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
+}
diff --git a/frontend/src/lib/useLastStock.ts b/frontend/src/lib/useLastStock.ts
new file mode 100644
index 0000000..6c318d5
--- /dev/null
+++ b/frontend/src/lib/useLastStock.ts
@@ -0,0 +1,52 @@
+import { useCallback, useState } from 'react'
+
+/**
+ * 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
+ *
+ * 两个分析页(财务 / 个股)各自独立记忆,key 区分:
+ * - financials: 最后查看的财务分析个股
+ * - stock-analysis: 最后查看的个股分析个股
+ *
+ * 用法:
+ * const { last, remember } = useLastStock('stock-analysis')
+ * remember('000001.SZ', '平安银行') // 选中股票时调用
+ * // 渲染在 PageHeader 右侧
+ */
+
+export interface StockRef { symbol: string; name: string }
+
+const PREFIX = 'last_stock:'
+
+export function useLastStock(scope: string) {
+ const [last, setLast] = useState(() => load(scope))
+
+ const remember = useCallback((symbol: string, name: string) => {
+ const ref = { symbol, name }
+ setLast(ref)
+ save(scope, ref)
+ }, [scope])
+
+ const clear = useCallback(() => {
+ setLast(null)
+ save(scope, null)
+ }, [scope])
+
+ return { last, remember, clear }
+}
+
+function load(scope: string): StockRef | null {
+ try {
+ const v = localStorage.getItem(PREFIX + scope)
+ if (!v) return null
+ const p = JSON.parse(v)
+ if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
+ } catch { /* ignore */ }
+ return null
+}
+
+function save(scope: string, ref: StockRef | null) {
+ try {
+ if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
+ else localStorage.removeItem(PREFIX + scope)
+ } catch { /* ignore */ }
+}
diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx
index d8f6330..a8552e0 100644
--- a/frontend/src/pages/Financials.tsx
+++ b/frontend/src/pages/Financials.tsx
@@ -7,6 +7,8 @@ import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
+import { LastStockChip } from '@/components/LastStockChip'
+import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
@@ -49,6 +51,11 @@ export function Financials() {
}, [syncing, syncStartedAt])
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
+ const { last: lastStock, remember: rememberStock } = useLastStock('financials')
+ const pick = (symbol: string, name: string) => {
+ setSelected({ symbol, name })
+ rememberStock(symbol, name)
+ }
if (!hasFinancial) {
return (
@@ -129,6 +136,7 @@ export function Financials() {
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
right={
+
{syncing && (
@@ -250,7 +258,7 @@ export function Financials() {
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
- setSelected({ symbol, name })} />
+
- setSelected({ symbol, name })} />
+
支持股票代码或名称模糊匹配,如 600000 / 浦发
diff --git a/frontend/src/pages/StockAnalysis.tsx b/frontend/src/pages/StockAnalysis.tsx
index 4960f5e..a7199d9 100644
--- a/frontend/src/pages/StockAnalysis.tsx
+++ b/frontend/src/pages/StockAnalysis.tsx
@@ -1,10 +1,292 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink } from 'lucide-react'
+import { PageHeader } from '@/components/PageHeader'
+import { EmptyState } from '@/components/EmptyState'
+import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
+import { StockPreviewDialog } from '@/components/StockPreviewDialog'
+import { LastStockChip } from '@/components/LastStockChip'
+import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
+import { api } from '@/lib/api'
+import { useLastStock } from '@/lib/useLastStock'
+import { QK } from '@/lib/queryKeys'
+import { toast } from '@/components/Toast'
+import {
+ startAnalysis, findTodayReport, useHistoryReports,
+ deleteReport, openHistoryReport,
+} from '@/lib/stockAnalysisStore'
+
+/**
+ * 个股分析页 —— 日 K + 关键价位(压力/支撑/密集区/枢轴/前高前低)+ AI 四维分析。
+ *
+ * 与财务分析页的区别:
+ * - 以【行情 + 关键价位】为视觉主体(专用日 K 图表,不复用个股对话框图表)
+ * - AI 分析输出买卖区间 / 操作建议(非财务质量评级)
+ * - 报告胶囊用蓝色系,与财务分析(紫色)并存
+ */
export function StockAnalysis() {
+ const [symbol, setSymbol] = useState('')
+ const [name, setName] = useState('')
+ const [checking, setChecking] = useState(false)
+ const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
+ const [showHistory, setShowHistory] = useState(false)
+ const [previewSymbol, setPreviewSymbol] = useState(null)
+ const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
+
+ const onSelect = (sym: string, nm: string) => {
+ setSymbol(sym)
+ setName(nm)
+ setShowHistory(false)
+ setConfirmReport(null)
+ rememberStock(sym, nm)
+ }
+
+ const handleAnalyze = async () => {
+ if (!symbol || checking) return
+ setChecking(true)
+ try {
+ // 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
+ const today = await findTodayReport(symbol)
+ if (today) {
+ setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
+ } else {
+ await doAnalysis()
+ }
+ } catch {
+ await doAnalysis()
+ } finally {
+ setChecking(false)
+ }
+ }
+
+ const doAnalysis = async () => {
+ const r = await startAnalysis(symbol, name)
+ if (r.error) toast(r.error, 'error')
+ }
+
return (
-
-
-
个股分析
-
开发中...
+ <>
+
+ Beta
+
+ }
+ subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
+ right={
+
+
+ {symbol && (
+
+ )}
+
+ }
+ />
+
+
+ {/* 搜索栏 */}
+
+
+
+
+ {symbol && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* 主体 */}
+ {!symbol ? (
+
+ ) : showHistory ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 二次确认:已有历史报告 */}
+ {confirmReport && (
+ { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
+ onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
+ onClose={() => setConfirmReport(null)}
+ />
+ )}
+
+ {/* 个股日 K 详情对话框(点击名称/代码打开) */}
+ setPreviewSymbol(null)}
+ />
+ >
+ )
+}
+
+// ===== 分析看板:日 K + 关键价位 =====
+function StockAnalysisBoard({ symbol }: { symbol: string }) {
+ const kline = useQuery({
+ queryKey: ['kline', symbol, ''],
+ queryFn: () => api.klineDaily(symbol, 250),
+ enabled: !!symbol,
+ staleTime: 60_000,
+ })
+
+ const levelsQ = useQuery({
+ queryKey: QK.stockLevels(symbol),
+ queryFn: () => api.stockAnalysisLevels(symbol, 250),
+ enabled: !!symbol,
+ staleTime: 60_000,
+ })
+
+ if (kline.isLoading) {
+ return
+ }
+
+ const rows = kline.data?.rows ?? []
+ if (rows.length === 0) {
+ return
+ }
+
+ const levels = (levelsQ.data?.levels ?? {}) as Record
+
+ return (
+
+
+
+
+ 关键价位分析
+
+ {rows.length} 个交易日 · 当前价 {levelsQ.data?.close?.toFixed(2) ?? '—'}
+
+
+
+
)
}
+
+// ===== 历史报告列表 =====
+function HistoryList({ symbol }: { symbol: string }) {
+ const { reports, loaded } = useHistoryReports()
+ const mine = reports.filter(r => r.symbol === symbol)
+
+ if (!loaded) {
+ return
+ }
+ if (mine.length === 0) {
+ return
+ }
+
+ return (
+
+ {mine.map(r => (
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+// ===== 二次确认弹窗 =====
+function ConfirmModal({ report, onView, onRedo, onClose }: {
+ report: { id: string; created_at: string; focus: string }
+ onView: () => void
+ onRedo: () => void
+ onClose: () => void
+}) {
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+ 该个股已有分析报告
+
+
+ 最近一次报告生成于 {fmtRelative(report.created_at)}。
+
+ {report.focus &&
关注点: {report.focus}
}
+
可直接查看历史,或重新生成一份新报告。
+
+
+
+
+
+
+ )
+}
+
+function fmtRelative(iso: string): string {
+ try {
+ const t = new Date(iso).getTime()
+ const diff = Date.now() - t
+ if (diff < 60_000) return '刚刚'
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
+ if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
+ return new Date(iso).toLocaleDateString('zh-CN')
+ } catch { return iso }
+}
diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx
index 172b53e..abd9827 100644
--- a/frontend/src/pages/settings/AI.tsx
+++ b/frontend/src/pages/settings/AI.tsx
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
- Shuffle, Plug, Zap, Settings2, ExternalLink,
+ Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
} from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
-import { api } from '@/lib/api'
+import { api, type SettingsState } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
// 统一的输入框样式(与项目其他设置页一致)
@@ -35,6 +35,7 @@ export function SettingsAIPanel() {
const [userAgent, setUserAgent] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
+ const [confirmClear, setConfirmClear] = useState(false)
// 测试
const [testing, setTesting] = useState(false)
@@ -70,11 +71,44 @@ export function SettingsAIPanel() {
user_agent: customUa ? userAgent : '',
}),
onSuccess: () => {
- setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
+ setSaved(true); setApiKey('')
+ // 乐观更新:保存后立刻刷新连接状态 & 左侧菜单,不等异步 refetch
+ qc.setQueryData(QK.settings, prev => prev ? {
+ ...prev,
+ ai_provider: provider,
+ ai_base_url: baseUrl,
+ ai_model: model,
+ // 只在本次确实提交了新 Key 时才更新连接态(留空不修改)
+ ...(apiKey ? {
+ has_ai_key: true,
+ ai_api_key_masked: `${apiKey.slice(0, 4)}••••••${apiKey.slice(-4)}`,
+ } : {}),
+ } : prev)
+ qc.invalidateQueries({ queryKey: QK.settings })
setTimeout(() => setSaved(false), 2000)
},
})
+ const clear = useMutation({
+ mutationFn: () => api.clearAiSettings(),
+ onSuccess: () => {
+ setConfirmClear(false)
+ // 同步清空本地表单(保留自定义 UA)
+ setProvider('openai_compat'); setBaseUrl(''); setApiKey(''); setModel('')
+ setTestResult(null)
+ // 乐观更新:立刻把连接状态/左侧菜单置为未配置
+ qc.setQueryData(QK.settings, prev => prev ? {
+ ...prev,
+ ai_provider: 'openai_compat',
+ ai_base_url: '',
+ ai_model: '',
+ has_ai_key: false,
+ ai_api_key_masked: '',
+ } : prev)
+ qc.invalidateQueries({ queryKey: QK.settings })
+ },
+ })
+
const handleTest = async () => {
setTesting(true); setTestResult(null)
try {
@@ -246,12 +280,45 @@ export function SettingsAIPanel() {
- {/* ===== 保存 ===== */}
-
+ {/* ===== 保存 / 清空 ===== */}
+
+
+ {configured && (
+
+ )}
+
+
+ {/* 二次确认:清空 AI 配置 */}
+ {confirmClear && (
+
+
setConfirmClear(false)} />
+
+
清空 AI 配置
+
+ 将清除 API Key、API 地址、模型等所有 AI 配置。自定义请求头(User-Agent)会保留。清空后相关 AI 功能将不可用,需重新配置。
+
+
+
+
+
+
+
+ )}
)
}