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