This commit is contained in:
aaron 2026-04-13 14:32:41 +08:00
parent 1f3d4eb047
commit 0dc27af2d1
12 changed files with 260 additions and 107 deletions

4
.gitignore vendored
View File

@ -76,3 +76,7 @@ htmlcov/
*.bak *.bak
*.tmp *.tmp
*.temp *.temp
#cluade
.claude/

146
README.md Normal file
View File

@ -0,0 +1,146 @@
# A股分析推荐 Agent
基于资金驱动的四层漏斗模型,盘中实时分析推荐 A 股。支持趋势突破/缩量回踩/启动型三种策略,结合 LLM (DeepSeek) 生成个股分析。
## 技术栈
- **Backend**: Python 3.10+ / FastAPI / SQLAlchemy / Tushare / APScheduler
- **Frontend**: Next.js 14 / React 18 / TypeScript / Tailwind CSS / ECharts
- **Database**: SQLite (aiosqlite)
- **LLM**: DeepSeek API可选用于 AI 分析)
## 前置条件
- Python 3.10+
- Node.js 18+
- [Tushare Pro](https://tushare.pro/) Token用于获取 A 股行情数据)
- DeepSeek API Key可选不配置则跳过 AI 分析功能)
## 快速开始
### 1. 克隆项目
```bash
git clone <repo-url>
cd astock-agent
```
### 2. 启动 Backend
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp .env.example .env
```
编辑 `backend/.env`,填入你的 Token
```env
ASTOCK_TUSHARE_TOKEN=你的Tushare Token
ASTOCK_DEBUG=true
# 可选:配置后启用 AI 分析功能
ASTOCK_DEEPSEEK_API_KEY=你的DeepSeek API Key
```
启动服务:
```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
首次启动会自动创建 SQLite 数据库和默认管理员账户:
- 用户名:`admin`
- 密码:`admin123`
### 3. 启动 Frontend
```bash
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
前端默认运行在 `http://localhost:3002`,通过 Next.js rewrites 代理 API 请求到后端 `localhost:8000`
### 4. 访问
浏览器打开 http://localhost:3002 ,使用默认管理员账户登录。
## 环境变量说明
| 变量 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `ASTOCK_TUSHARE_TOKEN` | 是 | - | Tushare Pro 数据接口 Token |
| `ASTOCK_DEBUG` | 否 | `false` | 开启调试日志 |
| `ASTOCK_DEEPSEEK_API_KEY` | 否 | - | DeepSeek API Key配置后启用 AI 分析 |
| `ASTOCK_DEEPSEEK_BASE_URL` | 否 | `https://api.deepseek.com/v1` | DeepSeek API 地址 |
| `ASTOCK_FRONTEND_URL` | 否 | `http://localhost:3002` | 前端地址CORS 白名单) |
| `ASTOCK_JWT_SECRET` | 否 | `change-me-in-production` | JWT 签名密钥,生产环境务必修改 |
| `ASTOCK_ADMIN_USERNAME` | 否 | `admin` | 默认管理员用户名 |
| `ASTOCK_ADMIN_PASSWORD` | 否 | `admin123` | 默认管理员密码 |
完整配置项见 `backend/app/config.py`
## 项目结构
```
astock-agent/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI 入口
│ │ ├── config.py # 配置管理
│ │ ├── api/ # API 路由(行情、板块、推荐、个股、聊天、认证)
│ │ ├── analysis/ # 分析引擎(信号检测、板块扫描、选股)
│ │ ├── core/ # 认证、缓存等核心模块
│ │ ├── data/ # 数据层Tushare、腾讯行情客户端
│ │ ├── db/ # 数据库表定义与初始化
│ │ ├── engine/ # 调度器(定时扫描任务)
│ │ └── llm/ # LLM 集成DeepSeek
│ ├── requirements.txt
│ ├── .env.example
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── app/ # Next.js 页面(总览、推荐、板块、个股、对话)
│ │ ├── components/ # 组件(图表、卡片、导航等)
│ │ ├── hooks/ # 自定义 Hooks
│ │ └── lib/ # 工具函数、API 客户端
│ ├── tailwind.config.ts
│ ├── next.config.js
│ └── package.json
└── .gitignore
```
## 构建生产版本
```bash
# Backend
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
# Frontend
cd frontend
npm run build
npm run start
```
## 注意事项
- 首次启动后,系统会在 A 股交易时段9:30-11:30, 13:00-15:00自动执行扫描任务
- 也可以在推荐列表页手动点击「立即扫描」触发
- 数据库为 SQLite数据文件 `astock.db` 自动生成在 `backend/` 目录下
- Tushare API 有请求频率限制,请勿过于频繁地手动触发扫描

