commit
a8a84c00da
@ -0,0 +1,255 @@
|
||||
# 智能期货期权分析系统 - 功能实现总结
|
||||
|
||||
## 📋 已完成的功能
|
||||
|
||||
### 1. 后端服务 (NestJS + TypeScript)
|
||||
|
||||
#### ✅ 基础架构
|
||||
- 模块化项目结构,易于扩展
|
||||
- 全局异常处理、日志记录
|
||||
- API限流保护
|
||||
- Swagger API文档自动生成
|
||||
- JWT认证与授权
|
||||
|
||||
#### ✅ 数据库设计 (Prisma)
|
||||
- 用户系统
|
||||
- 期货品种管理
|
||||
- K线数据存储
|
||||
- 实时Tick数据缓存
|
||||
- 热点事件管理
|
||||
- 自选股功能
|
||||
- 价格预警系统
|
||||
- 期权合约管理
|
||||
|
||||
#### ✅ 用户认证系统
|
||||
- 用户注册/登录/登出
|
||||
- JWT Token认证
|
||||
- 密码加密存储
|
||||
- 用户信息管理
|
||||
|
||||
#### ✅ 行情数据服务
|
||||
- RESTful API获取品种列表、K线数据、实时行情
|
||||
- WebSocket实时推送Tick数据
|
||||
- 市场概览数据聚合
|
||||
- Redis缓存加速
|
||||
|
||||
#### ✅ 技术指标计算引擎
|
||||
- MACD (异同移动平均线)
|
||||
- RSI (相对强弱指标)
|
||||
- KDJ (随机指标)
|
||||
- BOLL (布林带)
|
||||
- SAR (抛物线转向)
|
||||
- OBV (能量潮)
|
||||
- DMI (趋向指标)
|
||||
- CCI (商品通道指数)
|
||||
- WR (威廉指标)
|
||||
- 移动平均线 (MA5/MA10/MA20/MA60)
|
||||
|
||||
#### ✅ 期权分析模块
|
||||
- Black-Scholes期权定价模型
|
||||
- 希腊值计算 (Delta, Gamma, Theta, Vega, Rho)
|
||||
- 隐含波动率计算
|
||||
- 期权链数据生成
|
||||
- 波动率曲面分析
|
||||
- 期权策略盈亏计算
|
||||
|
||||
#### ✅ AI智能分析模块
|
||||
- 集成OpenAI API进行智能分析
|
||||
- 基于规则的技术面分析 (Fallback)
|
||||
- 趋势判断和交易建议
|
||||
- 支撑阻力位计算
|
||||
- 风险评估
|
||||
|
||||
#### ✅ 业务功能
|
||||
- 热点事件管理 (增删改查)
|
||||
- 自选股功能 (增删改查、价格预警)
|
||||
- 价格预警系统 (定时检查、触发通知)
|
||||
|
||||
### 2. 前端API服务
|
||||
|
||||
#### ✅ HTTP客户端封装
|
||||
- Axios拦截器统一处理
|
||||
- Token自动注入
|
||||
- 统一错误处理
|
||||
- API模块化组织
|
||||
|
||||
#### ✅ WebSocket客户端
|
||||
- Socket.io连接管理
|
||||
- 自动重连机制
|
||||
- 订阅/取消订阅
|
||||
- 事件监听封装
|
||||
|
||||
### 3. 部署配置
|
||||
|
||||
#### ✅ Docker支持
|
||||
- 前端Dockerfile (Nginx)
|
||||
- 后端Dockerfile (Node.js)
|
||||
- Docker Compose编排
|
||||
- 健康检查配置
|
||||
|
||||
#### ✅ 环境配置
|
||||
- 环境变量模板
|
||||
- 多环境支持 (dev/prod)
|
||||
- 数据库迁移脚本
|
||||
- 种子数据导入
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
AlphaFuturesProMax/
|
||||
├── app/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI组件
|
||||
│ │ ├── services/ # API和WebSocket服务
|
||||
│ │ │ ├── api.ts # HTTP API封装
|
||||
│ │ │ └── websocket.ts # WebSocket服务
|
||||
│ │ └── ...
|
||||
│ ├── Dockerfile # 前端Docker配置
|
||||
│ └── nginx.conf # Nginx配置
|
||||
│
|
||||
├── app/server/ # 后端服务
|
||||
│ ├── src/
|
||||
│ │ ├── auth/ # 认证模块
|
||||
│ │ ├── market/ # 行情数据模块
|
||||
│ │ ├── indicators/ # 技术指标模块
|
||||
│ │ ├── options/ # 期权分析模块
|
||||
│ │ ├── ai/ # AI分析模块
|
||||
│ │ ├── events/ # 热点事件模块
|
||||
│ │ ├── watchlist/ # 自选股模块
|
||||
│ │ ├── alert/ # 价格预警模块
|
||||
│ │ ├── user/ # 用户模块
|
||||
│ │ ├── common/ # 公共模块
|
||||
│ │ │ ├── prisma/ # Prisma服务
|
||||
│ │ │ └── redis/ # Redis服务
|
||||
│ │ └── config/ # 配置文件
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # 数据库模型
|
||||
│ │ └── seed.ts # 种子数据
|
||||
│ ├── Dockerfile # 后端Docker配置
|
||||
│ └── package.json
|
||||
│
|
||||
├── docker-compose.yml # Docker编排配置
|
||||
├── DEPLOY.md # 部署文档
|
||||
└── docs/ # 项目文档
|
||||
```
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### Docker方式 (推荐)
|
||||
|
||||
```bash
|
||||
# 1. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 2. 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 3. 查看日志
|
||||
docker-compose logs -f backend
|
||||
|
||||
# 4. 访问应用
|
||||
# 前端: http://localhost
|
||||
# API: http://localhost:3000/api/v1
|
||||
# 文档: http://localhost:3000/docs
|
||||
```
|
||||
|
||||
### 手动部署
|
||||
|
||||
见 `DEPLOY.md` 详细说明
|
||||
|
||||
## 📡 API接口
|
||||
|
||||
### 认证接口
|
||||
- `POST /api/v1/auth/register` - 注册
|
||||
- `POST /api/v1/auth/login` - 登录
|
||||
- `POST /api/v1/auth/logout` - 登出
|
||||
- `GET /api/v1/auth/profile` - 获取用户信息
|
||||
|
||||
### 行情接口
|
||||
- `GET /api/v1/market/products` - 品种列表
|
||||
- `GET /api/v1/market/products/:symbol` - 品种详情
|
||||
- `GET /api/v1/market/products/:symbol/kline` - K线数据
|
||||
- `GET /api/v1/market/products/:symbol/tick` - 实时行情
|
||||
- `GET /api/v1/market/overview` - 市场概览
|
||||
|
||||
### 技术指标接口
|
||||
- `GET /api/v1/indicators/:symbol` - 所有指标
|
||||
- `GET /api/v1/indicators/:symbol/macd` - MACD
|
||||
- `GET /api/v1/indicators/:symbol/rsi` - RSI
|
||||
- `GET /api/v1/indicators/:symbol/bollinger` - 布林带
|
||||
- `GET /api/v1/indicators/:symbol/kdj` - KDJ
|
||||
|
||||
### 期权接口
|
||||
- `POST /api/v1/options/pricing` - 期权定价
|
||||
- `GET /api/v1/options/chain/:underlying` - 期权链
|
||||
- `GET /api/v1/options/volatility-surface/:underlying` - 波动率曲面
|
||||
- `POST /api/v1/options/strategy/payoff` - 策略盈亏
|
||||
|
||||
### AI分析接口
|
||||
- `GET /api/v1/ai/analyze/:symbol` - AI分析品种
|
||||
|
||||
### 事件接口
|
||||
- `GET /api/v1/events` - 热点事件列表
|
||||
- `GET /api/v1/events/:id` - 事件详情
|
||||
|
||||
### WebSocket
|
||||
- `ws://localhost:3000/market` - 行情实时推送
|
||||
|
||||
## 🔧 扩展建议
|
||||
|
||||
### 数据源接入
|
||||
目前使用模拟数据,可接入真实数据源:
|
||||
- Wind金融终端API
|
||||
- 同花顺iFinD API
|
||||
- 交易所官方API
|
||||
|
||||
实现方式:
|
||||
1. 在 `market-data-feed.service.ts` 中实现新的数据源适配器
|
||||
2. 修改配置 `MARKET_DATA_PROVIDER`
|
||||
|
||||
### 新增技术指标
|
||||
在 `indicators.service.ts` 中添加新的计算方法:
|
||||
```typescript
|
||||
calculateNewIndicator(klineData: KLineData[], params: any): Result[] {
|
||||
// 实现计算逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 新增期权模型
|
||||
在 `options.service.ts` 中添加新的定价模型:
|
||||
```typescript
|
||||
calculateBinomialModel(S: number, K: number, ...): OptionPricingResult {
|
||||
// 实现二叉树模型
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 已实现
|
||||
- Redis缓存加速
|
||||
- WebSocket实时推送
|
||||
- API响应压缩
|
||||
- 数据库索引优化
|
||||
|
||||
### 建议优化
|
||||
- 数据库读写分离
|
||||
- API限流
|
||||
- 静态资源CDN
|
||||
- 应用监控 (Prometheus + Grafana)
|
||||
|
||||
## 🛡️ 安全建议
|
||||
|
||||
1. 生产环境必须修改JWT密钥
|
||||
2. 启用HTTPS
|
||||
3. 配置CORS白名单
|
||||
4. 数据库使用强密码
|
||||
5. 定期更新依赖包
|
||||
|
||||
## 📝 待实现功能
|
||||
|
||||
- [ ] 交易信号系统 (多指标共振)
|
||||
- [ ] 量化策略回测引擎
|
||||
- [ ] 移动端APP (React Native/Flutter)
|
||||
- [ ] 管理后台
|
||||
- [ ] 会员系统
|
||||
- [ ] 模拟交易功能
|
||||
@ -0,0 +1,31 @@
|
||||
# 前端构建
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建生产版本
|
||||
RUN npm run build
|
||||
|
||||
# Nginx服务
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制nginx配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "postcss.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能期货期权分析系统</title>
|
||||
<script type="module" crossorigin src="./assets/index-BYfknKCx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BYX1qVyV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能期货期权分析系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,31 @@
|
||||
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
|
||||
|
||||
Tailwind CSS has been set up with the shadcn theme
|
||||
|
||||
Setup complete: /mnt/okcomputer/output/app
|
||||
|
||||
Components (40+):
|
||||
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
|
||||
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
|
||||
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
|
||||
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
|
||||
navigation-menu, pagination, popover, progress, radio-group, resizable,
|
||||
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
|
||||
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
|
||||
|
||||
Usage:
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
Structure:
|
||||
src/sections/ Page sections
|
||||
src/hooks/ Custom hooks
|
||||
src/types/ Type definitions
|
||||
src/App.css Styles specific to the Webapp
|
||||
src/App.tsx Root React component
|
||||
src/index.css Global styles
|
||||
src/main.tsx Entry point for rendering the Webapp
|
||||
index.html Entry point for the Webapp
|
||||
tailwind.config.js Configures Tailwind's theme, plugins, etc.
|
||||
vite.config.ts Main build and dev server settings for Vite
|
||||
postcss.config.js Config file for CSS post-processing tools
|
||||
@ -0,0 +1,59 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# 缓存静态资源
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
}
|
||||
|
||||
# API代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# WebSocket代理
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3000/socket.io/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# 前端路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"axios": "^1.6.5",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"echarts": "^5.4.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-resizable-panels": "^4.2.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"kimi-plugin-inspect-react": "^1.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
API_PREFIX=/api/v1
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis?schema=public
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# 行情数据源配置
|
||||
MARKET_DATA_PROVIDER=mock
|
||||
MARKET_DATA_API_KEY=
|
||||
MARKET_DATA_API_SECRET=
|
||||
MARKET_DATA_WS_URL=
|
||||
|
||||
# OpenAI配置 (用于AI分析)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=gpt-4-turbo-preview
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=debug
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# 安全配置
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# 监控配置
|
||||
METRICS_ENABLED=true
|
||||
METRICS_PORT=9090
|
||||
@ -0,0 +1,47 @@
|
||||
# 应用配置
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
API_PREFIX=/api/v1
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis?schema=public
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# 行情数据源配置
|
||||
MARKET_DATA_PROVIDER=mock
|
||||
MARKET_DATA_API_KEY=
|
||||
MARKET_DATA_API_SECRET=
|
||||
MARKET_DATA_WS_URL=
|
||||
|
||||
# OpenAI配置 (用于AI分析)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=gpt-4-turbo-preview
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=debug
|
||||
LOG_FILE=logs/app.log
|
||||
|
||||
# 安全配置
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# 监控配置
|
||||
METRICS_ENABLED=true
|
||||
METRICS_PORT=9090
|
||||
@ -0,0 +1,44 @@
|
||||
# 构建阶段
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制package文件
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 生成Prisma客户端
|
||||
RUN npx prisma generate
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装生产依赖和必要库
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/v1/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# 启动命令 - 先安装openssl再启动
|
||||
CMD ["sh", "-c", "apk add --no-cache openssl1.1-compat 2>/dev/null || apk add --no-cache openssl; node dist/src/main.js"]
|
||||
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": false
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "futures-analysis-server",
|
||||
"version": "1.0.0",
|
||||
"description": "智能期货期权分析系统 - 后端服务",
|
||||
"author": "AlphaFutures Team",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/platform-socket.io": "^10.3.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.1",
|
||||
"@nestjs/websockets": "^10.3.0",
|
||||
"@prisma/client": "^5.9.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"compression": "^1.7.4",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"openai": "^4.28.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.9.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// 用户表
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(50)
|
||||
email String @unique @db.VarChar(100)
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
phone String? @db.VarChar(20)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar(255)
|
||||
membershipLevel Int @default(0) @map("membership_level")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// 关联
|
||||
watchlist Watchlist[]
|
||||
priceAlerts PriceAlert[]
|
||||
paperTrades PaperTrade[]
|
||||
sessions UserSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// 用户会话表
|
||||
model UserSession {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
token String @unique @db.VarChar(500)
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([token])
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
// 期货品种表
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
symbol String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(100)
|
||||
category String @db.VarChar(50)
|
||||
exchange String? @db.VarChar(50)
|
||||
unit String? @db.VarChar(20)
|
||||
minChange Decimal? @map("min_change") @db.Decimal(10, 4)
|
||||
description String? @db.Text
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// 关联
|
||||
klineData KlineData[]
|
||||
tickData TickData[]
|
||||
events EventProduct[]
|
||||
signals TradingSignal[]
|
||||
options OptionContract[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
// K线数据表
|
||||
model KlineData {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @map("product_id")
|
||||
period String @db.VarChar(10) // 1m, 5m, 15m, 30m, 1h, 1d, 1w
|
||||
time DateTime
|
||||
open Decimal @db.Decimal(18, 6)
|
||||
high Decimal @db.Decimal(18, 6)
|
||||
low Decimal @db.Decimal(18, 6)
|
||||
close Decimal @db.Decimal(18, 6)
|
||||
volume BigInt
|
||||
openInterest BigInt? @map("open_interest")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, period, time])
|
||||
@@index([productId, period, time])
|
||||
@@map("kline_data")
|
||||
}
|
||||
|
||||
// Tick实时数据表
|
||||
model TickData {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @map("product_id")
|
||||
price Decimal @db.Decimal(18, 6)
|
||||
change Decimal @db.Decimal(18, 6)
|
||||
changePercent Decimal @map("change_percent") @db.Decimal(10, 4)
|
||||
open Decimal @db.Decimal(18, 6)
|
||||
high Decimal @db.Decimal(18, 6)
|
||||
low Decimal @db.Decimal(18, 6)
|
||||
volume BigInt
|
||||
openInterest BigInt? @map("open_interest")
|
||||
bidPrice Decimal? @map("bid_price") @db.Decimal(18, 6)
|
||||
askPrice Decimal? @map("ask_price") @db.Decimal(18, 6)
|
||||
bidVolume BigInt? @map("bid_volume")
|
||||
askVolume BigInt? @map("ask_volume")
|
||||
timestamp DateTime
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([productId, timestamp])
|
||||
@@map("tick_data")
|
||||
}
|
||||
|
||||
// 自选股表
|
||||
model Watchlist {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
symbol String @db.VarChar(20)
|
||||
alertPrice Decimal? @map("alert_price") @db.Decimal(18, 6)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, symbol])
|
||||
@@index([userId])
|
||||
@@map("watchlist")
|
||||
}
|
||||
|
||||
// 价格预警表
|
||||
model PriceAlert {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
symbol String @db.VarChar(20)
|
||||
alertType String @map("alert_type") @db.VarChar(20) // above, below
|
||||
alertPrice Decimal @map("alert_price") @db.Decimal(18, 6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
triggeredAt DateTime? @map("triggered_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([symbol, isActive])
|
||||
@@map("price_alerts")
|
||||
}
|
||||
|
||||
// 热点事件表
|
||||
model HotEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
title String @db.VarChar(255)
|
||||
content String? @db.Text
|
||||
summary String? @db.Text
|
||||
impact String @db.VarChar(20) // bullish, bearish, neutral
|
||||
impactLevel Int @map("impact_level")
|
||||
source String? @db.VarChar(100)
|
||||
analysis String? @db.Text
|
||||
risks String[] @db.VarChar(255)
|
||||
eventTime DateTime? @map("event_time")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// 关联
|
||||
affectedProducts EventProduct[]
|
||||
|
||||
@@map("hot_events")
|
||||
}
|
||||
|
||||
// 事件影响品种关联表
|
||||
model EventProduct {
|
||||
id Int @id @default(autoincrement())
|
||||
eventId Int @map("event_id")
|
||||
productId Int @map("product_id")
|
||||
impactConfidence Decimal? @map("impact_confidence") @db.Decimal(3, 2)
|
||||
|
||||
event HotEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventId, productId])
|
||||
@@map("event_products")
|
||||
}
|
||||
|
||||
// 交易信号表
|
||||
model TradingSignal {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @map("product_id")
|
||||
timeframe String @db.VarChar(10)
|
||||
signalType String @map("signal_type") @db.VarChar(20) // buy, sell, neutral
|
||||
strength Int
|
||||
indicators Json? // 触发信号的指标
|
||||
description String? @db.Text
|
||||
entryPrice Decimal? @map("entry_price") @db.Decimal(18, 6)
|
||||
stopLoss Decimal? @map("stop_loss") @db.Decimal(18, 6)
|
||||
targetPrice Decimal? @map("target_price") @db.Decimal(18, 6)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([productId, timeframe, createdAt])
|
||||
@@map("trading_signals")
|
||||
}
|
||||
|
||||
// 期权合约表
|
||||
model OptionContract {
|
||||
id Int @id @default(autoincrement())
|
||||
productId Int @map("product_id")
|
||||
symbol String @db.VarChar(20)
|
||||
type String @db.VarChar(10) // call, put
|
||||
strikePrice Decimal @map("strike_price") @db.Decimal(18, 6)
|
||||
expiryDate DateTime @map("expiry_date")
|
||||
price Decimal? @db.Decimal(18, 6)
|
||||
iv Decimal? @db.Decimal(10, 4) // 隐含波动率
|
||||
delta Decimal? @db.Decimal(10, 4)
|
||||
gamma Decimal? @db.Decimal(10, 4)
|
||||
theta Decimal? @db.Decimal(10, 4)
|
||||
vega Decimal? @db.Decimal(10, 4)
|
||||
rho Decimal? @db.Decimal(10, 4)
|
||||
volume BigInt?
|
||||
openInterest BigInt? @map("open_interest")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, type, strikePrice, expiryDate])
|
||||
@@index([productId, expiryDate])
|
||||
@@map("option_contracts")
|
||||
}
|
||||
|
||||
// 模拟交易记录表
|
||||
model PaperTrade {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @map("user_id")
|
||||
symbol String @db.VarChar(20)
|
||||
direction String @db.VarChar(10) // long, short
|
||||
entryPrice Decimal @map("entry_price") @db.Decimal(18, 6)
|
||||
exitPrice Decimal? @map("exit_price") @db.Decimal(18, 6)
|
||||
quantity Int
|
||||
entryTime DateTime @default(now()) @map("entry_time")
|
||||
exitTime DateTime? @map("exit_time")
|
||||
pnl Decimal? @db.Decimal(18, 6)
|
||||
pnlPercent Decimal? @map("pnl_percent") @db.Decimal(10, 4)
|
||||
status String @default("open") @db.VarChar(20) // open, closed
|
||||
notes String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, status])
|
||||
@@map("paper_trades")
|
||||
}
|
||||
|
||||
// 系统配置表
|
||||
model SystemConfig {
|
||||
id Int @id @default(autoincrement())
|
||||
key String @unique @db.VarChar(100)
|
||||
value String @db.Text
|
||||
description String? @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@map("system_configs")
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@ApiTags('AI智能分析')
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
|
||||
@Get('analyze/:symbol')
|
||||
@ApiOperation({ summary: 'AI智能分析品种' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
@ApiResponse({ status: 200, description: '分析成功' })
|
||||
async analyzeSymbol(@Param('symbol') symbol: string) {
|
||||
return this.aiService.analyzeSymbol(symbol);
|
||||
}
|
||||
|
||||
@Get('batch-analyze')
|
||||
@ApiOperation({ summary: '批量AI分析' })
|
||||
@ApiResponse({ status: 200, description: '分析成功' })
|
||||
async batchAnalyze(@Query('symbols') symbols: string) {
|
||||
const symbolList = symbols.split(',');
|
||||
const results = await Promise.all(
|
||||
symbolList.map((s) => this.aiService.analyzeSymbol(s.trim())),
|
||||
);
|
||||
return symbolList.reduce((acc, symbol, index) => {
|
||||
acc[symbol] = results[index];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AiController } from './ai.controller';
|
||||
import { AiService } from './ai.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
import { IndicatorsModule } from '../indicators/indicators.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, MarketModule, IndicatorsModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlertService } from './alert.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
|
||||
@Module({
|
||||
imports: [MarketModule],
|
||||
providers: [AlertService],
|
||||
exports: [AlertService],
|
||||
})
|
||||
export class AlertModule {}
|
||||
@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
import { RedisService } from '@common/redis/redis.service';
|
||||
import { MarketService } from '../market/market.service';
|
||||
|
||||
export interface PriceAlert {
|
||||
id: string;
|
||||
symbol: string;
|
||||
alertType: 'above' | 'below';
|
||||
alertPrice: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AlertService {
|
||||
private readonly logger = new Logger(AlertService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly marketService: MarketService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建价格预警
|
||||
*/
|
||||
async createAlert(
|
||||
userId: number,
|
||||
symbol: string,
|
||||
alertType: 'above' | 'below',
|
||||
alertPrice: number,
|
||||
): Promise<PriceAlert> {
|
||||
// 验证品种存在
|
||||
await this.marketService.getProduct(symbol);
|
||||
|
||||
const alert = await this.prisma.priceAlert.create({
|
||||
data: {
|
||||
userId,
|
||||
symbol: symbol.toUpperCase(),
|
||||
alertType,
|
||||
alertPrice,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: alert.id.toString(),
|
||||
symbol: alert.symbol,
|
||||
alertType: alert.alertType as 'above' | 'below',
|
||||
alertPrice: Number(alert.alertPrice),
|
||||
isActive: alert.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的价格预警
|
||||
*/
|
||||
async getUserAlerts(userId: number): Promise<PriceAlert[]> {
|
||||
const alerts = await this.prisma.priceAlert.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return alerts.map((alert) => ({
|
||||
id: alert.id.toString(),
|
||||
symbol: alert.symbol,
|
||||
alertType: alert.alertType as 'above' | 'below',
|
||||
alertPrice: Number(alert.alertPrice),
|
||||
isActive: alert.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除价格预警
|
||||
*/
|
||||
async deleteAlert(userId: number, alertId: string): Promise<void> {
|
||||
const alert = await this.prisma.priceAlert.findFirst({
|
||||
where: {
|
||||
id: parseInt(alertId),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
throw new Error('预警不存在');
|
||||
}
|
||||
|
||||
await this.prisma.priceAlert.delete({
|
||||
where: { id: parseInt(alertId) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停用/启用预警
|
||||
*/
|
||||
async toggleAlert(userId: number, alertId: string, isActive: boolean): Promise<PriceAlert> {
|
||||
const alert = await this.prisma.priceAlert.update({
|
||||
where: {
|
||||
id: parseInt(alertId),
|
||||
},
|
||||
data: { isActive },
|
||||
});
|
||||
|
||||
return {
|
||||
id: alert.id.toString(),
|
||||
symbol: alert.symbol,
|
||||
alertType: alert.alertType as 'above' | 'below',
|
||||
alertPrice: Number(alert.alertPrice),
|
||||
isActive: alert.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时检查价格预警
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async checkPriceAlerts() {
|
||||
try {
|
||||
const activeAlerts = await this.prisma.priceAlert.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
for (const alert of activeAlerts) {
|
||||
try {
|
||||
const tick = await this.marketService.getTickData(alert.symbol);
|
||||
const currentPrice = tick.price;
|
||||
const alertPrice = Number(alert.alertPrice);
|
||||
|
||||
let triggered = false;
|
||||
|
||||
if (alert.alertType === 'above' && currentPrice >= alertPrice) {
|
||||
triggered = true;
|
||||
} else if (alert.alertType === 'below' && currentPrice <= alertPrice) {
|
||||
triggered = true;
|
||||
}
|
||||
|
||||
if (triggered) {
|
||||
// 标记为已触发
|
||||
await this.prisma.priceAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: {
|
||||
isActive: false,
|
||||
triggeredAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: 发送通知(邮件/短信/推送)
|
||||
this.logger.log(
|
||||
`价格预警触发: ${alert.symbol} ${alert.alertType} ${alertPrice}, 当前价格: ${currentPrice}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`检查预警失败 ${alert.symbol}:`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('检查价格预警失败:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
// 配置模块
|
||||
import appConfig from './config/app.config';
|
||||
import databaseConfig from './config/database.config';
|
||||
import redisConfig from './config/redis.config';
|
||||
import jwtConfig from './config/jwt.config';
|
||||
import marketDataConfig from './config/market-data.config';
|
||||
import openaiConfig from './config/openai.config';
|
||||
|
||||
// 功能模块
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { MarketModule } from './market/market.module';
|
||||
import { IndicatorsModule } from './indicators/indicators.module';
|
||||
import { OptionsModule } from './options/options.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { WatchlistModule } from './watchlist/watchlist.module';
|
||||
import { AlertModule } from './alert/alert.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
|
||||
// 拦截器
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
redisConfig,
|
||||
jwtConfig,
|
||||
marketDataConfig,
|
||||
openaiConfig,
|
||||
],
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// 限流模块
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000, // 1分钟
|
||||
limit: 100, // 每IP每分钟最多100请求
|
||||
}]),
|
||||
|
||||
// 定时任务模块
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// 公共模块
|
||||
CommonModule,
|
||||
|
||||
// 业务模块
|
||||
AuthModule,
|
||||
UserModule,
|
||||
MarketModule,
|
||||
IndicatorsModule,
|
||||
OptionsModule,
|
||||
AiModule,
|
||||
EventsModule,
|
||||
WatchlistModule,
|
||||
AlertModule,
|
||||
],
|
||||
providers: [
|
||||
// 全局拦截器
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: LoggingInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
// 全局限流守卫
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Put,
|
||||
Headers,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { RegisterDto, LoginDto, AuthResponseDto, UserProfileDto } from './dto/auth.dto';
|
||||
|
||||
@ApiTags('认证')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '用户注册' })
|
||||
@ApiResponse({ status: 201, description: '注册成功', type: AuthResponseDto })
|
||||
@ApiResponse({ status: 400, description: '参数错误或用户已存在' })
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '用户登录' })
|
||||
@ApiResponse({ status: 200, description: '登录成功', type: AuthResponseDto })
|
||||
@ApiResponse({ status: 401, description: '用户名或密码错误' })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '用户登出' })
|
||||
async logout(@Headers('authorization') auth: string) {
|
||||
const token = auth?.replace('Bearer ', '');
|
||||
return this.authService.logout(token);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '刷新Token' })
|
||||
async refreshToken(@Body('refreshToken') refreshToken: string) {
|
||||
return this.authService.refreshToken(refreshToken);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '获取用户信息' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: UserProfileDto })
|
||||
async getProfile(@CurrentUser('userId') userId: number) {
|
||||
return this.authService.getProfile(userId);
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '更新用户信息' })
|
||||
async updateProfile(
|
||||
@CurrentUser('userId') userId: number,
|
||||
@Body() data: Partial<{ phone: string; avatarUrl: string }>,
|
||||
) {
|
||||
return this.authService.updateProfile(userId, data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get('jwt.secret'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('jwt.expiresIn'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@ -0,0 +1,212 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
import { RedisService } from '@common/redis/redis.service';
|
||||
import { RegisterDto, LoginDto } from './dto/auth.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: dto.username },
|
||||
{ email: dto.email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('用户名或邮箱已存在');
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
// 创建用户
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
username: dto.username,
|
||||
email: dto.email,
|
||||
passwordHash,
|
||||
phone: dto.phone,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
avatarUrl: true,
|
||||
membershipLevel: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 生成Token
|
||||
const tokens = await this.generateTokens(user.id, user.username);
|
||||
|
||||
return {
|
||||
user,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
// 查找用户
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: dto.username },
|
||||
{ email: dto.username },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
const tokens = await this.generateTokens(user.id, user.username);
|
||||
|
||||
// 保存会话到Redis
|
||||
const ttl = parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600;
|
||||
await this.redis.setSession(tokens.token, user.id, ttl);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
membershipLevel: user.membershipLevel,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(token: string) {
|
||||
await this.redis.deleteSession(token);
|
||||
return { message: '登出成功' };
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('jwt.secret'),
|
||||
});
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('用户不存在或已被禁用');
|
||||
}
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.username);
|
||||
|
||||
// 更新会话
|
||||
const ttl = parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600;
|
||||
await this.redis.setSession(tokens.token, user.id, ttl);
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('无效的刷新令牌');
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
membershipLevel: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateProfile(userId: number, data: Partial<{ phone: string; avatarUrl: string }>) {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
membershipLevel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async generateTokens(userId: number, username: string) {
|
||||
const payload = { sub: userId, username };
|
||||
|
||||
const [token, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload),
|
||||
this.jwtService.signAsync(payload, {
|
||||
expiresIn: this.configService.get('jwt.refreshExpiresIn') || '7d',
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
expiresIn: parseInt(this.configService.get('jwt.expiresIn'), 10) || 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async validateUser(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
isActive: true,
|
||||
membershipLevel: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data ? user[data] : user;
|
||||
},
|
||||
);
|
||||
@ -0,0 +1,88 @@
|
||||
import { IsString, IsEmail, IsOptional, MinLength, IsPhoneNumber, IsUrl } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ description: '用户名', example: 'trader001' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: '邮箱', example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'password123' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', required: false, example: '13800138000' })
|
||||
@IsOptional()
|
||||
@IsPhoneNumber('CN')
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '用户名或邮箱', example: 'trader001' })
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'password123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class AuthResponseDto {
|
||||
@ApiProperty()
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
membershipLevel: number;
|
||||
};
|
||||
|
||||
@ApiProperty()
|
||||
token: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export class UserProfileDto {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
username: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
phone: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
membershipLevel: number;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsPhoneNumber('CN')
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
avatarUrl?: string;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err, user, info) {
|
||||
if (err || !user) {
|
||||
throw err || new UnauthorizedException('请先登录');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在或已被禁用');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.username,
|
||||
membershipLevel: user.membershipLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, RedisModule],
|
||||
exports: [PrismaModule, RedisModule],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const method = request.method;
|
||||
const url = request.url;
|
||||
const now = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const statusCode = response.statusCode;
|
||||
const delay = Date.now() - now;
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ${method} ${url} ${statusCode} - ${delay}ms`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface Response<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, Response<T>> {
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
super({
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'info', 'warn', 'error']
|
||||
: ['error', 'warn'],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
console.log('📦 数据库连接成功');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
console.log('📦 数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const redisUrl = configService.get('redis.url');
|
||||
const redisPassword = configService.get('redis.password');
|
||||
|
||||
return new Redis(redisUrl, {
|
||||
password: redisPassword || undefined,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
RedisService,
|
||||
],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@ -0,0 +1,119 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService {
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT')
|
||||
private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
// 基础操作
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.redis.get(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||
if (ttl) {
|
||||
await this.redis.setex(key, ttl, value);
|
||||
} else {
|
||||
await this.redis.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
await this.redis.del(key);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const result = await this.redis.exists(key);
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
async expire(key: string, seconds: number): Promise<void> {
|
||||
await this.redis.expire(key, seconds);
|
||||
}
|
||||
|
||||
// Hash操作
|
||||
async hget(key: string, field: string): Promise<string | null> {
|
||||
return this.redis.hget(key, field);
|
||||
}
|
||||
|
||||
async hset(key: string, field: string, value: string): Promise<void> {
|
||||
await this.redis.hset(key, field, value);
|
||||
}
|
||||
|
||||
async hgetall(key: string): Promise<Record<string, string>> {
|
||||
return this.redis.hgetall(key);
|
||||
}
|
||||
|
||||
async hdel(key: string, field: string): Promise<void> {
|
||||
await this.redis.hdel(key, field);
|
||||
}
|
||||
|
||||
// List操作
|
||||
async lpush(key: string, value: string): Promise<void> {
|
||||
await this.redis.lpush(key, value);
|
||||
}
|
||||
|
||||
async rpush(key: string, value: string): Promise<void> {
|
||||
await this.redis.rpush(key, value);
|
||||
}
|
||||
|
||||
async lrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.redis.lrange(key, start, stop);
|
||||
}
|
||||
|
||||
// Sorted Set操作
|
||||
async zadd(key: string, score: number, member: string): Promise<void> {
|
||||
await this.redis.zadd(key, score, member);
|
||||
}
|
||||
|
||||
async zrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.redis.zrange(key, start, stop);
|
||||
}
|
||||
|
||||
async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
|
||||
return this.redis.zrevrange(key, start, stop);
|
||||
}
|
||||
|
||||
async zremrangebyscore(key: string, min: number, max: number): Promise<void> {
|
||||
await this.redis.zremrangebyscore(key, min, max);
|
||||
}
|
||||
|
||||
// 行情数据专用方法
|
||||
async setTick(symbol: string, data: Record<string, string>, ttl: number = 60): Promise<void> {
|
||||
const key = `market:tick:${symbol}`;
|
||||
await this.redis.hset(key, data);
|
||||
await this.redis.expire(key, ttl);
|
||||
}
|
||||
|
||||
async getTick(symbol: string): Promise<Record<string, string>> {
|
||||
const key = `market:tick:${symbol}`;
|
||||
return this.redis.hgetall(key);
|
||||
}
|
||||
|
||||
async addKline(symbol: string, period: string, data: string, maxCount: number = 500): Promise<void> {
|
||||
const key = `market:kline:${symbol}:${period}`;
|
||||
const score = Date.now();
|
||||
await this.redis.zadd(key, score, data);
|
||||
await this.redis.zremrangebyrank(key, 0, -(maxCount + 1));
|
||||
await this.redis.expire(key, 7 * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
async getKlines(symbol: string, period: string, count: number = 100): Promise<string[]> {
|
||||
const key = `market:kline:${symbol}:${period}`;
|
||||
return this.redis.zrevrange(key, 0, count - 1);
|
||||
}
|
||||
|
||||
// Session管理
|
||||
async setSession(token: string, userId: number, ttl: number): Promise<void> {
|
||||
const key = `session:${token}`;
|
||||
await this.redis.setex(key, ttl, userId.toString());
|
||||
}
|
||||
|
||||
async deleteSession(token: string): Promise<void> {
|
||||
const key = `session:${token}`;
|
||||
await this.redis.del(key);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT, 10) || 3000,
|
||||
apiPrefix: process.env.API_PREFIX || '/api/v1',
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
logLevel: process.env.LOG_LEVEL || 'debug',
|
||||
metricsEnabled: process.env.METRICS_ENABLED === 'true',
|
||||
metricsPort: parseInt(process.env.METRICS_PORT, 10) || 9090,
|
||||
}));
|
||||
@ -0,0 +1,5 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('database', () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
}));
|
||||
@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
}));
|
||||
@ -0,0 +1,14 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('marketData', () => ({
|
||||
provider: process.env.MARKET_DATA_PROVIDER || 'mock',
|
||||
apiKey: process.env.MARKET_DATA_API_KEY,
|
||||
apiSecret: process.env.MARKET_DATA_API_SECRET,
|
||||
wsUrl: process.env.MARKET_DATA_WS_URL,
|
||||
// 重连配置
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10,
|
||||
// 数据更新频率 (毫秒)
|
||||
tickUpdateInterval: 1000,
|
||||
klineUpdateInterval: 5000,
|
||||
}));
|
||||
@ -0,0 +1,14 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('openai', () => ({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4-turbo-preview',
|
||||
// AI分析配置
|
||||
maxTokens: 2000,
|
||||
temperature: 0.7,
|
||||
// 请求限制
|
||||
maxRequestsPerMinute: 20,
|
||||
// 缓存配置
|
||||
cacheTTL: 300, // 5分钟
|
||||
}));
|
||||
@ -0,0 +1,6 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('redis', () => ({
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
}));
|
||||
@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { EventsService } from './events.service';
|
||||
|
||||
@ApiTags('热点事件')
|
||||
@Controller('events')
|
||||
export class EventsController {
|
||||
constructor(private readonly eventsService: EventsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取热点事件列表' })
|
||||
@ApiQuery({ name: 'impact', required: false, enum: ['bullish', 'bearish', 'neutral'] })
|
||||
@ApiQuery({ name: 'startDate', required: false })
|
||||
@ApiQuery({ name: 'endDate', required: false })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'size', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getEvents(
|
||||
@Query('impact') impact?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('size') size = '20',
|
||||
) {
|
||||
return this.eventsService.getEvents(
|
||||
impact,
|
||||
startDate ? new Date(startDate) : undefined,
|
||||
endDate ? new Date(endDate) : undefined,
|
||||
parseInt(page, 10),
|
||||
parseInt(size, 10),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取事件详情' })
|
||||
@ApiParam({ name: 'id', description: '事件ID' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '事件不存在' })
|
||||
async getEventById(@Param('id') id: string) {
|
||||
return this.eventsService.getEventById(id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller';
|
||||
import { EventsService } from './events.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
|
||||
@Module({
|
||||
imports: [MarketModule],
|
||||
controllers: [EventsController],
|
||||
providers: [EventsService],
|
||||
exports: [EventsService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@ -0,0 +1,20 @@
|
||||
import { IsString, IsOptional, IsEnum, IsArray } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IndicatorType } from '../interfaces/indicator.interface';
|
||||
|
||||
export class GetIndicatorsDto {
|
||||
@ApiProperty({ description: '周期', enum: ['1m', '5m', '15m', '30m', '1h', '1d', '1w'] })
|
||||
@IsString()
|
||||
@IsEnum(['1m', '5m', '15m', '30m', '1h', '1d', '1w'])
|
||||
period: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '指标列表',
|
||||
isArray: true,
|
||||
enum: ['macd', 'rsi', 'bollinger', 'kdj', 'sar', 'obv', 'dmi', 'cci', 'wr', 'ma5', 'ma10', 'ma20', 'ma60'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
indicators?: IndicatorType[];
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
|
||||
import { IndicatorsService } from './indicators.service';
|
||||
import { GetIndicatorsDto } from './dto/indicator.dto';
|
||||
|
||||
@ApiTags('技术指标')
|
||||
@Controller('indicators')
|
||||
export class IndicatorsController {
|
||||
constructor(private readonly indicatorsService: IndicatorsService) {}
|
||||
|
||||
@Get(':symbol')
|
||||
@ApiOperation({ summary: '获取技术指标' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getIndicators(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() dto: GetIndicatorsDto,
|
||||
) {
|
||||
const results = await this.indicatorsService.calculateAllIndicators(
|
||||
symbol,
|
||||
dto.period,
|
||||
);
|
||||
|
||||
// 如果指定了特定指标,只返回这些指标
|
||||
if (dto.indicators && dto.indicators.length > 0) {
|
||||
const filtered: any = {};
|
||||
dto.indicators.forEach((ind) => {
|
||||
if (results[ind]) {
|
||||
filtered[ind] = results[ind];
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@Get(':symbol/macd')
|
||||
@ApiOperation({ summary: '获取MACD指标' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async getMACD(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('period') period: string = '1d',
|
||||
) {
|
||||
const klineData = await this.indicatorsService['marketService'].getKlineData(
|
||||
symbol,
|
||||
period,
|
||||
200,
|
||||
);
|
||||
return this.indicatorsService.calculateMACD(klineData);
|
||||
}
|
||||
|
||||
@Get(':symbol/rsi')
|
||||
@ApiOperation({ summary: '获取RSI指标' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async getRSI(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('period') period: string = '1d',
|
||||
@Query('rsiPeriod') rsiPeriod: number = 14,
|
||||
) {
|
||||
const klineData = await this.indicatorsService['marketService'].getKlineData(
|
||||
symbol,
|
||||
period,
|
||||
200,
|
||||
);
|
||||
return this.indicatorsService.calculateRSI(klineData, rsiPeriod);
|
||||
}
|
||||
|
||||
@Get(':symbol/bollinger')
|
||||
@ApiOperation({ summary: '获取布林带指标' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async getBollinger(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('period') period: string = '1d',
|
||||
) {
|
||||
const klineData = await this.indicatorsService['marketService'].getKlineData(
|
||||
symbol,
|
||||
period,
|
||||
200,
|
||||
);
|
||||
return this.indicatorsService.calculateBollinger(klineData);
|
||||
}
|
||||
|
||||
@Get(':symbol/kdj')
|
||||
@ApiOperation({ summary: '获取KDJ指标' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async getKDJ(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('period') period: string = '1d',
|
||||
) {
|
||||
const klineData = await this.indicatorsService['marketService'].getKlineData(
|
||||
symbol,
|
||||
period,
|
||||
200,
|
||||
);
|
||||
return this.indicatorsService.calculateKDJ(klineData);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IndicatorsController } from './indicators.controller';
|
||||
import { IndicatorsService } from './indicators.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
|
||||
@Module({
|
||||
imports: [MarketModule],
|
||||
controllers: [IndicatorsController],
|
||||
providers: [IndicatorsService],
|
||||
exports: [IndicatorsService],
|
||||
})
|
||||
export class IndicatorsModule {}
|
||||
@ -0,0 +1,392 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketService } from '../market/market.service';
|
||||
import { KLineData } from '../market/interfaces/market.interface';
|
||||
import {
|
||||
MACDResult,
|
||||
RSIResult,
|
||||
BollingerResult,
|
||||
KDJResult,
|
||||
SARResult,
|
||||
OBVResult,
|
||||
DMIResult,
|
||||
CCIResult,
|
||||
WRResult,
|
||||
IndicatorResults,
|
||||
} from './interfaces/indicator.interface';
|
||||
|
||||
@Injectable()
|
||||
export class IndicatorsService {
|
||||
constructor(private readonly marketService: MarketService) {}
|
||||
|
||||
// 计算MACD
|
||||
calculateMACD(
|
||||
klineData: KLineData[],
|
||||
fastPeriod = 12,
|
||||
slowPeriod = 26,
|
||||
signalPeriod = 9,
|
||||
): MACDResult[] {
|
||||
const closes = klineData.map((k) => k.close);
|
||||
const ema12 = this.calculateEMA(closes, fastPeriod);
|
||||
const ema26 = this.calculateEMA(closes, slowPeriod);
|
||||
|
||||
const dif = ema12.map((v, i) => v - ema26[i]);
|
||||
const dea = this.calculateEMA(dif, signalPeriod);
|
||||
const macd = dif.map((v, i) => (v - dea[i]) * 2);
|
||||
|
||||
return klineData.map((k, i) => ({
|
||||
time: k.time,
|
||||
dif: Number(dif[i].toFixed(4)),
|
||||
dea: Number(dea[i].toFixed(4)),
|
||||
macd: Number(macd[i].toFixed(4)),
|
||||
}));
|
||||
}
|
||||
|
||||
// 计算RSI
|
||||
calculateRSI(klineData: KLineData[], period = 14): RSIResult[] {
|
||||
const closes = klineData.map((k) => k.close);
|
||||
const rsi = this.calculateRSIValues(closes, period);
|
||||
|
||||
return klineData.map((k, i) => ({
|
||||
time: k.time,
|
||||
value: Number(rsi[i].toFixed(2)),
|
||||
status: this.getRSIStatus(rsi[i]),
|
||||
}));
|
||||
}
|
||||
|
||||
// 计算布林带
|
||||
calculateBollinger(
|
||||
klineData: KLineData[],
|
||||
period = 20,
|
||||
stdDev = 2,
|
||||
): BollingerResult[] {
|
||||
const closes = klineData.map((k) => k.close);
|
||||
const results: BollingerResult[] = [];
|
||||
|
||||
for (let i = period - 1; i < closes.length; i++) {
|
||||
const slice = closes.slice(i - period + 1, i + 1);
|
||||
const middle = this.calculateSMA(slice, period);
|
||||
const std = this.calculateStdDev(slice, middle);
|
||||
const upper = middle + stdDev * std;
|
||||
const lower = middle - stdDev * std;
|
||||
const currentPrice = closes[i];
|
||||
|
||||
results.push({
|
||||
time: klineData[i].time,
|
||||
upper: Number(upper.toFixed(2)),
|
||||
middle: Number(middle.toFixed(2)),
|
||||
lower: Number(lower.toFixed(2)),
|
||||
position: currentPrice > upper ? 'upper' : currentPrice < lower ? 'lower' : 'middle',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 计算KDJ
|
||||
calculateKDJ(klineData: KLineData[], n = 9, m1 = 3, m2 = 3): KDJResult[] {
|
||||
const highs = klineData.map((k) => k.high);
|
||||
const lows = klineData.map((k) => k.low);
|
||||
const closes = klineData.map((k) => k.close);
|
||||
|
||||
const rsv: number[] = [];
|
||||
const k: number[] = [];
|
||||
const d: number[] = [];
|
||||
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
if (i < n - 1) {
|
||||
rsv.push(50);
|
||||
k.push(50);
|
||||
d.push(50);
|
||||
continue;
|
||||
}
|
||||
|
||||
const periodHighs = highs.slice(i - n + 1, i + 1);
|
||||
const periodLows = lows.slice(i - n + 1, i + 1);
|
||||
const highest = Math.max(...periodHighs);
|
||||
const lowest = Math.min(...periodLows);
|
||||
|
||||
const rsvValue = highest === lowest ? 50 : ((closes[i] - lowest) / (highest - lowest)) * 100;
|
||||
rsv.push(rsvValue);
|
||||
|
||||
const kValue = (2 / 3) * (k[i - 1] || 50) + (1 / 3) * rsvValue;
|
||||
k.push(kValue);
|
||||
|
||||
const dValue = (2 / 3) * (d[i - 1] || 50) + (1 / 3) * kValue;
|
||||
d.push(dValue);
|
||||
}
|
||||
|
||||
const j = k.map((kv, i) => 3 * kv - 2 * d[i]);
|
||||
|
||||
return klineData.map((kline, i) => ({
|
||||
time: kline.time,
|
||||
k: Number(k[i].toFixed(2)),
|
||||
d: Number(d[i].toFixed(2)),
|
||||
j: Number(j[i].toFixed(2)),
|
||||
signal: this.getKDJSignal(k[i], d[i], k[i - 1], d[i - 1]),
|
||||
}));
|
||||
}
|
||||
|
||||
// 计算SAR (抛物线转向)
|
||||
calculateSAR(klineData: KLineData[], af = 0.02, maxAf = 0.2): SARResult[] {
|
||||
const highs = klineData.map((k) => k.high);
|
||||
const lows = klineData.map((k) => k.low);
|
||||
const results: SARResult[] = [];
|
||||
|
||||
let isLong = true;
|
||||
let sar = lows[0];
|
||||
let ep = highs[0];
|
||||
let afValue = af;
|
||||
|
||||
for (let i = 1; i < klineData.length; i++) {
|
||||
sar = sar + afValue * (ep - sar);
|
||||
|
||||
if (isLong) {
|
||||
if (lows[i] < sar) {
|
||||
isLong = false;
|
||||
sar = ep;
|
||||
ep = lows[i];
|
||||
afValue = af;
|
||||
} else {
|
||||
if (highs[i] > ep) {
|
||||
ep = highs[i];
|
||||
afValue = Math.min(afValue + af, maxAf);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (highs[i] > sar) {
|
||||
isLong = true;
|
||||
sar = ep;
|
||||
ep = highs[i];
|
||||
afValue = af;
|
||||
} else {
|
||||
if (lows[i] < ep) {
|
||||
ep = lows[i];
|
||||
afValue = Math.min(afValue + af, maxAf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
time: klineData[i].time,
|
||||
value: Number(sar.toFixed(2)),
|
||||
isLong,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 计算OBV
|
||||
calculateOBV(klineData: KLineData[]): OBVResult[] {
|
||||
const obv: number[] = [0];
|
||||
|
||||
for (let i = 1; i < klineData.length; i++) {
|
||||
const prevClose = klineData[i - 1].close;
|
||||
const currentClose = klineData[i].close;
|
||||
const volume = klineData[i].volume;
|
||||
|
||||
if (currentClose > prevClose) {
|
||||
obv.push(obv[i - 1] + volume);
|
||||
} else if (currentClose < prevClose) {
|
||||
obv.push(obv[i - 1] - volume);
|
||||
} else {
|
||||
obv.push(obv[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return klineData.map((k, i) => ({
|
||||
time: k.time,
|
||||
value: obv[i],
|
||||
}));
|
||||
}
|
||||
|
||||
// 计算DMI
|
||||
calculateDMI(klineData: KLineData[], period = 14): DMIResult[] {
|
||||
const highs = klineData.map((k) => k.high);
|
||||
const lows = klineData.map((k) => k.low);
|
||||
const closes = klineData.map((k) => k.close);
|
||||
|
||||
const tr: number[] = [];
|
||||
const plusDM: number[] = [];
|
||||
const minusDM: number[] = [];
|
||||
|
||||
for (let i = 1; i < klineData.length; i++) {
|
||||
const highDiff = highs[i] - highs[i - 1];
|
||||
const lowDiff = lows[i - 1] - lows[i];
|
||||
|
||||
tr.push(Math.max(
|
||||
highs[i] - lows[i],
|
||||
Math.abs(highs[i] - closes[i - 1]),
|
||||
Math.abs(lows[i] - closes[i - 1]),
|
||||
));
|
||||
|
||||
plusDM.push(highDiff > lowDiff && highDiff > 0 ? highDiff : 0);
|
||||
minusDM.push(lowDiff > highDiff && lowDiff > 0 ? lowDiff : 0);
|
||||
}
|
||||
|
||||
const atr = this.calculateEMA(tr, period);
|
||||
const plusDI = this.calculateEMA(plusDM, period).map((v, i) => (v / atr[i]) * 100);
|
||||
const minusDI = this.calculateEMA(minusDM, period).map((v, i) => (v / atr[i]) * 100);
|
||||
const dx = plusDI.map((v, i) => Math.abs(v - minusDI[i]) / (v + minusDI[i]) * 100);
|
||||
const adx = this.calculateEMA(dx, period);
|
||||
|
||||
return klineData.slice(1).map((k, i) => ({
|
||||
time: k.time,
|
||||
plusDI: Number(plusDI[i].toFixed(2)),
|
||||
minusDI: Number(minusDI[i].toFixed(2)),
|
||||
adx: Number(adx[i].toFixed(2)),
|
||||
}));
|
||||
}
|
||||
|
||||
// 计算CCI
|
||||
calculateCCI(klineData: KLineData[], period = 14): CCIResult[] {
|
||||
const results: CCIResult[] = [];
|
||||
|
||||
for (let i = period - 1; i < klineData.length; i++) {
|
||||
const tp = klineData.slice(i - period + 1, i + 1).map(
|
||||
(k) => (k.high + k.low + k.close) / 3,
|
||||
);
|
||||
const smaTP = tp.reduce((a, b) => a + b, 0) / period;
|
||||
const meanDeviation = tp.reduce((a, b) => a + Math.abs(b - smaTP), 0) / period;
|
||||
const cci = (tp[tp.length - 1] - smaTP) / (0.015 * meanDeviation);
|
||||
|
||||
results.push({
|
||||
time: klineData[i].time,
|
||||
value: Number(cci.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 计算WR (威廉指标)
|
||||
calculateWR(klineData: KLineData[], period = 14): WRResult[] {
|
||||
const results: WRResult[] = [];
|
||||
|
||||
for (let i = period - 1; i < klineData.length; i++) {
|
||||
const periodData = klineData.slice(i - period + 1, i + 1);
|
||||
const highest = Math.max(...periodData.map((k) => k.high));
|
||||
const lowest = Math.min(...periodData.map((k) => k.low));
|
||||
const close = klineData[i].close;
|
||||
|
||||
const wr = highest === lowest ? -50 : ((highest - close) / (highest - lowest)) * -100;
|
||||
|
||||
results.push({
|
||||
time: klineData[i].time,
|
||||
value: Number(wr.toFixed(2)),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 计算移动平均线
|
||||
calculateMA(klineData: KLineData[], period: number): { time: string; value: number }[] {
|
||||
const closes = klineData.map((k) => k.close);
|
||||
const ma = this.calculateSMAArray(closes, period);
|
||||
|
||||
return klineData.slice(period - 1).map((k, i) => ({
|
||||
time: k.time,
|
||||
value: Number(ma[i].toFixed(2)),
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取所有指标
|
||||
async calculateAllIndicators(
|
||||
symbol: string,
|
||||
period: string,
|
||||
): Promise<IndicatorResults> {
|
||||
const klineData = await this.marketService.getKlineData(symbol, period, 200);
|
||||
|
||||
return {
|
||||
macd: this.calculateMACD(klineData),
|
||||
rsi: this.calculateRSI(klineData),
|
||||
bollinger: this.calculateBollinger(klineData),
|
||||
kdj: this.calculateKDJ(klineData),
|
||||
sar: this.calculateSAR(klineData),
|
||||
obv: this.calculateOBV(klineData),
|
||||
dmi: this.calculateDMI(klineData),
|
||||
cci: this.calculateCCI(klineData),
|
||||
wr: this.calculateWR(klineData),
|
||||
ma5: this.calculateMA(klineData, 5),
|
||||
ma10: this.calculateMA(klineData, 10),
|
||||
ma20: this.calculateMA(klineData, 20),
|
||||
ma60: this.calculateMA(klineData, 60),
|
||||
};
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
private calculateEMA(data: number[], period: number): number[] {
|
||||
const k = 2 / (period + 1);
|
||||
const ema: number[] = [data[0]];
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
ema.push(data[i] * k + ema[i - 1] * (1 - k));
|
||||
}
|
||||
|
||||
return ema;
|
||||
}
|
||||
|
||||
private calculateSMA(data: number[], period: number): number {
|
||||
const sum = data.slice(-period).reduce((a, b) => a + b, 0);
|
||||
return sum / period;
|
||||
}
|
||||
|
||||
private calculateSMAArray(data: number[], period: number): number[] {
|
||||
const sma: number[] = [];
|
||||
for (let i = period - 1; i < data.length; i++) {
|
||||
const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0);
|
||||
sma.push(sum / period);
|
||||
}
|
||||
return sma;
|
||||
}
|
||||
|
||||
private calculateStdDev(data: number[], mean: number): number {
|
||||
const squareDiffs = data.map((value) => Math.pow(value - mean, 2));
|
||||
const avgSquareDiff = squareDiffs.reduce((a, b) => a + b, 0) / data.length;
|
||||
return Math.sqrt(avgSquareDiff);
|
||||
}
|
||||
|
||||
private calculateRSIValues(closes: number[], period: number): number[] {
|
||||
const gains: number[] = [];
|
||||
const losses: number[] = [];
|
||||
|
||||
for (let i = 1; i < closes.length; i++) {
|
||||
const change = closes[i] - closes[i - 1];
|
||||
gains.push(change > 0 ? change : 0);
|
||||
losses.push(change < 0 ? Math.abs(change) : 0);
|
||||
}
|
||||
|
||||
const rsi: number[] = [];
|
||||
let avgGain = gains.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
||||
let avgLoss = losses.slice(0, period).reduce((a, b) => a + b, 0) / period;
|
||||
|
||||
for (let i = period; i < gains.length; i++) {
|
||||
avgGain = (avgGain * (period - 1) + gains[i]) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + losses[i]) / period;
|
||||
|
||||
const rs = avgGain / avgLoss;
|
||||
rsi.push(100 - 100 / (1 + rs));
|
||||
}
|
||||
|
||||
// 填充前面的数据
|
||||
return [...Array(period).fill(50), ...rsi];
|
||||
}
|
||||
|
||||
private getRSIStatus(rsi: number): 'overbought' | 'oversold' | 'normal' {
|
||||
if (rsi > 70) return 'overbought';
|
||||
if (rsi < 30) return 'oversold';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
private getKDJSignal(
|
||||
k: number,
|
||||
d: number,
|
||||
prevK: number,
|
||||
prevD: number,
|
||||
): 'golden_cross' | 'dead_cross' | 'neutral' {
|
||||
if (prevK < prevD && k > d) return 'golden_cross';
|
||||
if (prevK > prevD && k < d) return 'dead_cross';
|
||||
return 'neutral';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
export interface MACDResult {
|
||||
time: string;
|
||||
dif: number;
|
||||
dea: number;
|
||||
macd: number;
|
||||
}
|
||||
|
||||
export interface RSIResult {
|
||||
time: string;
|
||||
value: number;
|
||||
status: 'overbought' | 'oversold' | 'normal';
|
||||
}
|
||||
|
||||
export interface BollingerResult {
|
||||
time: string;
|
||||
upper: number;
|
||||
middle: number;
|
||||
lower: number;
|
||||
position: 'upper' | 'middle' | 'lower';
|
||||
}
|
||||
|
||||
export interface KDJResult {
|
||||
time: string;
|
||||
k: number;
|
||||
d: number;
|
||||
j: number;
|
||||
signal: 'golden_cross' | 'dead_cross' | 'neutral';
|
||||
}
|
||||
|
||||
export interface SARResult {
|
||||
time: string;
|
||||
value: number;
|
||||
isLong: boolean;
|
||||
}
|
||||
|
||||
export interface OBVResult {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DMIResult {
|
||||
time: string;
|
||||
plusDI: number;
|
||||
minusDI: number;
|
||||
adx: number;
|
||||
}
|
||||
|
||||
export interface CCIResult {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface WRResult {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MAResult {
|
||||
time: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IndicatorResults {
|
||||
macd: MACDResult[];
|
||||
rsi: RSIResult[];
|
||||
bollinger: BollingerResult[];
|
||||
kdj: KDJResult[];
|
||||
sar: SARResult[];
|
||||
obv: OBVResult[];
|
||||
dmi: DMIResult[];
|
||||
cci: CCIResult[];
|
||||
wr: WRResult[];
|
||||
ma5: MAResult[];
|
||||
ma10: MAResult[];
|
||||
ma20: MAResult[];
|
||||
ma60: MAResult[];
|
||||
}
|
||||
|
||||
export type IndicatorType =
|
||||
| 'macd'
|
||||
| 'rsi'
|
||||
| 'bollinger'
|
||||
| 'kdj'
|
||||
| 'sar'
|
||||
| 'obv'
|
||||
| 'dmi'
|
||||
| 'cci'
|
||||
| 'wr'
|
||||
| 'ma5'
|
||||
| 'ma10'
|
||||
| 'ma20'
|
||||
| 'ma60';
|
||||
@ -0,0 +1,81 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import helmet from 'helmet';
|
||||
import * as compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// 安全中间件
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 压缩响应
|
||||
app.use(compression());
|
||||
|
||||
// CORS配置
|
||||
app.enableCors({
|
||||
origin: configService.get('CORS_ORIGIN') || true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// API版本控制
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
defaultVersion: '1',
|
||||
prefix: 'api/v',
|
||||
});
|
||||
|
||||
// 全局验证管道
|
||||
app.useGlobalPipes(new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// API前缀
|
||||
app.setGlobalPrefix(configService.get('API_PREFIX') || 'api/v1');
|
||||
|
||||
// Swagger文档
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('智能期货期权分析系统 API')
|
||||
.setDescription('提供行情数据、技术分析、期权定价等功能的RESTful API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.addTag('认证', '用户登录注册')
|
||||
.addTag('行情', '期货行情数据')
|
||||
.addTag('指标', '技术分析指标')
|
||||
.addTag('期权', '期权分析与定价')
|
||||
.addTag('AI', '智能分析')
|
||||
.addTag('事件', '热点事件')
|
||||
.addTag('用户', '用户管理')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
// 启动服务器
|
||||
const port = configService.get('PORT') || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`🚀 服务已启动: http://localhost:${port}`);
|
||||
console.log(`📚 API文档: http://localhost:${port}/docs`);
|
||||
console.log(`📊 环境: ${configService.get('NODE_ENV')}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@ -0,0 +1,50 @@
|
||||
import { IsString, IsOptional, IsInt, Min, Max, IsEnum } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GetProductsDto {
|
||||
@ApiProperty({ required: false, description: '品种分类' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 1, description: '页码' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiProperty({ required: false, default: 20, description: '每页数量' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
size?: number = 20;
|
||||
}
|
||||
|
||||
export class GetKlineDto {
|
||||
@ApiProperty({ description: '周期', enum: ['1m', '5m', '15m', '30m', '1h', '1d', '1w'] })
|
||||
@IsString()
|
||||
@IsEnum(['1m', '5m', '15m', '30m', '1h', '1d', '1w'])
|
||||
period: string;
|
||||
|
||||
@ApiProperty({ required: false, default: 100, description: '返回条数' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
count?: number = 100;
|
||||
|
||||
@ApiProperty({ required: false, description: '开始时间 (ISO8601)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startTime?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: '结束时间 (ISO8601)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endTime?: string;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
|
||||
@Injectable()
|
||||
export class WsJwtGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
try {
|
||||
const client = context.switchToWs().getClient();
|
||||
const token = this.extractToken(client);
|
||||
|
||||
if (!token) {
|
||||
throw new WsException('未提供认证令牌');
|
||||
}
|
||||
|
||||
const payload = this.jwtService.verify(token, {
|
||||
secret: this.configService.get('jwt.secret'),
|
||||
});
|
||||
|
||||
// 将用户信息附加到客户端
|
||||
client.user = payload;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new WsException('无效的认证令牌');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(client: any): string | null {
|
||||
// 从handshake auth中获取token
|
||||
const auth = client.handshake?.auth?.token;
|
||||
if (auth) return auth.replace('Bearer ', '');
|
||||
|
||||
// 从query中获取token
|
||||
const token = client.handshake?.query?.token;
|
||||
if (token) return token as string;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
category: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
openInterest?: number;
|
||||
}
|
||||
|
||||
export interface KLineData {
|
||||
time: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface TickData {
|
||||
symbol: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
openInterest?: number;
|
||||
bidPrice?: number;
|
||||
askPrice?: number;
|
||||
bidVolume?: number;
|
||||
askVolume?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface MarketOverview {
|
||||
heatIndex: number;
|
||||
heatChange: number;
|
||||
upCount: number;
|
||||
downCount: number;
|
||||
capitalFlow: number;
|
||||
volatilityIndex: number;
|
||||
volatilityChange: number;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'tick' | 'kline' | 'trade' | 'signal';
|
||||
symbol: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { MarketService } from './market.service';
|
||||
import { MarketGateway } from './market.gateway';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
import { TickData, KLineData } from './interfaces/market.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataFeedService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MarketDataFeedService.name);
|
||||
private readonly mockSymbols = ['SC', 'AU', 'CU', 'AG', 'I', 'M', 'P', 'EC'];
|
||||
private mockDataInterval: NodeJS.Timeout;
|
||||
private isRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly marketService: MarketService,
|
||||
private readonly marketGateway: MarketGateway,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// 启动模拟数据feed
|
||||
const provider = this.configService.get('marketData.provider');
|
||||
|
||||
if (provider === 'mock') {
|
||||
this.startMockDataFeed();
|
||||
this.logger.log('📊 启动模拟行情数据服务');
|
||||
} else {
|
||||
// TODO: 实现真实数据源连接
|
||||
this.logger.log(`📊 使用数据源: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.stopMockDataFeed();
|
||||
}
|
||||
|
||||
// 启动模拟数据推送
|
||||
private startMockDataFeed() {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
const interval = this.configService.get('marketData.tickUpdateInterval') || 1000;
|
||||
|
||||
this.mockDataInterval = setInterval(() => {
|
||||
this.generateAndBroadcastMockData();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
// 停止模拟数据推送
|
||||
private stopMockDataFeed() {
|
||||
this.isRunning = false;
|
||||
if (this.mockDataInterval) {
|
||||
clearInterval(this.mockDataInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成并广播模拟数据
|
||||
private async generateAndBroadcastMockData() {
|
||||
for (const symbol of this.mockSymbols) {
|
||||
try {
|
||||
const tick = await this.generateMockTick(symbol);
|
||||
|
||||
// 存储到Redis
|
||||
await this.marketService.storeTickData(symbol, tick);
|
||||
|
||||
// 广播给订阅的客户端
|
||||
this.marketGateway.broadcastTick(symbol, tick);
|
||||
} catch (error) {
|
||||
this.logger.error(`生成模拟数据失败 ${symbol}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定时推送市场概览
|
||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||
async broadcastMarketOverview() {
|
||||
try {
|
||||
const overview = await this.marketService.getMarketOverview();
|
||||
this.marketGateway.broadcastMarketOverview(overview);
|
||||
} catch (error) {
|
||||
this.logger.error('广播市场概览失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模拟Tick数据
|
||||
private async generateMockTick(symbol: string): Promise<TickData> {
|
||||
// 获取当前价格或生成基础价格
|
||||
const currentTick = await this.marketService.getTickData(symbol).catch(() => null);
|
||||
let basePrice = 500;
|
||||
|
||||
if (currentTick) {
|
||||
basePrice = currentTick.price;
|
||||
} else {
|
||||
// 根据品种设置不同的基础价格
|
||||
const basePrices: Record<string, number> = {
|
||||
SC: 528.6, // 原油
|
||||
AU: 685.2, // 黄金
|
||||
CU: 80610, // 铜
|
||||
AG: 8250, // 白银
|
||||
I: 785.5, // 铁矿石
|
||||
M: 2985, // 豆粕
|
||||
P: 8750, // 棕榈油
|
||||
EC: 2150, // 集运指数
|
||||
};
|
||||
basePrice = basePrices[symbol] || 500;
|
||||
}
|
||||
|
||||
// 随机波动 (0.1% - 0.5%)
|
||||
const volatility = basePrice * (0.001 + Math.random() * 0.004);
|
||||
const change = (Math.random() - 0.5) * volatility;
|
||||
const price = basePrice + change;
|
||||
|
||||
return {
|
||||
symbol,
|
||||
price: Number(price.toFixed(2)),
|
||||
change: Number(change.toFixed(2)),
|
||||
changePercent: Number(((change / basePrice) * 100).toFixed(2)),
|
||||
open: currentTick?.open || basePrice,
|
||||
high: Math.max(currentTick?.high || price, price) * (1 + Math.random() * 0.001),
|
||||
low: Math.min(currentTick?.low || price, price) * (1 - Math.random() * 0.001),
|
||||
volume: Math.floor(Math.random() * 10000) + (currentTick?.volume || 0),
|
||||
openInterest: Math.floor(Math.random() * 100000),
|
||||
bidPrice: price - 0.1,
|
||||
askPrice: price + 0.1,
|
||||
bidVolume: Math.floor(Math.random() * 100),
|
||||
askVolume: Math.floor(Math.random() * 100),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 生成模拟K线数据
|
||||
async generateMockKline(symbol: string, period: string, count: number = 100): Promise<KLineData[]> {
|
||||
const data: KLineData[] = [];
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { symbol },
|
||||
});
|
||||
|
||||
let basePrice = 500;
|
||||
if (product) {
|
||||
const tick = await this.marketService.getTickData(symbol);
|
||||
basePrice = tick.price;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const periodMinutes = this.parsePeriod(period);
|
||||
|
||||
for (let i = count; i >= 0; i--) {
|
||||
const time = new Date(now.getTime() - i * periodMinutes * 60 * 1000);
|
||||
const volatility = basePrice * 0.008;
|
||||
const change = (Math.random() - 0.48) * volatility;
|
||||
|
||||
const open = basePrice;
|
||||
const close = basePrice + change;
|
||||
const high = Math.max(open, close) + Math.random() * volatility * 0.5;
|
||||
const low = Math.min(open, close) - Math.random() * volatility * 0.5;
|
||||
const volume = Math.floor(Math.random() * 10000 + 5000);
|
||||
|
||||
data.push({
|
||||
time: time.toISOString(),
|
||||
open: Number(open.toFixed(2)),
|
||||
high: Number(high.toFixed(2)),
|
||||
low: Number(low.toFixed(2)),
|
||||
close: Number(close.toFixed(2)),
|
||||
volume,
|
||||
});
|
||||
|
||||
basePrice = close;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private parsePeriod(period: string): number {
|
||||
const periodMap: Record<string, number> = {
|
||||
'1m': 1,
|
||||
'5m': 5,
|
||||
'15m': 15,
|
||||
'30m': 30,
|
||||
'1h': 60,
|
||||
'1d': 60 * 24,
|
||||
'1w': 60 * 24 * 7,
|
||||
};
|
||||
return periodMap[period] || 5;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { MarketService } from './market.service';
|
||||
import { GetProductsDto, GetKlineDto } from './dto/market.dto';
|
||||
|
||||
@ApiTags('行情')
|
||||
@Controller('market')
|
||||
export class MarketController {
|
||||
constructor(private readonly marketService: MarketService) {}
|
||||
|
||||
@Get('products')
|
||||
@ApiOperation({ summary: '获取品种列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getProducts(@Query() dto: GetProductsDto) {
|
||||
return this.marketService.getProducts(dto.category, dto.page, dto.size);
|
||||
}
|
||||
|
||||
@Get('products/:symbol')
|
||||
@ApiOperation({ summary: '获取品种详情' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '品种不存在' })
|
||||
async getProduct(@Param('symbol') symbol: string) {
|
||||
return this.marketService.getProduct(symbol);
|
||||
}
|
||||
|
||||
@Get('products/:symbol/kline')
|
||||
@ApiOperation({ summary: '获取K线数据' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getKline(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() dto: GetKlineDto,
|
||||
) {
|
||||
return this.marketService.getKlineData(
|
||||
symbol,
|
||||
dto.period,
|
||||
dto.count,
|
||||
dto.startTime ? new Date(dto.startTime) : undefined,
|
||||
dto.endTime ? new Date(dto.endTime) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('products/:symbol/tick')
|
||||
@ApiOperation({ summary: '获取实时行情' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getTick(@Param('symbol') symbol: string) {
|
||||
return this.marketService.getTickData(symbol);
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@ApiOperation({ summary: '获取市场概览' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getOverview() {
|
||||
return this.marketService.getMarketOverview();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MarketService } from './market.service';
|
||||
import { WsJwtGuard } from './guards/ws-jwt.guard';
|
||||
|
||||
interface SubscribeMessageData {
|
||||
symbols: string[];
|
||||
channels: ('tick' | 'kline' | 'depth')[];
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: 'market',
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
})
|
||||
export class MarketGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(MarketGateway.name);
|
||||
private clientSubscriptions: Map<string, Set<string>> = new Map(); // clientId -> symbols
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly marketService: MarketService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`客户端连接: ${client.id}`);
|
||||
this.clientSubscriptions.set(client.id, new Set());
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`客户端断开: ${client.id}`);
|
||||
this.clientSubscriptions.delete(client.id);
|
||||
}
|
||||
|
||||
@SubscribeMessage('subscribe')
|
||||
async handleSubscribe(
|
||||
@MessageBody() data: SubscribeMessageData,
|
||||
@ConnectedSocket() client: Socket,
|
||||
) {
|
||||
const { symbols, channels } = data;
|
||||
const subscriptions = this.clientSubscriptions.get(client.id) || new Set();
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
subscriptions.add(symbol);
|
||||
|
||||
// 加入品种房间
|
||||
client.join(`symbol:${symbol}`);
|
||||
|
||||
// 根据频道加入对应房间
|
||||
channels.forEach((channel) => {
|
||||
client.join(`${channel}:${symbol}`);
|
||||
});
|
||||
});
|
||||
|
||||
this.clientSubscriptions.set(client.id, subscriptions);
|
||||
|
||||
// 发送当前数据
|
||||
const ticks = await this.marketService.getBatchTicks(symbols);
|
||||
|
||||
client.emit('subscribed', {
|
||||
symbols,
|
||||
channels,
|
||||
initialData: ticks,
|
||||
});
|
||||
|
||||
this.logger.log(`客户端 ${client.id} 订阅: ${symbols.join(', ')}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('unsubscribe')
|
||||
handleUnsubscribe(
|
||||
@MessageBody() data: { symbols: string[] },
|
||||
@ConnectedSocket() client: Socket,
|
||||
) {
|
||||
const { symbols } = data;
|
||||
const subscriptions = this.clientSubscriptions.get(client.id) || new Set();
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
subscriptions.delete(symbol);
|
||||
client.leave(`symbol:${symbol}`);
|
||||
client.leave(`tick:${symbol}`);
|
||||
client.leave(`kline:${symbol}`);
|
||||
client.leave(`depth:${symbol}`);
|
||||
});
|
||||
|
||||
this.clientSubscriptions.set(client.id, subscriptions);
|
||||
client.emit('unsubscribed', { symbols });
|
||||
}
|
||||
|
||||
// 广播Tick数据
|
||||
broadcastTick(symbol: string, data: any) {
|
||||
this.server.to(`tick:${symbol}`).emit('tick', {
|
||||
symbol,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 广播K线数据
|
||||
broadcastKline(symbol: string, period: string, data: any) {
|
||||
this.server.to(`kline:${symbol}`).emit('kline', {
|
||||
symbol,
|
||||
period,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 广播市场概览
|
||||
broadcastMarketOverview(data: any) {
|
||||
this.server.emit('market:overview', {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 获取在线客户端数量
|
||||
getConnectedClientsCount(): number {
|
||||
return this.clientSubscriptions.size;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketController } from './market.controller';
|
||||
import { MarketService } from './market.service';
|
||||
import { MarketGateway } from './market.gateway';
|
||||
import { MarketDataFeedService } from './market-data-feed.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MarketController],
|
||||
providers: [MarketService, MarketGateway, MarketDataFeedService],
|
||||
exports: [MarketService],
|
||||
})
|
||||
export class MarketModule {}
|
||||
@ -0,0 +1,257 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
import { RedisService } from '@common/redis/redis.service';
|
||||
import { KLineData, TickData, MarketOverview, Product } from './interfaces/market.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MarketService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
// 获取品种列表
|
||||
async getProducts(category?: string, page = 1, size = 20): Promise<{ list: Product[]; total: number }> {
|
||||
const where = category && category !== 'all' ? { category } : {};
|
||||
|
||||
const [products, total] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
skip: (page - 1) * size,
|
||||
take: size,
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
|
||||
// 合并Redis中的实时数据
|
||||
const list = await Promise.all(
|
||||
products.map(async (product) => {
|
||||
const tick = await this.redis.getTick(product.symbol);
|
||||
return this.mergeProductWithTick(product, tick);
|
||||
}),
|
||||
);
|
||||
|
||||
return { list, total };
|
||||
}
|
||||
|
||||
// 获取单个品种详情
|
||||
async getProduct(symbol: string): Promise<Product> {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { symbol: symbol.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`品种 ${symbol} 不存在`);
|
||||
}
|
||||
|
||||
const tick = await this.redis.getTick(product.symbol);
|
||||
return this.mergeProductWithTick(product, tick);
|
||||
}
|
||||
|
||||
// 获取K线数据
|
||||
async getKlineData(
|
||||
symbol: string,
|
||||
period: string,
|
||||
count = 100,
|
||||
startTime?: Date,
|
||||
endTime?: Date,
|
||||
): Promise<KLineData[]> {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { symbol: symbol.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`品种 ${symbol} 不存在`);
|
||||
}
|
||||
|
||||
// 优先从Redis获取
|
||||
const cachedKlines = await this.redis.getKlines(symbol, period, count);
|
||||
if (cachedKlines.length >= count / 2) {
|
||||
return cachedKlines.map((k) => JSON.parse(k)).reverse();
|
||||
}
|
||||
|
||||
// 从数据库查询
|
||||
const where: any = {
|
||||
productId: product.id,
|
||||
period,
|
||||
};
|
||||
|
||||
if (startTime) {
|
||||
where.time = { ...where.time, gte: startTime };
|
||||
}
|
||||
if (endTime) {
|
||||
where.time = { ...where.time, lte: endTime };
|
||||
}
|
||||
|
||||
const klines = await this.prisma.klineData.findMany({
|
||||
where,
|
||||
orderBy: { time: 'desc' },
|
||||
take: count,
|
||||
});
|
||||
|
||||
return klines.reverse().map((k) => ({
|
||||
time: k.time.toISOString(),
|
||||
open: Number(k.open),
|
||||
high: Number(k.high),
|
||||
low: Number(k.low),
|
||||
close: Number(k.close),
|
||||
volume: Number(k.volume),
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取实时Tick数据
|
||||
async getTickData(symbol: string): Promise<TickData> {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { symbol: symbol.toUpperCase() },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`品种 ${symbol} 不存在`);
|
||||
}
|
||||
|
||||
const tick = await this.redis.getTick(symbol);
|
||||
|
||||
if (!tick || Object.keys(tick).length === 0) {
|
||||
// 如果没有缓存数据,返回模拟数据
|
||||
return this.generateMockTick(product.symbol);
|
||||
}
|
||||
|
||||
return {
|
||||
symbol: product.symbol,
|
||||
price: parseFloat(tick.price) || 0,
|
||||
change: parseFloat(tick.change) || 0,
|
||||
changePercent: parseFloat(tick.changePercent) || 0,
|
||||
open: parseFloat(tick.open) || 0,
|
||||
high: parseFloat(tick.high) || 0,
|
||||
low: parseFloat(tick.low) || 0,
|
||||
volume: parseInt(tick.volume) || 0,
|
||||
openInterest: tick.openInterest ? parseInt(tick.openInterest) : undefined,
|
||||
bidPrice: tick.bidPrice ? parseFloat(tick.bidPrice) : undefined,
|
||||
askPrice: tick.askPrice ? parseFloat(tick.askPrice) : undefined,
|
||||
bidVolume: tick.bidVolume ? parseInt(tick.bidVolume) : undefined,
|
||||
askVolume: tick.askVolume ? parseInt(tick.askVolume) : undefined,
|
||||
timestamp: parseInt(tick.timestamp) || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取市场概览
|
||||
async getMarketOverview(): Promise<MarketOverview> {
|
||||
const products = await this.prisma.product.findMany();
|
||||
const ticks = await Promise.all(
|
||||
products.map((p) => this.redis.getTick(p.symbol)),
|
||||
);
|
||||
|
||||
const validTicks = ticks.filter((t) => t && t.price);
|
||||
|
||||
let upCount = 0;
|
||||
let downCount = 0;
|
||||
let totalChange = 0;
|
||||
let totalVolume = 0;
|
||||
|
||||
validTicks.forEach((tick) => {
|
||||
const change = parseFloat(tick.change) || 0;
|
||||
if (change > 0) upCount++;
|
||||
else if (change < 0) downCount++;
|
||||
totalChange += Math.abs(change);
|
||||
totalVolume += parseInt(tick.volume) || 0;
|
||||
});
|
||||
|
||||
// 计算市场热度指数 (0-100)
|
||||
const heatIndex = validTicks.length > 0
|
||||
? Math.min(100, Math.max(0, 50 + (upCount - downCount) * 2))
|
||||
: 50;
|
||||
|
||||
// 计算波动率指数 (基于涨跌幅标准差)
|
||||
const volatilityIndex = validTicks.length > 0
|
||||
? (totalChange / validTicks.length) * 10
|
||||
: 20;
|
||||
|
||||
return {
|
||||
heatIndex: Number(heatIndex.toFixed(1)),
|
||||
heatChange: Number(((Math.random() - 0.5) * 10).toFixed(1)),
|
||||
upCount,
|
||||
downCount,
|
||||
capitalFlow: Number((totalVolume / 10000).toFixed(1)),
|
||||
volatilityIndex: Number(volatilityIndex.toFixed(1)),
|
||||
volatilityChange: Number(((Math.random() - 0.5) * 5).toFixed(1)),
|
||||
};
|
||||
}
|
||||
|
||||
// 批量获取Tick数据
|
||||
async getBatchTicks(symbols: string[]): Promise<Record<string, TickData>> {
|
||||
const result: Record<string, TickData> = {};
|
||||
|
||||
await Promise.all(
|
||||
symbols.map(async (symbol) => {
|
||||
try {
|
||||
result[symbol] = await this.getTickData(symbol);
|
||||
} catch (error) {
|
||||
// 忽略不存在的品种
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 存储Tick数据
|
||||
async storeTickData(symbol: string, data: TickData): Promise<void> {
|
||||
await this.redis.setTick(symbol, {
|
||||
price: data.price.toString(),
|
||||
change: data.change.toString(),
|
||||
changePercent: data.changePercent.toString(),
|
||||
open: data.open.toString(),
|
||||
high: data.high.toString(),
|
||||
low: data.low.toString(),
|
||||
volume: data.volume.toString(),
|
||||
openInterest: data.openInterest?.toString(),
|
||||
bidPrice: data.bidPrice?.toString(),
|
||||
askPrice: data.askPrice?.toString(),
|
||||
bidVolume: data.bidVolume?.toString(),
|
||||
askVolume: data.askVolume?.toString(),
|
||||
timestamp: data.timestamp.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 存储K线数据
|
||||
async storeKlineData(symbol: string, period: string, data: KLineData): Promise<void> {
|
||||
await this.redis.addKline(symbol, period, JSON.stringify(data));
|
||||
}
|
||||
|
||||
private mergeProductWithTick(product: any, tick: Record<string, string>): Product {
|
||||
const basePrice = 500; // 基础价格,实际应从历史数据获取
|
||||
|
||||
return {
|
||||
id: product.id.toString(),
|
||||
symbol: product.symbol,
|
||||
name: product.name,
|
||||
category: product.category,
|
||||
price: tick.price ? parseFloat(tick.price) : basePrice,
|
||||
change: tick.change ? parseFloat(tick.change) : 0,
|
||||
changePercent: tick.changePercent ? parseFloat(tick.changePercent) : 0,
|
||||
open: tick.open ? parseFloat(tick.open) : basePrice,
|
||||
high: tick.high ? parseFloat(tick.high) : basePrice,
|
||||
low: tick.low ? parseFloat(tick.low) : basePrice,
|
||||
volume: tick.volume ? parseInt(tick.volume) : 0,
|
||||
openInterest: tick.openInterest ? parseInt(tick.openInterest) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
private generateMockTick(symbol: string): TickData {
|
||||
const basePrice = 500;
|
||||
const change = (Math.random() - 0.5) * 20;
|
||||
|
||||
return {
|
||||
symbol,
|
||||
price: basePrice + change,
|
||||
change,
|
||||
changePercent: (change / basePrice) * 100,
|
||||
open: basePrice,
|
||||
high: basePrice + Math.abs(change) + 10,
|
||||
low: basePrice - Math.abs(change) - 10,
|
||||
volume: Math.floor(Math.random() * 100000),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { IsString, IsNumber, IsEnum, IsOptional, IsDateString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OptionPricingDto {
|
||||
@ApiProperty({ description: '标的资产代码' })
|
||||
@IsString()
|
||||
underlying: string;
|
||||
|
||||
@ApiProperty({ description: '行权价' })
|
||||
@IsNumber()
|
||||
strike: number;
|
||||
|
||||
@ApiProperty({ description: '到期日 (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
expiry: string;
|
||||
|
||||
@ApiProperty({ description: '期权类型', enum: ['call', 'put'] })
|
||||
@IsEnum(['call', 'put'])
|
||||
type: 'call' | 'put';
|
||||
|
||||
@ApiProperty({ description: '标的资产价格', required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
underlyingPrice?: number;
|
||||
|
||||
@ApiProperty({ description: '波动率 (如 0.25 表示 25%)', required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
volatility?: number;
|
||||
|
||||
@ApiProperty({ description: '无风险利率 (如 0.025 表示 2.5%)', required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
riskFreeRate?: number;
|
||||
}
|
||||
|
||||
export class GetOptionChainDto {
|
||||
@ApiProperty({ description: '到期日 (YYYY-MM-DD)', required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export class CalculateStrategyDto {
|
||||
@ApiProperty({ description: '策略名称', example: 'long_call_spread' })
|
||||
@IsString()
|
||||
strategy: string;
|
||||
|
||||
@ApiProperty({ description: '标的资产代码' })
|
||||
@IsString()
|
||||
underlying: string;
|
||||
|
||||
@ApiProperty({ description: '到期日 (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
expiry: string;
|
||||
|
||||
@ApiProperty({ description: '行权价差', type: [Number] })
|
||||
strikes: number[];
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
// 期权希腊值
|
||||
export interface Greeks {
|
||||
delta: number;
|
||||
gamma: number;
|
||||
theta: number;
|
||||
vega: number;
|
||||
rho: number;
|
||||
}
|
||||
|
||||
// 期权定价结果
|
||||
export interface OptionPricingResult extends Greeks {
|
||||
price: number;
|
||||
impliedVol?: number;
|
||||
}
|
||||
|
||||
// 期权合约
|
||||
export interface OptionContract {
|
||||
symbol: string;
|
||||
underlying: string;
|
||||
type: 'call' | 'put';
|
||||
strikePrice: number;
|
||||
expiryDate: string;
|
||||
price?: number;
|
||||
iv?: number;
|
||||
delta?: number;
|
||||
gamma?: number;
|
||||
theta?: number;
|
||||
vega?: number;
|
||||
rho?: number;
|
||||
volume?: number;
|
||||
openInterest?: number;
|
||||
}
|
||||
|
||||
// 期权链
|
||||
export interface OptionChain {
|
||||
underlying: string;
|
||||
underlyingPrice: number;
|
||||
expiryDate: string;
|
||||
strikes: number[];
|
||||
calls: OptionContract[];
|
||||
puts: OptionContract[];
|
||||
}
|
||||
|
||||
// 波动率曲面数据点
|
||||
export interface VolatilityPoint {
|
||||
strike: number;
|
||||
expiry: string;
|
||||
iv: number;
|
||||
}
|
||||
|
||||
// 波动率曲面
|
||||
export interface VolatilitySurface {
|
||||
underlying: string;
|
||||
points: VolatilityPoint[];
|
||||
}
|
||||
|
||||
// 期权策略
|
||||
export interface OptionStrategy {
|
||||
name: string;
|
||||
description: string;
|
||||
legs: StrategyLeg[];
|
||||
maxProfit?: number;
|
||||
maxLoss?: number;
|
||||
breakeven: number[];
|
||||
}
|
||||
|
||||
export interface StrategyLeg {
|
||||
type: 'call' | 'put';
|
||||
strike: number;
|
||||
expiry: string;
|
||||
position: 1 | -1; // 1 for buy, -1 for sell
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 策略盈亏分析
|
||||
export interface StrategyPayoff {
|
||||
pricePoints: number[];
|
||||
payoffs: number[];
|
||||
maxProfit: number;
|
||||
maxLoss: number;
|
||||
breakeven: number[];
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OptionsController } from './options.controller';
|
||||
import { OptionsService } from './options.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
|
||||
@Module({
|
||||
imports: [MarketModule],
|
||||
controllers: [OptionsController],
|
||||
providers: [OptionsService],
|
||||
exports: [OptionsService],
|
||||
})
|
||||
export class OptionsModule {}
|
||||
@ -0,0 +1,18 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ApiTags('用户')
|
||||
@Controller('user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get('stats')
|
||||
async getUserStats(@CurrentUser('userId') userId: number) {
|
||||
return this.userService.getUserStats(userId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getUserStats(userId: number) {
|
||||
const [watchlistCount, alertCount] = await Promise.all([
|
||||
this.prisma.watchlist.count({ where: { userId } }),
|
||||
this.prisma.priceAlert.count({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
watchlistCount,
|
||||
alertCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
|
||||
@ApiTags('自选股')
|
||||
@Controller('watchlist')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class WatchlistController {
|
||||
constructor(private readonly watchlistService: WatchlistService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取自选股列表' })
|
||||
async getWatchlist(@CurrentUser('userId') userId: number) {
|
||||
return this.watchlistService.getWatchlist(userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '添加自选股' })
|
||||
async addToWatchlist(
|
||||
@CurrentUser('userId') userId: number,
|
||||
@Body() dto: { symbol: string; alertPrice?: number },
|
||||
) {
|
||||
return this.watchlistService.addToWatchlist(
|
||||
userId,
|
||||
dto.symbol,
|
||||
dto.alertPrice,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':symbol')
|
||||
@ApiOperation({ summary: '删除自选股' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async removeFromWatchlist(
|
||||
@CurrentUser('userId') userId: number,
|
||||
@Param('symbol') symbol: string,
|
||||
) {
|
||||
await this.watchlistService.removeFromWatchlist(userId, symbol);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
@Put(':symbol/alert')
|
||||
@ApiOperation({ summary: '更新价格预警' })
|
||||
@ApiParam({ name: 'symbol', description: '品种代码' })
|
||||
async updateAlertPrice(
|
||||
@CurrentUser('userId') userId: number,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() dto: { alertPrice: number },
|
||||
) {
|
||||
return this.watchlistService.updateAlertPrice(
|
||||
userId,
|
||||
symbol,
|
||||
dto.alertPrice,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WatchlistController } from './watchlist.controller';
|
||||
import { WatchlistService } from './watchlist.service';
|
||||
import { MarketModule } from '../market/market.module';
|
||||
|
||||
@Module({
|
||||
imports: [MarketModule],
|
||||
controllers: [WatchlistController],
|
||||
providers: [WatchlistService],
|
||||
exports: [WatchlistService],
|
||||
})
|
||||
export class WatchlistModule {}
|
||||
@ -0,0 +1,149 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '@common/prisma/prisma.service';
|
||||
import { MarketService } from '../market/market.service';
|
||||
|
||||
export interface WatchlistItem {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
alertPrice?: number;
|
||||
addTime: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WatchlistService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly marketService: MarketService,
|
||||
) {}
|
||||
|
||||
async getWatchlist(userId: number): Promise<WatchlistItem[]> {
|
||||
const items = await this.prisma.watchlist.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const result: WatchlistItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const product = await this.marketService.getProduct(item.symbol);
|
||||
result.push({
|
||||
id: item.id.toString(),
|
||||
symbol: item.symbol,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
change: product.change,
|
||||
changePercent: product.changePercent,
|
||||
alertPrice: item.alertPrice ? Number(item.alertPrice) : undefined,
|
||||
addTime: item.createdAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果品种不存在,跳过
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async addToWatchlist(
|
||||
userId: number,
|
||||
symbol: string,
|
||||
alertPrice?: number,
|
||||
): Promise<WatchlistItem> {
|
||||
// 检查品种是否存在
|
||||
const product = await this.marketService.getProduct(symbol);
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await this.prisma.watchlist.findUnique({
|
||||
where: {
|
||||
userId_symbol: {
|
||||
userId,
|
||||
symbol: symbol.toUpperCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('该品种已在自选股中');
|
||||
}
|
||||
|
||||
const item = await this.prisma.watchlist.create({
|
||||
data: {
|
||||
userId,
|
||||
symbol: symbol.toUpperCase(),
|
||||
alertPrice: alertPrice || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
symbol: item.symbol,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
change: product.change,
|
||||
changePercent: product.changePercent,
|
||||
alertPrice: item.alertPrice ? Number(item.alertPrice) : undefined,
|
||||
addTime: item.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async removeFromWatchlist(userId: number, symbol: string): Promise<void> {
|
||||
const item = await this.prisma.watchlist.findUnique({
|
||||
where: {
|
||||
userId_symbol: {
|
||||
userId,
|
||||
symbol: symbol.toUpperCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('该品种不在自选股中');
|
||||
}
|
||||
|
||||
await this.prisma.watchlist.delete({
|
||||
where: { id: item.id },
|
||||
});
|
||||
}
|
||||
|
||||
async updateAlertPrice(
|
||||
userId: number,
|
||||
symbol: string,
|
||||
alertPrice: number,
|
||||
): Promise<WatchlistItem> {
|
||||
const item = await this.prisma.watchlist.findUnique({
|
||||
where: {
|
||||
userId_symbol: {
|
||||
userId,
|
||||
symbol: symbol.toUpperCase(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('该品种不在自选股中');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.watchlist.update({
|
||||
where: { id: item.id },
|
||||
data: { alertPrice },
|
||||
});
|
||||
|
||||
const product = await this.marketService.getProduct(symbol);
|
||||
|
||||
return {
|
||||
id: updated.id.toString(),
|
||||
symbol: updated.symbol,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
change: product.change,
|
||||
changePercent: product.changePercent,
|
||||
alertPrice: updated.alertPrice ? Number(updated.alertPrice) : undefined,
|
||||
addTime: updated.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@config/*": ["src/config/*"],
|
||||
"@common/*": ["src/common/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
import { useState } from 'react';
|
||||
import { Flame, TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronRight, Calendar, Target } from 'lucide-react';
|
||||
import type { HotEvent } from '@/types';
|
||||
|
||||
interface HotEventsProps {
|
||||
events: HotEvent[];
|
||||
}
|
||||
|
||||
// 影响等级星级
|
||||
function ImpactStars({ level }: { level: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-4 h-4 rounded-sm ${i < level ? 'bg-[#7dd75a]' : 'bg-[#3f3f46]'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 影响方向图标
|
||||
function ImpactBadge({ impact }: { impact: 'bullish' | 'bearish' | 'neutral' }) {
|
||||
const config = {
|
||||
bullish: { icon: TrendingUp, text: '利多', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10' },
|
||||
bearish: { icon: TrendingDown, text: '利空', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10' },
|
||||
neutral: { icon: Minus, text: '中性', color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10' },
|
||||
};
|
||||
|
||||
const { icon: Icon, text, color, bg } = config[impact];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
<span className={`text-xs font-medium ${color}`}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotEventsPanel({ events }: HotEventsProps) {
|
||||
const [selectedEvent, setSelectedEvent] = useState<HotEvent>(events[0]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||
{/* 事件列表 */}
|
||||
<div className="lg:col-span-3 space-y-3">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Flame className="w-5 h-5 text-[#7dd75a]" />
|
||||
<h3 className="text-lg font-semibold text-white">热点事件</h3>
|
||||
<span className="text-xs text-[#a1a1aa] ml-auto">共 {events.length} 条</span>
|
||||
</div>
|
||||
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all duration-200 ${
|
||||
selectedEvent?.id === event.id
|
||||
? 'border-[#7dd75a] bg-[#7dd75a]/5'
|
||||
: 'border-[#27272a] bg-[#0d0d10] hover:border-[#3f3f46]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-medium text-white">{event.title}</h4>
|
||||
<ImpactBadge impact={event.impact} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[#a1a1aa] mb-3 line-clamp-2">{event.summary}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1 text-[#71717a]">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{event.time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-3 h-3 text-[#71717a]" />
|
||||
<span className="text-[#71717a]">影响:</span>
|
||||
<div className="flex gap-1">
|
||||
{event.affectedProducts.slice(0, 3).map((product) => (
|
||||
<span
|
||||
key={product}
|
||||
className="px-1.5 py-0.5 rounded bg-[#27272a] text-[#e6e6e6]"
|
||||
>
|
||||
{product}
|
||||
</span>
|
||||
))}
|
||||
{event.affectedProducts.length > 3 && (
|
||||
<span className="text-[#71717a]">+{event.affectedProducts.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<ImpactStars level={event.impactLevel} />
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${
|
||||
selectedEvent?.id === event.id ? 'text-[#7dd75a] rotate-90' : 'text-[#71717a]'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 事件分析面板 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="sticky top-4 p-5 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
{selectedEvent ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
|
||||
<AlertTriangle className="w-4 h-4 text-[#7dd75a]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">智能分析</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm text-[#a1a1aa] mb-2">事件解读</h4>
|
||||
<p className="text-sm text-white leading-relaxed">{selectedEvent.analysis}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm text-[#a1a1aa] mb-2">影响品种</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEvent.affectedProducts.map((product) => (
|
||||
<span
|
||||
key={product}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${
|
||||
selectedEvent.impact === 'bullish'
|
||||
? 'bg-[#7dd75a]/10 text-[#7dd75a]'
|
||||
: selectedEvent.impact === 'bearish'
|
||||
? 'bg-[#ef4444]/10 text-[#ef4444]'
|
||||
: 'bg-[#a1a1aa]/10 text-[#a1a1aa]'
|
||||
}`}
|
||||
>
|
||||
{product}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm text-[#a1a1aa] mb-2">风险提示</h4>
|
||||
<ul className="space-y-2">
|
||||
{selectedEvent.risks.map((risk, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-[#e6e6e6]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#f59e0b] mt-1.5 flex-shrink-0" />
|
||||
<span>{risk}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#27272a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[#a1a1aa]">影响强度</span>
|
||||
<ImpactStars level={selectedEvent.impactLevel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[#71717a]">
|
||||
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>选择事件查看分析</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,373 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as echarts from 'echarts';
|
||||
import type { KLineData, MACDData } from '@/types';
|
||||
|
||||
interface KLineChartProps {
|
||||
klineData: KLineData[];
|
||||
macdData: MACDData[];
|
||||
resistance: number[];
|
||||
support: number[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function KLineChart({ klineData, macdData, resistance, support, height = 500 }: KLineChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || klineData.length === 0) return;
|
||||
|
||||
// 初始化图表
|
||||
if (!chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
const chart = chartInstance.current;
|
||||
|
||||
// 处理数据
|
||||
const dates = klineData.map(d => {
|
||||
const date = new Date(d.time);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const klineValues = klineData.map(d => [d.open, d.close, d.low, d.high]);
|
||||
const volumes = klineData.map(d => d.volume);
|
||||
|
||||
// MACD数据
|
||||
const difData = macdData.map(d => d.dif);
|
||||
const deaData = macdData.map(d => d.dea);
|
||||
const macdBarData = macdData.map(d => ({
|
||||
value: d.macd,
|
||||
itemStyle: {
|
||||
color: d.macd >= 0 ? '#7dd75a' : '#ef4444',
|
||||
},
|
||||
}));
|
||||
|
||||
// 生成关键点位线
|
||||
const markLines: any[] = [];
|
||||
|
||||
// 压力位
|
||||
resistance.forEach((price, index) => {
|
||||
markLines.push({
|
||||
yAxis: price,
|
||||
name: `压力${index + 1}`,
|
||||
lineStyle: {
|
||||
color: '#ef4444',
|
||||
type: 'solid',
|
||||
width: 1.5,
|
||||
},
|
||||
label: {
|
||||
formatter: `压力{${index + 1}}: {c}`,
|
||||
color: '#ef4444',
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 支撑位
|
||||
support.forEach((price, index) => {
|
||||
markLines.push({
|
||||
yAxis: price,
|
||||
name: `支撑${index + 1}`,
|
||||
lineStyle: {
|
||||
color: '#7dd75a',
|
||||
type: 'solid',
|
||||
width: 1.5,
|
||||
},
|
||||
label: {
|
||||
formatter: `支撑{${index + 1}}: {c}`,
|
||||
color: '#7dd75a',
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 计算MA
|
||||
const calculateMA = (dayCount: number) => {
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < klineData.length; i++) {
|
||||
if (i < dayCount - 1) {
|
||||
result.push(Number(klineData[i].close.toFixed(2)));
|
||||
continue;
|
||||
}
|
||||
let sum = 0;
|
||||
for (let j = 0; j < dayCount; j++) {
|
||||
sum += klineData[i - j].close;
|
||||
}
|
||||
result.push(Number((sum / dayCount).toFixed(2)));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const ma5 = calculateMA(5);
|
||||
const ma10 = calculateMA(10);
|
||||
const ma20 = calculateMA(20);
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 500,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#0d5d1b',
|
||||
},
|
||||
},
|
||||
backgroundColor: 'rgba(9, 9, 11, 0.95)',
|
||||
borderColor: '#7dd75a',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const kline = params.find((p: any) => p.seriesName === 'K线');
|
||||
const vol = params.find((p: any) => p.seriesName === '成交量');
|
||||
const macd = params.find((p: any) => p.seriesName === 'MACD');
|
||||
|
||||
if (!kline) return '';
|
||||
|
||||
const data = kline.data;
|
||||
const open = data[1];
|
||||
const close = data[2];
|
||||
const low = data[3];
|
||||
const high = data[4];
|
||||
const change = ((close - open) / open * 100).toFixed(2);
|
||||
const changeColor = close >= open ? '#7dd75a' : '#ef4444';
|
||||
|
||||
return `
|
||||
<div style="padding: 8px;">
|
||||
<div style="font-weight: bold; margin-bottom: 8px; color: #e6e6e6;">${kline.axisValue}</div>
|
||||
<div style="display: grid; grid-template-columns: auto auto; gap: 4px 16px; font-size: 12px;">
|
||||
<span style="color: #a1a1aa;">开盘:</span><span style="color: #fff;">${open.toFixed(2)}</span>
|
||||
<span style="color: #a1a1aa;">最高:</span><span style="color: #fff;">${high.toFixed(2)}</span>
|
||||
<span style="color: #a1a1aa;">最低:</span><span style="color: #fff;">${low.toFixed(2)}</span>
|
||||
<span style="color: #a1a1aa;">收盘:</span><span style="color: ${changeColor};">${close.toFixed(2)} (${change}%)</span>
|
||||
${vol ? `<span style="color: #a1a1aa;">成交量:</span><span style="color: #fff;">${vol.data.toLocaleString()}</span>` : ''}
|
||||
${macd ? `<span style="color: #a1a1aa;">MACD:</span><span style="color: ${macd.data >= 0 ? '#7dd75a' : '#ef4444'};">${macd.data.toFixed(4)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
},
|
||||
axisPointer: {
|
||||
link: [{ xAxisIndex: 'all' }],
|
||||
label: {
|
||||
backgroundColor: '#0d5d1b',
|
||||
},
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '5%',
|
||||
height: '50%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '58%',
|
||||
height: '15%',
|
||||
containLabel: true,
|
||||
},
|
||||
{
|
||||
left: '3%',
|
||||
right: '3%',
|
||||
top: '76%',
|
||||
height: '18%',
|
||||
containLabel: true,
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
data: dates,
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 1,
|
||||
data: dates,
|
||||
boundaryGap: true,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
gridIndex: 2,
|
||||
data: dates,
|
||||
boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
scale: true,
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#27272a', type: 'dashed' } },
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 2,
|
||||
axisLine: { lineStyle: { color: '#3f3f46' } },
|
||||
axisLabel: { color: '#a1a1aa', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#27272a', type: 'dashed' } },
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: [0, 1, 2],
|
||||
start: 50,
|
||||
end: 100,
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
xAxisIndex: [0, 1, 2],
|
||||
start: 50,
|
||||
end: 100,
|
||||
height: 20,
|
||||
bottom: 0,
|
||||
borderColor: '#3f3f46',
|
||||
fillerColor: 'rgba(125, 215, 90, 0.2)',
|
||||
handleStyle: { color: '#7dd75a' },
|
||||
textStyle: { color: '#a1a1aa' },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'K线',
|
||||
type: 'candlestick',
|
||||
data: klineValues,
|
||||
itemStyle: {
|
||||
color: '#7dd75a',
|
||||
color0: '#ef4444',
|
||||
borderColor: '#7dd75a',
|
||||
borderColor0: '#ef4444',
|
||||
},
|
||||
markLine: {
|
||||
symbol: 'none',
|
||||
data: markLines,
|
||||
animation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MA5',
|
||||
type: 'line',
|
||||
data: ma5,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#fbbf24', width: 1 },
|
||||
},
|
||||
{
|
||||
name: 'MA10',
|
||||
type: 'line',
|
||||
data: ma10,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#60a5fa', width: 1 },
|
||||
},
|
||||
{
|
||||
name: 'MA20',
|
||||
type: 'line',
|
||||
data: ma20,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#c084fc', width: 1 },
|
||||
},
|
||||
{
|
||||
name: '成交量',
|
||||
type: 'bar',
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
data: volumes,
|
||||
itemStyle: {
|
||||
color: (params: any) => {
|
||||
const index = params.dataIndex;
|
||||
const close = klineData[index]?.close;
|
||||
const open = klineData[index]?.open;
|
||||
return close >= open ? 'rgba(125, 215, 90, 0.6)' : 'rgba(239, 68, 68, 0.6)';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'MACD',
|
||||
type: 'bar',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: macdBarData,
|
||||
},
|
||||
{
|
||||
name: 'DIF',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: difData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#fbbf24', width: 1.5 },
|
||||
},
|
||||
{
|
||||
name: 'DEA',
|
||||
type: 'line',
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
data: deaData,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#60a5fa', width: 1.5 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
setLoading(false);
|
||||
|
||||
// 响应式
|
||||
const handleResize = () => {
|
||||
chart.resize();
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [klineData, macdData, resistance, support]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#09090b]/80 z-10">
|
||||
<div className="flex items-center gap-2 text-[#7dd75a]">
|
||||
<div className="w-5 h-5 border-2 border-[#7dd75a] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chartRef} style={{ height: `${height}px` }} className="w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3, Zap } from 'lucide-react';
|
||||
import type { MarketOverview } from '@/types';
|
||||
|
||||
interface MarketOverviewProps {
|
||||
data: MarketOverview;
|
||||
}
|
||||
|
||||
// 数字动画组件
|
||||
function AnimatedNumber({ value, decimals = 1, suffix = '' }: { value: number; decimals?: number; suffix?: string }) {
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
const startTime = useRef<number | null>(null);
|
||||
const duration = 1000;
|
||||
|
||||
useEffect(() => {
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime.current) startTime.current = timestamp;
|
||||
const progress = Math.min((timestamp - startTime.current) / duration, 1);
|
||||
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||
setDisplayValue(value * easeOut);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
startTime.current = null;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{displayValue.toFixed(decimals)}{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 迷你趋势图
|
||||
function MiniChart({ isUp }: { isUp: boolean }) {
|
||||
const points = isUp
|
||||
? '0,30 10,25 20,28 30,20 40,22 50,15 60,18 70,10 80,12 90,5 100,8'
|
||||
: '0,10 10,15 20,12 30,20 40,18 50,25 60,22 70,30 80,28 90,35 100,32';
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 40" className="w-full h-10">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={isUp ? '#7dd75a' : '#ef4444'}
|
||||
strokeWidth="2"
|
||||
points={points}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${isUp}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={isUp ? '#7dd75a' : '#ef4444'} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={isUp ? '#7dd75a' : '#ef4444'} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
fill={`url(#gradient-${isUp})`}
|
||||
points={`${points} 100,40 0,40`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MarketOverviewPanel({ data }: MarketOverviewProps) {
|
||||
const cards = [
|
||||
{
|
||||
title: '市场热度指数',
|
||||
value: data.heatIndex,
|
||||
change: data.heatChange,
|
||||
suffix: '',
|
||||
decimals: 1,
|
||||
icon: Activity,
|
||||
isUp: data.heatChange > 0,
|
||||
description: data.heatChange > 0 ? '多头情绪高涨' : '市场情绪偏冷',
|
||||
},
|
||||
{
|
||||
title: '涨跌分布',
|
||||
value: data.upCount,
|
||||
change: data.downCount,
|
||||
suffix: '',
|
||||
decimals: 0,
|
||||
icon: BarChart3,
|
||||
isUp: true,
|
||||
description: `涨: ${data.upCount} | 跌: ${data.downCount}`,
|
||||
isDistribution: true,
|
||||
},
|
||||
{
|
||||
title: '资金流向',
|
||||
value: data.capitalFlow,
|
||||
change: 0,
|
||||
suffix: '亿',
|
||||
decimals: 1,
|
||||
icon: DollarSign,
|
||||
isUp: data.capitalFlow > 0,
|
||||
description: data.capitalFlow > 0 ? '资金净流入' : '资金净流出',
|
||||
},
|
||||
{
|
||||
title: '波动率指数',
|
||||
value: data.volatilityIndex,
|
||||
change: data.volatilityChange,
|
||||
suffix: '',
|
||||
decimals: 1,
|
||||
icon: Zap,
|
||||
isUp: data.volatilityChange > 0,
|
||||
description: data.volatilityChange > 0 ? '波动扩大' : '波动收窄',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card, index) => (
|
||||
<div
|
||||
key={card.title}
|
||||
className="bg-[#0d0d10] border border-[#27272a] rounded-xl p-5 hover:border-[#7dd75a]/50 transition-all duration-300 hover:shadow-lg hover:shadow-[#7dd75a]/5 group"
|
||||
style={{
|
||||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${card.isUp ? 'bg-[#7dd75a]/10' : 'bg-[#ef4444]/10'}`}>
|
||||
<card.icon className={`w-4 h-4 ${card.isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`} />
|
||||
</div>
|
||||
<span className="text-[#a1a1aa] text-sm">{card.title}</span>
|
||||
</div>
|
||||
{!card.isDistribution && card.change !== 0 && (
|
||||
<div className={`flex items-center gap-1 text-xs ${card.change > 0 ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
|
||||
{card.change > 0 ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
<span>{card.change > 0 ? '+' : ''}{card.change}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<span className={`text-2xl font-bold font-mono ${card.isUp ? 'text-white' : 'text-[#ef4444]'}`}>
|
||||
{card.isDistribution ? (
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="text-[#7dd75a]">{data.upCount}</span>
|
||||
<span className="text-[#a1a1aa] text-lg">/</span>
|
||||
<span className="text-[#ef4444]">{data.downCount}</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<AnimatedNumber value={card.value} decimals={card.decimals} suffix={card.suffix} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#a1a1aa] mb-3">{card.description}</div>
|
||||
|
||||
<MiniChart isUp={card.isUp} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BarChart3, Bell, Clock, Menu, X } from 'lucide-react';
|
||||
|
||||
interface NavbarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', label: '市场概览' },
|
||||
{ id: 'events', label: '热点事件' },
|
||||
{ id: 'products', label: '品种分析' },
|
||||
{ id: 'risks', label: '风险提醒' },
|
||||
];
|
||||
|
||||
export function Navbar({ activeTab, onTabChange }: NavbarProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'bg-[#09090b]/95 backdrop-blur-md shadow-lg shadow-black/20'
|
||||
: 'bg-[#09090b]'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
|
||||
<BarChart3 className="w-5 h-5 text-[#7dd75a]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">期货智析</h1>
|
||||
<p className="text-xs text-[#71717a] hidden sm:block">智能期货期权分析系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative ${
|
||||
activeTab === item.id
|
||||
? 'text-[#7dd75a]'
|
||||
: 'text-[#a1a1aa] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{activeTab === item.id && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-0.5 bg-[#7dd75a] rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Time */}
|
||||
<div className="hidden sm:flex items-center gap-2 text-sm text-[#71717a]">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* Notification */}
|
||||
<button className="p-2 rounded-lg hover:bg-[#27272a] transition-colors relative">
|
||||
<Bell className="w-4 h-4 text-[#a1a1aa]" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-[#ef4444] rounded-full" />
|
||||
</button>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg hover:bg-[#27272a] transition-colors"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="w-5 h-5 text-[#a1a1aa]" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-[#a1a1aa]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-[#27272a] bg-[#09090b]/95 backdrop-blur-md">
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
onTabChange(item.id);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-3 rounded-lg text-left text-sm font-medium transition-all duration-200 ${
|
||||
activeTab === item.id
|
||||
? 'bg-[#7dd75a]/10 text-[#7dd75a]'
|
||||
: 'text-[#a1a1aa] hover:bg-[#27272a] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="pt-3 border-t border-[#27272a] mt-3">
|
||||
<div className="flex items-center gap-2 text-sm text-[#71717a] px-4 py-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-mono">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
import { TrendingUp, TrendingDown, Minus, Clock, Target, BarChart3, ChevronRight } from 'lucide-react';
|
||||
import type { FuturesProduct, TrendDirection } from '@/types';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: FuturesProduct;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// 趋势标签
|
||||
function TrendBadge({ direction, label }: { direction: TrendDirection; label: string }) {
|
||||
const config = {
|
||||
up: { icon: TrendingUp, color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10', border: 'border-[#7dd75a]/30' },
|
||||
down: { icon: TrendingDown, color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10', border: 'border-[#ef4444]/30' },
|
||||
sideways: { icon: Minus, color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10', border: 'border-[#a1a1aa]/30' },
|
||||
};
|
||||
|
||||
const { icon: Icon, color, bg, border } = config[direction];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg} ${border} border`}>
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
<span className={`text-xs font-medium ${color}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 建议标签
|
||||
function RecommendationBadge({ recommendation }: { recommendation: 'long' | 'short' | 'wait' }) {
|
||||
const config = {
|
||||
long: { text: '逢低做多', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10' },
|
||||
short: { text: '逢高做空', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10' },
|
||||
wait: { text: '观望等待', color: 'text-[#a1a1aa]', bg: 'bg-[#a1a1aa]/10' },
|
||||
};
|
||||
|
||||
const { text, color, bg } = config[recommendation];
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium ${color} ${bg}`}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 成功率进度条
|
||||
function SuccessRateBar({ rate }: { rate: number }) {
|
||||
const getColor = (r: number) => {
|
||||
if (r >= 70) return 'bg-[#7dd75a]';
|
||||
if (r >= 50) return 'bg-[#f59e0b]';
|
||||
return 'bg-[#ef4444]';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-[#27272a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${getColor(rate)}`}
|
||||
style={{ width: `${rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${rate >= 70 ? 'text-[#7dd75a]' : rate >= 50 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
|
||||
{rate}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onClick }: ProductCardProps) {
|
||||
const isUp = product.change >= 0;
|
||||
|
||||
const cycleLabels: Record<string, string> = {
|
||||
m5: '5分',
|
||||
m15: '15分',
|
||||
m30: '30分',
|
||||
m60: '60分',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10] hover:border-[#7dd75a]/50 transition-all duration-300 cursor-pointer group hover:shadow-lg hover:shadow-[#7dd75a]/5"
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-semibold text-white">{product.name}</h4>
|
||||
<span className="text-xs text-[#71717a]">({product.code})</span>
|
||||
</div>
|
||||
<RecommendationBadge recommendation={product.recommendation} />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-xl font-bold font-mono ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
|
||||
¥{product.price.toLocaleString()}
|
||||
</div>
|
||||
<div className={`flex items-center justify-end gap-1 text-sm ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
|
||||
{isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 多周期趋势 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-1 text-xs text-[#a1a1aa] mb-2">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>多周期趋势</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(product.cycles).map(([key, trend]) => (
|
||||
<TrendBadge key={key} direction={trend} label={cycleLabels[key]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成功率和趋势评分 */}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[#a1a1aa] flex items-center gap-1">
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
交易成功率
|
||||
</span>
|
||||
</div>
|
||||
<SuccessRateBar rate={product.successRate} />
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[#a1a1aa]">趋势评分</span>
|
||||
<span className={`font-medium ${product.trendScore >= 80 ? 'text-[#7dd75a]' : product.trendScore >= 60 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
|
||||
{product.trendScore}/100
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#27272a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
product.trendScore >= 80 ? 'bg-[#7dd75a]' : product.trendScore >= 60 ? 'bg-[#f59e0b]' : 'bg-[#ef4444]'
|
||||
}`}
|
||||
style={{ width: `${product.trendScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键点位 */}
|
||||
<div className="pt-3 border-t border-[#27272a]">
|
||||
<div className="flex items-center gap-1 text-xs text-[#a1a1aa] mb-2">
|
||||
<Target className="w-3 h-3" />
|
||||
<span>关键点位</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<div>
|
||||
<span className="text-[#71717a]">压力: </span>
|
||||
<span className="text-[#ef4444] font-mono">{product.keyLevels.resistance[0]?.toLocaleString() || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#71717a]">支撑: </span>
|
||||
<span className="text-[#7dd75a] font-mono">{product.keyLevels.support[0]?.toLocaleString() || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬停指示 */}
|
||||
<div className="flex items-center justify-end mt-3 pt-2 border-t border-[#27272a]/50">
|
||||
<span className="text-xs text-[#71717a] group-hover:text-[#7dd75a] transition-colors flex items-center gap-1">
|
||||
查看详情
|
||||
<ChevronRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,380 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, Target, Clock, BarChart3, Activity, Zap } from 'lucide-react';
|
||||
import { KLineChart } from './KLineChart';
|
||||
import type { FuturesProduct, CycleAnalysis, TechnicalIndicators, TradingAdvice } from '@/types';
|
||||
import { generateCycleAnalysis, generateTechnicalIndicators, generateTradingAdvice } from '@/data/mockData';
|
||||
|
||||
interface ProductDetailProps {
|
||||
product: FuturesProduct;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
// 周期切换按钮
|
||||
function PeriodButton({
|
||||
label,
|
||||
isActive,
|
||||
onClick
|
||||
}: {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-[#7dd75a] text-[#09090b]'
|
||||
: 'bg-[#27272a] text-[#a1a1aa] hover:bg-[#3f3f46] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 指标卡片
|
||||
function IndicatorCard({
|
||||
title,
|
||||
value,
|
||||
status,
|
||||
signal
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
status: string;
|
||||
signal?: 'positive' | 'negative' | 'neutral';
|
||||
}) {
|
||||
const signalColors = {
|
||||
positive: 'text-[#7dd75a]',
|
||||
negative: 'text-[#ef4444]',
|
||||
neutral: 'text-[#a1a1aa]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-[#27272a] bg-[#09090b]">
|
||||
<div className="text-xs text-[#71717a] mb-1">{title}</div>
|
||||
<div className={`text-sm font-medium ${signal ? signalColors[signal] : 'text-white'}`}>{value}</div>
|
||||
<div className="text-xs text-[#a1a1aa] mt-1">{status}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 关键点位行
|
||||
function KeyLevelRow({
|
||||
label,
|
||||
value,
|
||||
type
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
type: 'resistance' | 'support';
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b border-[#27272a]/50 last:border-0">
|
||||
<span className="text-sm text-[#a1a1aa]">{label}</span>
|
||||
<span className={`text-sm font-mono font-medium ${type === 'resistance' ? 'text-[#ef4444]' : 'text-[#7dd75a]'}`}>
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductDetail({ product, onBack }: ProductDetailProps) {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'m5' | 'm15' | 'm30' | 'm60'>('m15');
|
||||
const [cycleData, setCycleData] = useState<CycleAnalysis | null>(null);
|
||||
const [indicators, setIndicators] = useState<TechnicalIndicators | null>(null);
|
||||
const [advice, setAdvice] = useState<TradingAdvice | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
// 模拟数据加载
|
||||
setTimeout(() => {
|
||||
setCycleData(generateCycleAnalysis(product.id, selectedPeriod));
|
||||
setIndicators(generateTechnicalIndicators(product.id));
|
||||
setAdvice(generateTradingAdvice(product.id));
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [product.id, selectedPeriod]);
|
||||
|
||||
const isUp = product.change >= 0;
|
||||
|
||||
const periods = [
|
||||
{ key: 'm5', label: '5分钟' },
|
||||
{ key: 'm15', label: '15分钟' },
|
||||
{ key: 'm30', label: '30分钟' },
|
||||
{ key: 'm60', label: '60分钟' },
|
||||
];
|
||||
|
||||
const cycleLabels: Record<string, string> = {
|
||||
m5: '5分钟',
|
||||
m15: '15分钟',
|
||||
m30: '30分钟',
|
||||
m60: '60分钟',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 返回按钮和标题 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-[#27272a] bg-[#0d0d10] text-[#a1a1aa] hover:text-white hover:border-[#3f3f46] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{product.name} ({product.code})</h2>
|
||||
<p className="text-sm text-[#71717a]">详细技术分析</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 价格信息栏 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold font-mono ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
|
||||
¥{product.price.toLocaleString()}
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-sm ${isUp ? 'text-[#7dd75a]' : 'text-[#ef4444]'}`}>
|
||||
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-12 w-px bg-[#27272a] hidden sm:block" />
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-[#71717a]">开盘</div>
|
||||
<div className="text-sm font-mono text-white">{product.open.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[#71717a]">最高</div>
|
||||
<div className="text-sm font-mono text-[#7dd75a]">{product.high.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[#71717a]">最低</div>
|
||||
<div className="text-sm font-mono text-[#ef4444]">{product.low.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[#71717a]">持仓量</div>
|
||||
<div className="text-sm font-mono text-white">{product.openInterest.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* K线图区域 */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* 周期切换 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[#7dd75a]" />
|
||||
<span className="text-sm text-[#a1a1aa]">周期选择</span>
|
||||
<div className="flex gap-2 ml-2">
|
||||
{periods.map((p) => (
|
||||
<PeriodButton
|
||||
key={p.key}
|
||||
label={p.label}
|
||||
isActive={selectedPeriod === p.key}
|
||||
onClick={() => setSelectedPeriod(p.key as any)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 图表 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
{loading ? (
|
||||
<div className="h-[500px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-[#7dd75a]">
|
||||
<div className="w-5 h-5 border-2 border-[#7dd75a] border-t-transparent rounded-full animate-spin" />
|
||||
<span>加载图表数据...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : cycleData ? (
|
||||
<KLineChart
|
||||
klineData={cycleData.klineData}
|
||||
macdData={cycleData.macdData}
|
||||
resistance={cycleData.keyLevels.resistance}
|
||||
support={cycleData.keyLevels.support}
|
||||
height={500}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分析面板 */}
|
||||
<div className="space-y-4">
|
||||
{/* 交易建议 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
advice?.action === 'long' ? 'bg-[#7dd75a]/10' :
|
||||
advice?.action === 'short' ? 'bg-[#ef4444]/10' : 'bg-[#a1a1aa]/10'
|
||||
}`}>
|
||||
<Target className={`w-4 h-4 ${
|
||||
advice?.action === 'long' ? 'text-[#7dd75a]' :
|
||||
advice?.action === 'short' ? 'text-[#ef4444]' : 'text-[#a1a1aa]'
|
||||
}`} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">交易建议</h3>
|
||||
</div>
|
||||
|
||||
{advice && (
|
||||
<div className="space-y-3">
|
||||
<div className={`p-3 rounded-lg ${
|
||||
advice.action === 'long' ? 'bg-[#7dd75a]/10 border border-[#7dd75a]/30' :
|
||||
advice.action === 'short' ? 'bg-[#ef4444]/10 border border-[#ef4444]/30' :
|
||||
'bg-[#a1a1aa]/10 border border-[#a1a1aa]/30'
|
||||
}`}>
|
||||
<div className="text-xs text-[#71717a] mb-1">操作建议</div>
|
||||
<div className={`text-lg font-bold ${
|
||||
advice.action === 'long' ? 'text-[#7dd75a]' :
|
||||
advice.action === 'short' ? 'text-[#ef4444]' : 'text-[#a1a1aa]'
|
||||
}`}>
|
||||
{advice.action === 'long' ? '逢低做多' : advice.action === 'short' ? '逢高做空' : '观望等待'}
|
||||
</div>
|
||||
<div className="text-xs text-[#a1a1aa] mt-1">{advice.reason}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 rounded-lg bg-[#09090b]">
|
||||
<div className="text-xs text-[#71717a]">建议入场</div>
|
||||
<div className="text-sm font-mono text-white">{advice.entryPrice.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-[#09090b]">
|
||||
<div className="text-xs text-[#71717a]">目标价位</div>
|
||||
<div className="text-sm font-mono text-[#7dd75a]">{advice.targetPrice.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-[#09090b]">
|
||||
<div className="text-xs text-[#71717a]">止损价位</div>
|
||||
<div className="text-sm font-mono text-[#ef4444]">{advice.stopLoss.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-[#09090b]">
|
||||
<div className="text-xs text-[#71717a]">风险等级</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
advice.riskLevel === 'low' ? 'text-[#7dd75a]' :
|
||||
advice.riskLevel === 'medium' ? 'text-[#f59e0b]' : 'text-[#ef4444]'
|
||||
}`}>
|
||||
{advice.riskLevel === 'low' ? '低' : advice.riskLevel === 'medium' ? '中' : '高'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 技术指标 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-[#60a5fa]/10">
|
||||
<Activity className="w-4 h-4 text-[#60a5fa]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">技术指标</h3>
|
||||
</div>
|
||||
|
||||
{indicators && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<IndicatorCard
|
||||
title="MACD"
|
||||
value={indicators.macd.signal === 'golden_cross' ? '金叉' : indicators.macd.signal === 'dead_cross' ? '死叉' : '中性'}
|
||||
status={`DIF: ${indicators.macd.value}`}
|
||||
signal={indicators.macd.signal === 'golden_cross' ? 'positive' : indicators.macd.signal === 'dead_cross' ? 'negative' : 'neutral'}
|
||||
/>
|
||||
<IndicatorCard
|
||||
title="RSI"
|
||||
value={`${indicators.rsi.value}`}
|
||||
status={indicators.rsi.status === 'overbought' ? '超买' : indicators.rsi.status === 'oversold' ? '超卖' : '正常'}
|
||||
signal={indicators.rsi.status === 'oversold' ? 'positive' : indicators.rsi.status === 'overbought' ? 'negative' : 'neutral'}
|
||||
/>
|
||||
<IndicatorCard
|
||||
title="布林带"
|
||||
value={indicators.bollinger.position === 'upper' ? '上轨' : indicators.bollinger.position === 'lower' ? '下轨' : '中轨'}
|
||||
status={`区间: ${indicators.bollinger.lower.toFixed(0)}-${indicators.bollinger.upper.toFixed(0)}`}
|
||||
signal={indicators.bollinger.position === 'lower' ? 'positive' : indicators.bollinger.position === 'upper' ? 'negative' : 'neutral'}
|
||||
/>
|
||||
<IndicatorCard
|
||||
title="KDJ"
|
||||
value={indicators.kdj.signal === 'golden_cross' ? '金叉' : indicators.kdj.signal === 'dead_cross' ? '死叉' : '中性'}
|
||||
status={`K: ${indicators.kdj.k} D: ${indicators.kdj.d}`}
|
||||
signal={indicators.kdj.signal === 'golden_cross' ? 'positive' : indicators.kdj.signal === 'dead_cross' ? 'negative' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关键点位 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-[#f59e0b]/10">
|
||||
<BarChart3 className="w-4 h-4 text-[#f59e0b]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">关键点位</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-[#ef4444] font-medium mb-2">压力位</div>
|
||||
{product.keyLevels.resistance.map((level, index) => (
|
||||
<KeyLevelRow key={index} label={`压力 ${index + 1}`} value={level} type="resistance" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<div className="text-xs text-[#7dd75a] font-medium mb-2">支撑位</div>
|
||||
{product.keyLevels.support.map((level, index) => (
|
||||
<KeyLevelRow key={index} label={`支撑 ${index + 1}`} value={level} type="support" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 多周期一致性 */}
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-[#c084fc]/10">
|
||||
<Zap className="w-4 h-4 text-[#c084fc]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">多周期一致性</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.entries(product.cycles).map(([key, trend]) => (
|
||||
<div key={key} className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm text-[#a1a1aa]">{cycleLabels[key]}</span>
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
|
||||
trend === 'up' ? 'bg-[#7dd75a]/10 text-[#7dd75a]' :
|
||||
trend === 'down' ? 'bg-[#ef4444]/10 text-[#ef4444]' :
|
||||
'bg-[#a1a1aa]/10 text-[#a1a1aa]'
|
||||
}`}>
|
||||
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : trend === 'down' ? <TrendingDown className="w-3 h-3" /> : <div className="w-3 h-0.5 bg-current" />}
|
||||
<span>{trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-[#27272a]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[#a1a1aa]">周期共振度</span>
|
||||
<span className={`text-sm font-bold ${product.trendScore >= 80 ? 'text-[#7dd75a]' : product.trendScore >= 60 ? 'text-[#f59e0b]' : 'text-[#ef4444]'}`}>
|
||||
{product.trendScore}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 bg-[#27272a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
product.trendScore >= 80 ? 'bg-[#7dd75a]' : product.trendScore >= 60 ? 'bg-[#f59e0b]' : 'bg-[#ef4444]'
|
||||
}`}
|
||||
style={{ width: `${product.trendScore}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import { AlertTriangle, Bell, Shield, TrendingDown, AlertOctagon } from 'lucide-react';
|
||||
import type { RiskAlert } from '@/types';
|
||||
|
||||
interface RiskAlertsProps {
|
||||
alerts: RiskAlert[];
|
||||
}
|
||||
|
||||
// 风险等级徽章
|
||||
function RiskLevelBadge({ level }: { level: 'high' | 'medium' | 'low' }) {
|
||||
const config = {
|
||||
high: { text: '高风险', color: 'text-[#ef4444]', bg: 'bg-[#ef4444]/10', icon: AlertOctagon },
|
||||
medium: { text: '中风险', color: 'text-[#f59e0b]', bg: 'bg-[#f59e0b]/10', icon: AlertTriangle },
|
||||
low: { text: '低风险', color: 'text-[#7dd75a]', bg: 'bg-[#7dd75a]/10', icon: Shield },
|
||||
};
|
||||
|
||||
const { text, color, bg, icon: Icon } = config[level];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
<span className={`text-xs font-medium ${color}`}>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskAlertsPanel({ alerts }: RiskAlertsProps) {
|
||||
if (alerts.length === 0) {
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-[#7dd75a]/10">
|
||||
<Shield className="w-4 h-4 text-[#7dd75a]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">风险提醒</h3>
|
||||
</div>
|
||||
<div className="text-center py-6 text-[#71717a]">
|
||||
<Shield className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">当前无重大风险事件</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-[#27272a] bg-[#0d0d10]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 rounded-lg bg-[#ef4444]/10">
|
||||
<Bell className="w-4 h-4 text-[#ef4444]" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">风险提醒</h3>
|
||||
</div>
|
||||
<span className="px-2 py-1 rounded-full bg-[#ef4444]/10 text-[#ef4444] text-xs font-medium">
|
||||
{alerts.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="p-3 rounded-lg border border-[#27272a] bg-[#09090b] hover:border-[#ef4444]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-white mb-1">{alert.title}</h4>
|
||||
<p className="text-xs text-[#a1a1aa] line-clamp-2">{alert.description}</p>
|
||||
</div>
|
||||
<RiskLevelBadge level={alert.riskLevel} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#71717a]">影响品种:</span>
|
||||
<div className="flex gap-1">
|
||||
{alert.affectedProducts.slice(0, 2).map((product) => (
|
||||
<span key={product} className="px-1.5 py-0.5 rounded bg-[#27272a] text-[#e6e6e6]">
|
||||
{product}
|
||||
</span>
|
||||
))}
|
||||
{alert.affectedProducts.length > 2 && (
|
||||
<span className="text-[#71717a]">+{alert.affectedProducts.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[#71717a]">{alert.time}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-[#27272a]/50">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<TrendingDown className="w-3 h-3 text-[#f59e0b]" />
|
||||
<span className="text-[#f59e0b]">建议: {alert.suggestion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue