1
This commit is contained in:
parent
1f3d4eb047
commit
0dc27af2d1
4
.gitignore
vendored
4
.gitignore
vendored
@ -76,3 +76,7 @@ htmlcov/
|
||||
*.bak
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
|
||||
#cluade
|
||||
.claude/
|
||||
146
README.md
Normal file
146
README.md
Normal 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.
@ -12,10 +12,25 @@
|
||||
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
|
||||
"static/chunks/117-ecf6156e4230cff0.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/css/863c18dca0735e65.css",
|
||||
"static/css/d058b374bc2164de.css",
|
||||
"static/chunks/733-a1f3d91858269b2e.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": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-adfa616cc092a9a4.js",
|
||||
@ -23,21 +38,13 @@
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.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": [
|
||||
"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/recommendations/page-3c6e5705ef2f75aa.js"
|
||||
"static/chunks/891-f6021cc9fb1f4075.js",
|
||||
"static/chunks/app/recommendations/page-80a26fb41740ab9e.js"
|
||||
],
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
@ -59,13 +66,6 @@
|
||||
"static/chunks/117-ecf6156e4230cff0.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/nySi9prB6K7gAs6Lh6cLf/_buildManifest.js",
|
||||
"static/nySi9prB6K7gAs6Lh6cLf/_ssgManifest.js"
|
||||
"static/dWWzvd20BkDpBrtD733RH/_buildManifest.js",
|
||||
"static/dWWzvd20BkDpBrtD733RH/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,11 +1,11 @@
|
||||
{
|
||||
"/_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",
|
||||
"/login/page": "app/login/page.js",
|
||||
"/page": "app/page.js",
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
||||
"/users/page": "app/users/page.js",
|
||||
"/chat/page": "app/chat/page.js"
|
||||
"/users/page": "app/users/page.js"
|
||||
}
|
||||
@ -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
@ -87,9 +87,9 @@ export default function ChatPage() {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<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="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">
|
||||
@ -114,7 +114,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<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">
|
||||
|
||||
@ -106,9 +106,9 @@ export default function RecommendationsPage() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||||
{/* 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>
|
||||
<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">
|
||||
共 <span className="font-mono tabular-nums">{totalCount}</span> 只 ·
|
||||
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
||||
@ -117,7 +117,7 @@ export default function RecommendationsPage() {
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
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 ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@ -139,20 +139,20 @@ export default function RecommendationsPage() {
|
||||
)}
|
||||
|
||||
{/* 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: "breakout", label: "突破型" },
|
||||
{ key: "pullback", label: "回踩型" },
|
||||
{ key: "launch", label: "启动型" },
|
||||
{ key: "buy", label: "买入信号" },
|
||||
{ key: "强烈推荐", label: "强烈推荐" },
|
||||
{ key: "breakout", label: "突破" },
|
||||
{ key: "pullback", label: "回踩" },
|
||||
{ key: "launch", label: "启动" },
|
||||
{ key: "buy", label: "买入" },
|
||||
{ key: "强烈推荐", label: "强烈" },
|
||||
{ key: "推荐", label: "推荐" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={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
|
||||
? "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"
|
||||
@ -165,12 +165,12 @@ export default function RecommendationsPage() {
|
||||
|
||||
{/* Day groups */}
|
||||
{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/50 text-xs">触发新的扫描以生成推荐</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{dayGroups.map((group, gi) => {
|
||||
const filtered = applyFilter(group.recommendations);
|
||||
if (filter !== "all" && filtered.length === 0) return null;
|
||||
|
||||
@ -26,38 +26,38 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
return (
|
||||
<a
|
||||
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 */}
|
||||
<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="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={`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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<div className={`text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
|
||||
<div className="text-right shrink-0 ml-2 sm:ml-3">
|
||||
<div className={`text-lg sm:text-xl font-bold font-mono tabular-nums tracking-tight ${getScoreColor(rec.score)}`}>
|
||||
{rec.score}
|
||||
</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" ? "卖出" : "持有"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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.sector_score} />
|
||||
<ScoreBar label="资金" value={rec.capital_score} />
|
||||
@ -66,47 +66,50 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
|
||||
{/* Price reference */}
|
||||
{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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasons */}
|
||||
<div className="space-y-1.5">
|
||||
{rec.reasons.map((r, i) => (
|
||||
<div key={i} className="text-xs text-text-secondary flex items-start gap-2">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-500/60 mt-[7px] shrink-0" />
|
||||
<span className="leading-relaxed">{r}</span>
|
||||
{/* Reasons - show max 2 on mobile, 3 on desktop */}
|
||||
<div className="space-y-1 sm:space-y-1.5">
|
||||
{rec.reasons.slice(0, 2).map((r, i) => (
|
||||
<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-[5px] sm:mt-[7px] shrink-0" />
|
||||
<span className="leading-relaxed line-clamp-2">{r}</span>
|
||||
</div>
|
||||
))}
|
||||
{rec.reasons.length > 2 && (
|
||||
<div className="text-[9px] sm:text-[10px] text-text-muted/60">+{rec.reasons.length - 2}条原因</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Analysis — collapsible */}
|
||||
{rec.llm_analysis && rec.llm_analysis !== "AI 分析暂时不可用" ? (
|
||||
<div className="mt-3">
|
||||
<div className="mt-2 sm:mt-3">
|
||||
<button
|
||||
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">
|
||||
<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" : ""}`}>
|
||||
<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="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" />
|
||||
</svg>
|
||||
AI 分析
|
||||
</div>
|
||||
{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={`font-bold ${rec.llm_score >= 7 ? "text-amber-400" : rec.llm_score >= 5 ? "text-cyan-400" : "text-text-muted"}`}>
|
||||
{rec.llm_score}
|
||||
@ -116,18 +119,18 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
)}
|
||||
</button>
|
||||
{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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : 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" />
|
||||
AI 分析暂时不可用
|
||||
</div>
|
||||
) : 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" />
|
||||
AI 分析中...
|
||||
</div>
|
||||
@ -135,13 +138,13 @@ export default function StockCard({ rec, showLLMLoading = false }: { rec: Recomm
|
||||
|
||||
{/* 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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover indicator */}
|
||||
<div className="mt-3 flex items-center gap-1 text-xs text-text-muted opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{/* Hover indicator - hidden on mobile */}
|
||||
<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>
|
||||
<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" />
|
||||
@ -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";
|
||||
return (
|
||||
<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-mono tabular-nums">{value.toFixed(0)}</span>
|
||||
</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
|
||||
className={`h-full rounded-full transition-all duration-700 ease-out ${gradientClass}`}
|
||||
style={{ width: `${width}%` }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user