Binary file not shown.

View File

@ -12,10 +12,25 @@
"static/chunks/fd9d1056-adfa616cc092a9a4.js", "static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js", "static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/css/863c18dca0735e65.css", "static/css/d058b374bc2164de.css",
"static/chunks/733-a1f3d91858269b2e.js", "static/chunks/733-a1f3d91858269b2e.js",
"static/chunks/app/layout-97d28992dc357af9.js" "static/chunks/app/layout-97d28992dc357af9.js"
], ],
"/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/891-f6021cc9fb1f4075.js",
"static/chunks/app/page-17e83ee269671b5a.js"
],
"/chat/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/chat/page-259ebe7c6b763203.js"
],
"/login/page": [ "/login/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js", "static/chunks/fd9d1056-adfa616cc092a9a4.js",
@ -23,21 +38,13 @@
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/login/page-85bca09c6487284f.js" "static/chunks/app/login/page-85bca09c6487284f.js"
], ],
"/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/891-93568215a17a0b22.js",
"static/chunks/app/page-17e83ee269671b5a.js"
],
"/recommendations/page": [ "/recommendations/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js", "static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js", "static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/891-93568215a17a0b22.js", "static/chunks/891-f6021cc9fb1f4075.js",
"static/chunks/app/recommendations/page-3c6e5705ef2f75aa.js" "static/chunks/app/recommendations/page-80a26fb41740ab9e.js"
], ],
"/sectors/page": [ "/sectors/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
@ -59,13 +66,6 @@
"static/chunks/117-ecf6156e4230cff0.js", "static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/users/page-42113a921614c709.js" "static/chunks/app/users/page-42113a921614c709.js"
],
"/chat/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
"static/chunks/117-ecf6156e4230cff0.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/chat/page-eed4ad2baeec6735.js"
] ]
} }
} }

View File

@ -5,8 +5,8 @@
"devFiles": [], "devFiles": [],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [ "lowPriorityFiles": [
"static/nySi9prB6K7gAs6Lh6cLf/_buildManifest.js", "static/dWWzvd20BkDpBrtD733RH/_buildManifest.js",
"static/nySi9prB6K7gAs6Lh6cLf/_ssgManifest.js" "static/dWWzvd20BkDpBrtD733RH/_ssgManifest.js"
], ],
"rootMainFiles": [ "rootMainFiles": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/_not-found/page": "app/_not-found/page.js",
"/page": "app/page.js",
"/chat/page": "app/chat/page.js",
"/api/chat/stream/route": "app/api/chat/stream/route.js", "/api/chat/stream/route": "app/api/chat/stream/route.js",
"/login/page": "app/login/page.js", "/login/page": "app/login/page.js",
"/page": "app/page.js",
"/recommendations/page": "app/recommendations/page.js", "/recommendations/page": "app/recommendations/page.js",
"/sectors/page": "app/sectors/page.js", "/sectors/page": "app/sectors/page.js",
"/stock/[code]/page": "app/stock/[code]/page.js", "/stock/[code]/page": "app/stock/[code]/page.js",
"/users/page": "app/users/page.js", "/users/page": "app/users/page.js"
"/chat/page": "app/chat/page.js"
} }

