feat: 个股分析(Beta) — 专用日K + 9类关键价位 + AI四维分析

新增个股分析页,以行情与关键价位为视觉主体,与财务分析(财务质量评级)定位互补:

后端:
- 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
This commit is contained in:
shy3130
2026-06-26 15:36:52 +08:00
parent 3bb66f3e81
commit 2015ef0c78
24 changed files with 2959 additions and 30 deletions
+25 -1
View File
@@ -35,7 +35,7 @@
| 配置项 | 说明 | 是否必填 |
| :--- | :--- | :--- |
| **TickFlow API Key** | 数据源凭证,留空启用 None 模式,获取免费key后开启free模式可定制策略+回测 | 可选 |
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中)等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
<table>
<tr>
@@ -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/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.45"
__version__ = "0.1.50"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+18
View File
@@ -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:
+133
View File
@@ -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}
+580
View File
@@ -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
+2 -1
View File
@@ -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)
+337
View File
@@ -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)
+91
View File
@@ -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")
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.45",
"version": "0.1.50",
"type": "module",
"scripts": {
"dev": "vite",
+31
View File
@@ -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 (
<button
onClick={() => onSelect?.(stock.symbol, stock.name)}
title={`继续查看 ${stock.name}`}
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
>
<Clock className="h-3 w-3 text-muted shrink-0" />
<span className="flex flex-col items-start leading-tight">
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
{stock.name}
</span>
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
</span>
</button>
)
}
+10
View File
@@ -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() {
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
{/* 个股分析 Beta 标识 */}
{to === '/stock-analysis' && (
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
Beta
</span>
)}
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
{to === '/data' && isDataSyncing && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
@@ -533,6 +541,8 @@ export function Layout() {
<AlertToastContainer />
<AiAnalysisHost />
<AiReportBubble />
<StockAnalysisHost />
<StockAnalysisBubble />
</div>
)
}
@@ -88,7 +88,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
blocks.push(<hr key={key++} className="my-3 border-border/40" />)
blocks.push(<hr key={key++} className="my-6 border-border" />)
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(
<div key={key++} className={`${sizeCls} ${mtCls} mb-2 font-semibold text-foreground flex items-center gap-1.5`}>
<div key={key++} className={`${sizeCls} ${mtCls} mb-3 font-semibold text-foreground flex items-center gap-1.5`}>
{renderInline(text, `h-${key}`)}
</div>,
)
@@ -117,7 +117,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
<blockquote key={key++} className="my-2 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
<blockquote key={key++} className="my-4 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
{renderInline(quoteLines.join(' '), `q-${key}`)}
</blockquote>,
)
@@ -131,7 +131,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
<div key={key++} className="my-3 overflow-hidden rounded-btn border border-border/30">
<div key={key++} className="my-5 overflow-hidden rounded-btn border border-border/30">
<table className="w-full text-xs border-collapse table-fixed">
<colgroup>
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
@@ -176,10 +176,10 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
<ul key={key++} className="my-1.5 space-y-1">
<ul key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-1.5 h-1 w-1 rounded-full bg-accent/60 shrink-0" />
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
</li>
))}
@@ -196,7 +196,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
i++
}
blocks.push(
<ol key={key++} className="my-1.5 space-y-1">
<ol key={key++} className="my-4 space-y-2">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
@@ -212,12 +212,14 @@ export function MarkdownRenderer({ content }: { content: string }) {
// 普通段落
blocks.push(
<p key={key++} className="my-1.5 text-xs text-foreground/90 leading-relaxed">
<p key={key++} className="my-3 text-xs text-foreground/90 leading-relaxed">
{renderInline(trimmed, `p-${key}`)}
</p>,
)
i++
}
return <div className="space-y-0">{blocks}</div>
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
return <div>{blocks}</div>
}
@@ -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<LevelType, PriceLevel[]>
/** 默认开启的价位组 */
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<HTMLDivElement>(null)
const chartInstRef = useRef<ECharts | null>(null)
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(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 (
<div className={className}>
{/* 价位开关按钮组 */}
{levels && (
<div className="flex flex-wrap items-center gap-1.5 mb-2">
<span className="text-[10px] text-muted mr-1"></span>
{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 (
<button
key={g.key}
onClick={() => toggleType(g.key)}
disabled={raw.length === 0}
title={`${g.label} (${count} 个)`}
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
active
? 'text-foreground'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
{g.label}
<span className="opacity-50">{count}</span>
</button>
)
})}
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
<span className="text-[10px] text-muted mr-1"></span>
{([1, 2, 3] as const).map(r => (
<button
key={r}
onClick={() => setPivotRank(r)}
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
pivotRank === r
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
}`}
>
{r}
</button>
))}
</div>
)}
</div>
)}
<div ref={chartRef} style={{ width: '100%', height }} />
{/* 价位概览:把当前开启的点位按"压力 / 支撑"结构化列出 */}
{levels && (
<LevelOverview
levels={levels}
activeTypes={activeTypes}
pivotRank={pivotRank}
close={rows.length ? rows[rows.length - 1].close : undefined}
/>
)}
</div>
)
}
// ===== 价位概览面板(结构化文本展示) =====
function LevelOverview({
levels, activeTypes, pivotRank, close,
}: {
levels: Record<LevelType, PriceLevel[]>
activeTypes: Set<LevelType>
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 (
<div className="flex items-center gap-2 py-0.5">
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
</div>
)
}
return (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
{/* 当前价 */}
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
<span className="text-[10px] text-muted"></span>
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
</div>
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
{resistances.length > 0 && (
<div>
<div className="text-[10px] font-medium text-bear mb-0.5"> </div>
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
</div>
)}
{/* 支撑位 + 中性(枢轴位 P) */}
<div>
{supports.length > 0 && (
<>
<div className="text-[10px] font-medium text-bull mb-0.5"> </div>
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
</>
)}
{neutrals.length > 0 && (
<div className={supports.length > 0 ? 'mt-2' : ''}>
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5"></div>}
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
</div>
)}
</div>
</div>
)
}
// ===== 工具:收集要画的价位线(按开启的组 + 档位 + 强度配色) =====
function collectPriceLines(
levels: Record<LevelType, PriceLevel[]> | undefined,
active: Set<LevelType>,
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)
}
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
</div>
)
}
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 (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
</div>
)}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
: isError ? <AlertCircle className="h-3 w-3" />
: <Check className="h-3 w-3" />}
</span>
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? <span className="text-sky-300/80"></span>
: isError ? <span className="text-red-300/80"></span>
: <span className="text-emerald-300/80"></span>}
</span>
</div>
<style>{`
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
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 */ }
}
@@ -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<HTMLDivElement>(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 (
<AnimatePresence>
<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 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* 头部 —— 蓝色主题 */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-sky-500/[0.06] via-blue-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-sky-300" />
: <LineChart className="h-4.5 w-4.5 text-sky-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 个股分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Sparkles className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-sky-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* 内容区 */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 flex items-center justify-center">
<LineChart className="h-4.5 w-4.5 text-sky-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-sky-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> K / / / ,</div>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</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>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-sky-500/15 border border-sky-400/30 text-xs text-sky-300 hover:bg-sky-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-sky-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* 底部:关注点输入 */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
type="text"
value={focus}
onChange={e => 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 ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
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 '' }
}
@@ -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 <StockAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
+102
View File
@@ -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<LevelType, PriceLevel[]>
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<LevelType, PriceLevel[]>
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<Preferences>('/api/settings/preferences'),
updateMinuteSync: (enabled: boolean, days: number) =>
request<Preferences>('/api/settings/preferences/minute-sync', {
@@ -1258,6 +1294,72 @@ export const api = {
}
},
// ===== 个股分析 =====
stockAnalysisLevels: (symbol: string, days = 120) =>
request<StockLevels>(`/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<LevelType, PriceLevel[]>
}) =>
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<LevelType, PriceLevel[]>
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'),
+1
View File
@@ -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) =>
+268
View File
@@ -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<LevelType, PriceLevel[]>
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<LevelType, PriceLevel[]>
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<ActiveTask>) {
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<void> {
try {
const res = await api.stockAnalysisReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch { /* 静默 */ }
}
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
if (!historyLoaded) await loadHistory()
return history.find(r => r.symbol === symbol) ?? null
}
/**
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
* 判断依据:created_at 的日期部分 == 本地今天。
* @returns 当天最近一条报告,或 null
*/
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
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<void> {
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()
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from 'react'
/**
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
*
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
* - financials: 最后查看的财务分析个股
* - stock-analysis: 最后查看的个股分析个股
*
* 用法:
* const { last, remember } = useLastStock('stock-analysis')
* remember('000001.SZ', '平安银行') // 选中股票时调用
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
*/
export interface StockRef { symbol: string; name: string }
const PREFIX = 'last_stock:'
export function useLastStock(scope: string) {
const [last, setLast] = useState<StockRef | null>(() => 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 */ }
}
+10 -2
View File
@@ -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={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} />
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
@@ -250,7 +258,7 @@ export function Financials() {
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
<StockFinancialSearch onSelect={pick} />
</div>
<button
onClick={() => setSelected(null)}
@@ -269,7 +277,7 @@ export function Financials() {
<span></span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
<StockFinancialSearch onSelect={pick} />
</div>
<div className="text-[11px] text-muted"> 600000 / </div>
</div>
+286 -4
View File
@@ -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<string>('')
const [name, setName] = useState<string>('')
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<string | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-lg text-secondary"></h2>
<p className="mt-2 text-sm text-muted">...</p>
<>
<PageHeader
title="个股分析"
titleExtra={
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
Beta
</span>
}
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={onSelect} />
{symbol && (
<button
onClick={() => setShowHistory(v => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
>
<HistoryIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
}
/>
<div className="px-8 py-6 space-y-6 max-w-7xl">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
<StockFinancialSearch onSelect={onSelect} />
</div>
{symbol && (
<>
<button
onClick={() => setPreviewSymbol(symbol)}
title="查看个股日 K 详情"
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
>
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
<span className="text-[10px] font-mono text-muted">{symbol}</span>
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button
onClick={handleAnalyze}
disabled={checking}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI
</button>
</>
)}
</div>
{/* 主体 */}
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : showHistory ? (
<HistoryList symbol={symbol} />
) : (
<StockAnalysisBoard symbol={symbol} />
)}
</div>
{/* 二次确认:已有历史报告 */}
{confirmReport && (
<ConfirmModal
report={confirmReport}
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
onClose={() => setConfirmReport(null)}
/>
)}
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol === symbol ? name : undefined}
triggerInfo={null}
onClose={() => 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 <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
const rows = kline.data?.rows ?? []
if (rows.length === 0) {
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
}
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
return (
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<div className="flex items-center gap-2">
<LineChart className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground"></span>
<span className="text-[10px] text-muted">
{rows.length} · {levelsQ.data?.close?.toFixed(2) ?? '—'}
</span>
</div>
</div>
<div className="p-3">
<AnalysisKChart
rows={rows}
levels={levels}
defaultLevelTypes={['sr', 'pivot']}
height={480}
/>
</div>
</div>
)
}
// ===== 历史报告列表 =====
function HistoryList({ symbol }: { symbol: string }) {
const { reports, loaded } = useHistoryReports()
const mine = reports.filter(r => r.symbol === symbol)
if (!loaded) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
if (mine.length === 0) {
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
}
return (
<div className="space-y-2">
{mine.map(r => (
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
<div className="flex items-center justify-between gap-3">
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
{r.close && <span className="text-[10px] font-mono text-muted"> {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">: {r.focus}</span>}
</div>
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
>
</button>
</div>
</div>
))}
</div>
)
}
// ===== 二次确认弹窗 =====
function ConfirmModal({ report, onView, onRedo, onClose }: {
report: { id: string; created_at: string; focus: string }
onView: () => void
onRedo: () => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-2">
<HistoryIcon className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground"></span>
</div>
<p className="text-xs text-secondary leading-relaxed mb-1">
<span className="text-foreground">{fmtRelative(report.created_at)}</span>
</p>
{report.focus && <p className="text-xs text-muted mb-1">: {report.focus}</p>}
<p className="text-xs text-muted mb-4">,</p>
<div className="flex gap-2">
<button onClick={onView}
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
</button>
<button onClick={onRedo}
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
</button>
</div>
</div>
</div>
)
}
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 }
}
+76 -9
View File
@@ -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<SettingsState>(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<SettingsState>(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() {
</div>
</div>
{/* ===== 保存 ===== */}
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
{/* ===== 保存 / 清空 ===== */}
<div className="flex gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
className="flex-1 h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
{configured && (
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending}
className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0"
title="清除 API Key、地址、模型等所有 AI 配置(保留自定义 UA)">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{/* 二次确认:清空 AI 配置 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmClear(false)} />
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> AI </h3>
<p className="text-xs text-secondary mb-5 leading-relaxed">
API KeyAPI AI (User-Agent) AI ,
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
</button>
<button onClick={() => clear.mutate()} disabled={clear.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50">
{clear.isPending ? '清空中...' : '确认清空'}
</button>
</div>
</div>
</div>
)}
</div>
)
}