View File

@ -1 +1 @@
{"node":{},"edge":{},"encryptionKey":"ZBXt/fi/GVfXipru+YX4u4B8vt56pv9El2dCfGPCuq8="} {"node":{},"edge":{},"encryptionKey":"zQZTP4OVau3TMa/3EnbIENz7Y9ODeKQcCxekJfzqw4o="}

File diff suppressed because one or more lines are too long

View File

@ -87,9 +87,9 @@ export default function ChatPage() {
}; };
return ( return (
<div className="max-w-3xl mx-auto flex flex-col md:h-[calc(100dvh)]"> <div className="max-w-3xl mx-auto flex flex-col h-[100dvh] pb-20 md:pb-10">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border-subtle bg-bg-primary/80 backdrop-blur-xl"> <div className="flex-shrink-0 flex items-center justify-between px-4 sm:px-5 py-3.5 border-b border-border-subtle bg-bg-primary/80 backdrop-blur-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-cyan/30 to-accent-cyan/10 flex items-center justify-center border border-accent-cyan/20"> <div className="w-7 h-7 rounded-lg bg-gradient-to-br from-accent-cyan/30 to-accent-cyan/10 flex items-center justify-center border border-accent-cyan/20">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-accent-cyan/70">
@ -114,7 +114,7 @@ export default function ChatPage() {
</div> </div>
{/* Messages */} {/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-5 space-y-4"> <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 sm:px-5 py-4 sm:py-5 space-y-4">
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in-up"> <div className="flex flex-col items-center justify-center h-full text-center animate-fade-in-up">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-cyan/15 to-accent-cyan/5 flex items-center justify-center mb-5 border border-accent-cyan/10"> <div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent-cyan/15 to-accent-cyan/5 flex items-center justify-center mb-5 border border-accent-cyan/10">

View File

@ -106,9 +106,9 @@ export default function RecommendationsPage() {
return ( return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10"> <div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-5 animate-fade-in-up"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5 animate-fade-in-up">
<div> <div>
<h1 className="text-lg font-bold tracking-tight"></h1> <h1 className="text-base sm:text-lg font-bold tracking-tight"></h1>
<p className="text-xs text-text-muted mt-0.5"> <p className="text-xs text-text-muted mt-0.5">
<span className="font-mono tabular-nums">{totalCount}</span> · <span className="font-mono tabular-nums">{totalCount}</span> ·
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> <span className="font-mono tabular-nums ml-1">{dayGroups.length}</span>
@ -117,7 +117,7 @@ export default function RecommendationsPage() {
<button <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium" className="text-xs px-3 py-1.5 sm:px-4 sm:py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg sm:rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium self-start sm:self-auto"
> >
{refreshing ? ( {refreshing ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
@ -139,20 +139,20 @@ export default function RecommendationsPage() {
)} )}
{/* Filter tabs */} {/* Filter tabs */}
<div className="flex gap-2 mb-5 overflow-x-auto pb-1 animate-fade-in-up delay-75"> <div className="flex gap-1.5 sm:gap-2 mb-4 sm:mb-5 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0 animate-fade-in-up delay-75">
{[ {[
{ key: "all", label: "全部" }, { key: "all", label: "全部" },
{ key: "breakout", label: "突破" }, { key: "breakout", label: "突破" },
{ key: "pullback", label: "回踩" }, { key: "pullback", label: "回踩" },
{ key: "launch", label: "启动" }, { key: "launch", label: "启动" },
{ key: "buy", label: "买入信号" }, { key: "buy", label: "买入" },
{ key: "强烈推荐", label: "强烈推荐" }, { key: "强烈推荐", label: "强烈" },
{ key: "推荐", label: "推荐" }, { key: "推荐", label: "推荐" },
].map(({ key, label }) => ( ].map(({ key, label }) => (
<button <button
key={key} key={key}
onClick={() => setFilter(key)} onClick={() => setFilter(key)}
className={`text-xs px-4 py-1.5 rounded-xl whitespace-nowrap transition-all duration-200 font-medium ${ className={`text-[11px] sm:text-xs px-2.5 sm:px-4 py-1.5 rounded-lg sm:rounded-xl whitespace-nowrap transition-all duration-200 font-medium ${
filter === key filter === key
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15" ? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
: "bg-surface-2 text-text-muted hover:text-text-secondary hover:bg-surface-4 border border-transparent" : "bg-surface-2 text-text-muted hover:text-text-secondary hover:bg-surface-4 border border-transparent"
@ -165,12 +165,12 @@ export default function RecommendationsPage() {
{/* Day groups */} {/* Day groups */}
{dayGroups.length === 0 ? ( {dayGroups.length === 0 ? (
<div className="glass-card-static p-12 text-center animate-fade-in-up"> <div className="glass-card-static p-6 sm:p-12 text-center animate-fade-in-up">
<div className="text-text-muted text-sm mb-1"></div> <div className="text-text-muted text-sm mb-1"></div>
<div className="text-text-muted/50 text-xs"></div> <div className="text-text-muted/50 text-xs"></div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-3 sm:space-y-4">
{dayGroups.map((group, gi) => { {dayGroups.map((group, gi) => {
const filtered = applyFilter(group.recommendations); const filtered = applyFilter(group.recommendations);
if (filter !== "all" && filtered.length === 0) return null; if (filter !== "all" && filtered.length === 0) return null;

View File

@ -26,38 +26,38 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
return ( return (
<a <a
href={`/stock/${rec.ts_code}`} href={`/stock/${rec.ts_code}`}
className="block glass-card p-4 md:p-5 group" className="block glass-card p-3 sm:p-4 md:p-5 group"
> >
{/* Header: Name + Strategy + Score */} {/* Header: Name + Strategy + Score */}
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<span className="font-semibold text-sm tracking-tight">{rec.name}</span> <span className="font-semibold text-sm tracking-tight">{rec.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}> <span className={`text-[10px] sm:text-xs px-1 py-0.5 rounded-full font-medium ${badge.bg} ${badge.text}`}>
{rec.level} {rec.level}
</span> </span>
{tag && ( {tag && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-medium border ${tag.style}`}> <span className={`text-[9px] sm:text-[10px] px-1 py-0.5 rounded-md font-medium border ${tag.style}`}>
{tag.label} {tag.label}
</span> </span>
)} )}
</div> </div>
<div className="text-[11px] text-text-muted mt-1 font-mono tabular-nums truncate"> <div className="text-[10px] sm:text-[11px] text-text-muted mt-0.5 sm:mt-1 font-mono tabular-nums truncate">
{rec.ts_code} <span className="text-text-muted/40 mx-0.5">·</span> {rec.sector} {rec.ts_code} <span className="text-text-muted/40 mx-0.5">·</span> {rec.sector}
</div> </div>
</div> </div>
<div className="text-right shrink-0 ml-3"> <div className="text-right shrink-0 ml-2 sm:ml-3">
<div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}> <div className={`text-lg sm:text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
{rec.score} {rec.score}
</div> </div>
<div className={`text-xs font-semibold tracking-wider ${getSignalColor(rec.signal)}`}> <div className={`text-[10px] sm:text-xs font-semibold tracking-wider ${getSignalColor(rec.signal)}`}>
{rec.signal === "BUY" ? "买入" : rec.signal === "SELL" ? "卖出" : "持有"} {rec.signal === "BUY" ? "买入" : rec.signal === "SELL" ? "卖出" : "持有"}
</div> </div>
</div> </div>
</div> </div>
{/* Four dimension score bars */} {/* Four dimension score bars */}
<div className="grid grid-cols-4 gap-2 mb-4"> <div className="grid grid-cols-4 gap-1 sm:gap-2 mb-3 sm:mb-4">
<ScoreBar label="市场" value={rec.market_temp_score} /> <ScoreBar label="市场" value={rec.market_temp_score} />
<ScoreBar label="板块" value={rec.sector_score} /> <ScoreBar label="板块" value={rec.sector_score} />
<ScoreBar label="资金" value={rec.capital_score} /> <ScoreBar label="资金" value={rec.capital_score} />
@ -66,47 +66,50 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
{/* Price reference */} {/* Price reference */}
{rec.entry_price && ( {rec.entry_price && (
<div className="grid grid-cols-3 gap-2 mb-3 bg-surface-2 rounded-xl px-3 md:px-4 py-2.5 border border-border-subtle text-xs"> <div className="grid grid-cols-3 gap-1.5 sm:gap-2 mb-2 sm:mb-3 bg-surface-2 rounded-lg sm:rounded-xl px-2.5 sm:px-3 md:px-4 py-2 border border-border-subtle text-[10px] sm:text-xs">
<div> <div>
<span className="text-text-muted block text-[10px]"></span> <span className="text-text-muted block text-[9px] sm:text-[10px]"></span>
<span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span> <span className="text-red-400 font-mono tabular-nums">{rec.entry_price}</span>
</div> </div>
<div> <div>
<span className="text-text-muted block text-[10px]"></span> <span className="text-text-muted block text-[9px] sm:text-[10px]"></span>
<span className="text-amber-400 font-mono tabular-nums">{rec.target_price}</span> <span className="text-amber-400 font-mono tabular-nums">{rec.target_price}</span>
</div> </div>
<div> <div>
<span className="text-text-muted block text-[10px]"></span> <span className="text-text-muted block text-[9px] sm:text-[10px]"></span>
<span className="text-emerald-400 font-mono tabular-nums">{rec.stop_loss}</span> <span className="text-emerald-400 font-mono tabular-nums">{rec.stop_loss}</span>
</div> </div>
</div> </div>
)} )}
{/* Reasons */} {/* Reasons - show max 2 on mobile, 3 on desktop */}
<div className="space-y-1.5"> <div className="space-y-1 sm:space-y-1.5">
{rec.reasons.map((r, i) => ( {rec.reasons.slice(0, 2).map((r, i) => (
<div key={i} className="text-xs text-text-secondary flex items-start gap-2"> <div key={i} className="text-[10px] sm:text-xs text-text-secondary flex items-start gap-1.5 sm:gap-2">
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[7px] shrink-0" /> <span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[5px] sm:mt-[7px] shrink-0" />
<span className="leading-relaxed">{r}</span> <span className="leading-relaxed line-clamp-2">{r}</span>
</div> </div>
))} ))}
{rec.reasons.length > 2 && (
<div className="text-[9px] sm:text-[10px] text-text-muted/60">+{rec.reasons.length - 2}</div>
)}
</div> </div>
{/* AI Analysis — collapsible */} {/* AI Analysis — collapsible */}
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? ( {rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? (
<div className="mt-3"> <div className="mt-2 sm:mt-3">
<button <button
onClick={(e) => { e.preventDefault(); setAiExpanded(!aiExpanded); }} onClick={(e) => { e.preventDefault(); setAiExpanded(!aiExpanded); }}
className="w-full flex items-center justify-between bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-xl px-4 py-2.5 hover:bg-accent-cyan/[0.09] transition-colors" className="w-full flex items-center justify-between bg-accent-cyan/[0.06] border border-accent-cyan/[0.12] rounded-lg sm:rounded-xl px-3 sm:px-4 py-2 hover:bg-accent-cyan/[0.09] transition-colors"
> >
<div className="flex items-center gap-2 text-xs text-accent-cyan/80 font-semibold tracking-wider"> <div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-accent-cyan/80 font-semibold tracking-wider">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${aiExpanded ? "rotate-90" : ""}`}>
<path d="M9 18l6-6-6-6" /> <path d="M9 18l6-6-6-6" />
</svg> </svg>
AI AI
</div> </div>
{rec.llm_score != null && ( {rec.llm_score != null && (
<div className="text-xs font-mono tabular-nums"> <div className="text-[10px] sm:text-xs font-mono tabular-nums">
<span className="text-text-muted"> </span> <span className="text-text-muted"> </span>
<span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}> <span className={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
{rec.llm_score} {rec.llm_score}
@ -116,18 +119,18 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
)} )}
</button> </button>
{aiExpanded && ( {aiExpanded && (
<div className="text-xs text-text-secondary leading-relaxed whitespace-pre-line bg-accent-cyan/[0.03] border border-t-0 border-accent-cyan/[0.08] rounded-b-xl px-4 py-3"> <div className="text-[10px] sm:text-xs text-text-secondary leading-relaxed whitespace-pre-line bg-accent-cyan/[0.03] border border-t-0 border-accent-cyan/[0.08] rounded-b-lg sm:rounded-b-xl px-3 sm:px-4 py-2 sm:py-3">
<MarkdownText text={rec.llm_analysis} /> <MarkdownText text={rec.llm_analysis} />
</div> </div>
)} )}
</div> </div>
) : rec.llm_analysis === "AI 分析暂时不可用" ? ( ) : rec.llm_analysis === "AI 分析暂时不可用" ? (
<div className="mt-3 text-xs text-text-muted/50 flex items-center gap-1.5"> <div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted/50 flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-text-muted/30" /> <span className="w-1 h-1 rounded-full bg-text-muted/30" />
AI AI
</div> </div>
) : showLLMLoading ? ( ) : showLLMLoading ? (
<div className="mt-3 text-xs text-text-muted flex items-center gap-2"> <div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-text-muted flex items-center gap-2">
<span className="inline-block w-3 h-3 border border-accent-cyan/30 border-t-accent-cyan/80 rounded-full animate-spin" /> <span className="inline-block w-3 h-3 border border-accent-cyan/30 border-t-accent-cyan/80 rounded-full animate-spin" />
AI ... AI ...
</div> </div>
@ -135,13 +138,13 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
{/* Risk note */} {/* Risk note */}
{rec.risk_note && ( {rec.risk_note && (
<div className="mt-3 text-xs text-amber-500/60 bg-amber-500/[0.04] border border-amber-500/[0.08] rounded-lg px-3 py-1.5"> <div className="mt-2 sm:mt-3 text-[10px] sm:text-xs text-amber-500/60 bg-amber-500/[0.04] border border-amber-500/[0.08] rounded-lg px-2.5 sm:px-3 py-1.5">
{rec.risk_note} {rec.risk_note}
</div> </div>
)} )}
{/* Hover indicator */} {/* Hover indicator - hidden on mobile */}
<div className="mt-3 flex items-center gap-1 text-xs text-text-muted opacity-0 group-hover:opacity-100 transition-opacity duration-300"> <div className="mt-2 sm:mt-3 hidden sm:flex items-center gap-1 text-xs text-text-muted opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span></span> <span></span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M12 5l7 7-7 7" /> <path d="M5 12h14M12 5l7 7-7 7" />
@ -236,11 +239,11 @@ function ScoreBar({ label, value }: { label: string; value: number }) {
const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low"; const gradientClass = value >= 70 ? "score-bar-gradient-high" : value >= 50 ? "score-bar-gradient-mid" : "score-bar-gradient-low";
return ( return (
<div> <div>
<div className="flex justify-between text-xs text-text-muted mb-1"> <div className="flex justify-between text-[9px] sm:text-[10px] xs:text-xs text-text-muted mb-0.5 sm:mb-1">
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
<span className="font-mono tabular-nums">{value.toFixed(0)}</span> <span className="font-mono tabular-nums">{value.toFixed(0)}</span>
</div> </div>
<div className="h-1.5 bg-surface-3 rounded-full overflow-hidden"> <div className="h-1 bg-surface-3 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`} className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
style={{ width: `${width}%` }} style={{ width: `${width}%` }}