feat: 本次提交更新内容如下
定版本转移2025年7月17日
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["warn"],
|
||||
"react/react-in-jsx-scope": "off"
|
||||
}
|
||||
}
|
||||
26
Cunkebao/.gitignore
vendored
26
Cunkebao/.gitignore
vendored
@@ -2,26 +2,22 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# debug
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1 +0,0 @@
|
||||
# 请将伪静态规则或自定义Apache配置填写到此处
|
||||
@@ -1 +0,0 @@
|
||||
nKNDFrnOs9-cf7FZzW7Cg4MpIxhtE8pZ1etiT_7NPWA.2wKeISBpPoGcCt72TdgVrBMxtnnieXof2OZkRdHKCJI
|
||||
@@ -1,73 +1,123 @@
|
||||
# 存客宝 - 智能获客管理平台
|
||||
# 内客宝 - 智能获客管理平台
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
存客宝是一个专业的微信获客和流量管理平台,提供智能化的客户获取、管理和运营解决方案。平台集成了多种自动化工具,帮助企业高效管理微信营销活动。
|
||||
内客宝是一个专业的微信获客和流量管理平台,基于 React 技术栈构建。平台提供智能化的客户获取、管理和运营解决方案,集成了多种自动化工具,帮助企业高效管理微信营销活动。
|
||||
|
||||
## 🚀 技术栈
|
||||
## 🚀 技术栈详解
|
||||
|
||||
### 前端框架
|
||||
- **Next.js 15.3.5** - React 全栈框架,支持 SSR/SSG
|
||||
- **React 19.1.0** - 用户界面库
|
||||
- **TypeScript 5** - 类型安全的 JavaScript 超集
|
||||
### 核心框架
|
||||
- **React 18.2.0** - 现代化的用户界面库
|
||||
- **TypeScript 4.9.5** - 类型安全的 JavaScript 超集
|
||||
- **Create React App (CRA) 5.0.1** - React 应用脚手架
|
||||
- **React Router DOM 6.20.0** - 客户端路由管理
|
||||
|
||||
### 构建工具
|
||||
- **CRACO 7.1.0** - Create React App Configuration Override
|
||||
- 支持自定义 webpack 配置
|
||||
- 路径别名配置
|
||||
- 构建优化
|
||||
|
||||
### UI 组件库
|
||||
- **shadcn/ui** - 基于 Radix UI 的现代化组件库
|
||||
- **Radix UI** - 无样式的可访问组件库
|
||||
- 完整的组件生态系统(30+ 组件)
|
||||
- 优秀的无障碍访问支持
|
||||
- 高度可定制
|
||||
- **Tailwind CSS 3.4.17** - 实用优先的 CSS 框架
|
||||
- **Lucide React** - 精美的图标库
|
||||
- **Framer Motion** - 动画库
|
||||
- 响应式设计支持
|
||||
- 自定义主题配置
|
||||
- 原子化 CSS 类
|
||||
|
||||
### 状态管理与表单
|
||||
- **React Hook Form** - 高性能表单库
|
||||
- **Zod** - TypeScript 优先的模式验证
|
||||
- **@hookform/resolvers** - 表单验证解析器
|
||||
### 图标和样式
|
||||
- **Lucide React 0.454.0** - 精美的图标库
|
||||
- **Tailwind CSS Animate** - CSS 动画库
|
||||
- **Class Variance Authority** - 组件变体管理
|
||||
- **Tailwind Merge** - Tailwind 类名合并工具
|
||||
|
||||
### 状态管理和表单
|
||||
- **React Hook Form 7.54.1** - 高性能表单库
|
||||
- **Zod 3.24.1** - TypeScript 优先的模式验证
|
||||
- **@hookform/resolvers 3.9.1** - 表单验证解析器
|
||||
|
||||
### 数据可视化
|
||||
- **Recharts** - 基于 React 的图表库
|
||||
- **Chart.js** - 灵活的图表库
|
||||
- **Chart.js 4.5.0** - 灵活的图表库
|
||||
- **@ant-design/plots** - Ant Design 图表组件
|
||||
|
||||
### HTTP 请求和数据处理
|
||||
- **Axios 1.6.0** - HTTP 客户端
|
||||
- **Crypto-js 4.2.0** - 加密库
|
||||
- **Date-fns** - 日期处理库
|
||||
- **XLSX 0.18.5** - Excel 文件处理
|
||||
|
||||
### 通知和反馈
|
||||
- **React Hot Toast 2.5.2** - 轻量级通知库
|
||||
- **Sonner 1.7.4** - 现代化 Toast 组件
|
||||
|
||||
### 高级组件
|
||||
- **@tanstack/react-table** - 功能强大的表格组件
|
||||
- **Embla Carousel React 8.5.1** - 轮播组件
|
||||
- **React Resizable Panels 2.1.7** - 可调整大小的面板
|
||||
- **Vaul 0.9.6** - 抽屉组件
|
||||
- **Input OTP 1.4.1** - OTP 输入组件
|
||||
- **React Day Picker** - 日期选择器
|
||||
|
||||
### 开发工具
|
||||
- **PostCSS 8** - CSS 后处理器
|
||||
- **Autoprefixer 10.4.20** - CSS 前缀自动添加
|
||||
- **ESLint** - 代码质量检查
|
||||
- **PostCSS** - CSS 后处理器
|
||||
- **Autoprefixer** - CSS 前缀自动添加
|
||||
- **Jest** - 单元测试框架
|
||||
- **Testing Library** - React 测试工具
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
Cunkebao/
|
||||
├── app/ # Next.js App Router 页面
|
||||
│ ├── api/ # API 路由
|
||||
│ ├── components/ # 页面级组件
|
||||
│ ├── workspace/ # 工作台模块
|
||||
│ ├── scenarios/ # 场景管理
|
||||
│ ├── devices/ # 设备管理
|
||||
│ ├── content/ # 内容管理
|
||||
│ └── ...
|
||||
├── components/ # 全局组件
|
||||
│ ├── ui/ # shadcn/ui 组件
|
||||
│ └── ...
|
||||
├── lib/ # 工具库
|
||||
│ ├── api/ # API 封装
|
||||
│ └── utils.ts # 通用工具函数
|
||||
├── hooks/ # 自定义 Hooks
|
||||
├── types/ # TypeScript 类型定义
|
||||
└── public/ # 静态资源
|
||||
nkebao/
|
||||
├── public/ # 静态资源
|
||||
├── src/ # 源代码
|
||||
│ ├── api/ # API 接口封装
|
||||
│ ├── components/ # 全局组件
|
||||
│ │ ├── ui/ # UI 基础组件
|
||||
│ │ └── icons/ # 图标组件
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── contexts/ # React Context
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── workspace/ # 工作台模块
|
||||
│ │ │ ├── auto-like/ # 自动点赞
|
||||
│ │ │ ├── auto-group/ # 自动建群
|
||||
│ │ │ ├── group-push/ # 群消息推送
|
||||
│ │ │ ├── moments-sync/ # 朋友圈同步
|
||||
│ │ │ ├── ai-assistant/ # AI 对话助手
|
||||
│ │ │ └── traffic-distribution/ # 流量分发
|
||||
│ │ ├── devices/ # 设备管理
|
||||
│ │ ├── scenarios/ # 场景管理
|
||||
│ │ ├── content/ # 内容管理
|
||||
│ │ └── ...
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── App.tsx # 应用根组件
|
||||
│ └── index.tsx # 应用入口
|
||||
├── craco.config.js # CRACO 配置
|
||||
├── tailwind.config.js # Tailwind CSS 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 项目依赖
|
||||
```
|
||||
|
||||
## 🎯 核心功能模块
|
||||
|
||||
### 工作台 (Workspace)
|
||||
- **自动点赞** - 智能点赞管理
|
||||
- **朋友圈同步** - 内容同步工具
|
||||
- **群消息推送** - 群组消息管理
|
||||
- **AI 对话助手** - 智能客服系统
|
||||
- **自动建群** - 群组自动化管理
|
||||
- **流量分发** - 流量分配策略
|
||||
- **自动点赞** - 智能点赞管理和配置
|
||||
- **自动建群** - 群组自动化创建和管理
|
||||
- **群消息推送** - 群组消息批量发送
|
||||
- **朋友圈同步** - 内容同步和发布
|
||||
- **AI 对话助手** - 智能客服和对话管理
|
||||
- **流量分发** - 流量分配和策略管理
|
||||
|
||||
### 设备管理 (Devices)
|
||||
- 设备状态监控
|
||||
- 设备配置管理
|
||||
- 设备状态监控和配置
|
||||
- 设备性能分析
|
||||
- 设备权限管理
|
||||
|
||||
### 场景管理 (Scenarios)
|
||||
- 营销场景配置
|
||||
@@ -79,61 +129,78 @@ Cunkebao/
|
||||
- 内容模板管理
|
||||
- 内容发布调度
|
||||
|
||||
### 用户管理 (Users)
|
||||
- 用户权限管理
|
||||
- 角色分配
|
||||
- 用户行为分析
|
||||
### 其他模块
|
||||
- 用户管理 (Users)
|
||||
- 订单管理 (Orders)
|
||||
- 流量池管理 (Traffic Pool)
|
||||
- 联系人导入 (Contact Import)
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- npm 或 pnpm
|
||||
- **Node.js** 16+
|
||||
- **npm** 或 **yarn**
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm install
|
||||
# 或
|
||||
pnpm install
|
||||
|
||||
# 使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 开发环境启动
|
||||
```bash
|
||||
npm run dev
|
||||
# 或
|
||||
pnpm dev
|
||||
# 使用 npm
|
||||
npm start
|
||||
|
||||
# 使用 yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# 使用 yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
### 运行测试
|
||||
```bash
|
||||
npm run lint
|
||||
# 使用 npm
|
||||
npm test
|
||||
|
||||
# 使用 yarn
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量
|
||||
创建 `.env.local` 文件:
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=your_api_base_url
|
||||
CUSTOM_KEY=your_custom_key
|
||||
### 路径别名配置
|
||||
项目使用 CRACO 配置了路径别名:
|
||||
```javascript
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@/components': path.resolve(__dirname, 'src/components'),
|
||||
'@/api': path.resolve(__dirname, 'src/api'),
|
||||
'@/types': path.resolve(__dirname, 'src/types'),
|
||||
'@/hooks': path.resolve(__dirname, 'src/hooks'),
|
||||
'@/utils': path.resolve(__dirname, 'src/utils'),
|
||||
'@/styles': path.resolve(__dirname, 'src/styles'),
|
||||
'@/pages': path.resolve(__dirname, 'src/pages'),
|
||||
```
|
||||
|
||||
### Next.js 配置
|
||||
- 支持图片优化和多种格式
|
||||
- 配置了 API 代理和 CORS
|
||||
- 启用了 SWC 压缩
|
||||
- 支持包分析工具
|
||||
|
||||
### Tailwind CSS 配置
|
||||
- 支持暗色模式
|
||||
- 自定义颜色系统
|
||||
- 响应式设计支持
|
||||
- 自定义字体大小和间距
|
||||
- 响应式断点配置
|
||||
- 主题颜色系统
|
||||
|
||||
### TypeScript 配置
|
||||
- 严格模式启用
|
||||
- 路径映射配置
|
||||
- JSX 支持
|
||||
|
||||
## 📱 响应式设计
|
||||
|
||||
@@ -149,9 +216,9 @@ CUSTOM_KEY=your_custom_key
|
||||
- 一致的用户体验
|
||||
- 无障碍访问支持
|
||||
|
||||
### 组件库
|
||||
- 50+ 个预构建组件
|
||||
- 完整的表单组件
|
||||
### 组件库特点
|
||||
- 基于 Radix UI 的高质量组件
|
||||
- 完整的表单组件系统
|
||||
- 数据展示组件
|
||||
- 导航和布局组件
|
||||
|
||||
@@ -160,21 +227,27 @@ CUSTOM_KEY=your_custom_key
|
||||
- 身份验证和授权
|
||||
- API 请求拦截
|
||||
- 数据验证和清理
|
||||
- CORS 配置
|
||||
- 加密功能支持
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
- 代码分割和懒加载
|
||||
- 图片优化
|
||||
- 组件优化
|
||||
- 缓存策略
|
||||
- 包大小优化
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
- 单元测试 (Jest + Testing Library)
|
||||
- 组件测试
|
||||
- 集成测试支持
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支
|
||||
3. 提交更改
|
||||
4. 推送到分支
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
@@ -187,5 +260,7 @@ CUSTOM_KEY=your_custom_key
|
||||
|
||||
---
|
||||
|
||||
**项目名称**: 内客宝 (nkebao2)
|
||||
**版本**: 0.1.0
|
||||
**技术栈**: React + TypeScript + CRA + Tailwind CSS
|
||||
**最后更新**: 2024年12月
|
||||
@@ -1,192 +0,0 @@
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
ApiResponse,
|
||||
Device,
|
||||
DeviceStats,
|
||||
DeviceTaskRecord,
|
||||
PaginatedResponse,
|
||||
QueryDeviceParams,
|
||||
CreateDeviceParams,
|
||||
UpdateDeviceParams,
|
||||
DeviceStatus,
|
||||
ServerDevice,
|
||||
ServerDevicesResponse
|
||||
} from "@/types/device"
|
||||
|
||||
const API_BASE = "/api/devices"
|
||||
|
||||
// 获取设备列表 - 连接到服务器/v1/devices接口
|
||||
export const fetchDeviceList = async (page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', page.toString());
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword);
|
||||
}
|
||||
|
||||
return api.get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
|
||||
};
|
||||
|
||||
// 获取设备详情 - 连接到服务器/v1/devices/:id接口
|
||||
export const fetchDeviceDetail = async (id: string | number): Promise<ApiResponse<any>> => {
|
||||
return api.get<ApiResponse<any>>(`/v1/devices/${id}`);
|
||||
};
|
||||
|
||||
// 获取设备关联的微信账号
|
||||
export const fetchDeviceRelatedAccounts = async (id: string | number): Promise<ApiResponse<any>> => {
|
||||
return api.get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
|
||||
};
|
||||
|
||||
// 获取设备操作记录
|
||||
export const fetchDeviceHandleLogs = async (id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> => {
|
||||
return api.get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
|
||||
};
|
||||
|
||||
// 更新设备任务配置
|
||||
export const updateDeviceTaskConfig = async (
|
||||
config: {
|
||||
deviceId: string | number;
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
momentsSync?: boolean;
|
||||
aiChat?: boolean;
|
||||
}
|
||||
): Promise<ApiResponse<any>> => {
|
||||
return api.post<ApiResponse<any>>(`/v1/devices/task-config`, {
|
||||
...config
|
||||
});
|
||||
};
|
||||
|
||||
// 删除设备
|
||||
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
|
||||
return api.delete<ApiResponse<any>>(`/v1/devices/${id}`);
|
||||
};
|
||||
|
||||
// 设备管理API
|
||||
export const deviceApi = {
|
||||
// 创建设备
|
||||
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 更新设备
|
||||
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备详情
|
||||
async getById(id: string): Promise<ApiResponse<Device>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 查询设备列表
|
||||
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
|
||||
// 创建一个新对象,用于构建URLSearchParams
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 按需将params中的属性添加到queryParams
|
||||
if (params.keyword) queryParams.keyword = params.keyword;
|
||||
if (params.status) queryParams.status = params.status;
|
||||
if (params.type) queryParams.type = params.type;
|
||||
if (params.page) queryParams.page = params.page.toString();
|
||||
if (params.pageSize) queryParams.pageSize = params.pageSize.toString();
|
||||
|
||||
// 特殊处理需要JSON序列化的属性
|
||||
if (params.tags) queryParams.tags = JSON.stringify(params.tags);
|
||||
if (params.dateRange) queryParams.dateRange = JSON.stringify(params.dateRange);
|
||||
|
||||
// 构建查询字符串
|
||||
const queryString = new URLSearchParams(queryParams).toString();
|
||||
const response = await fetch(`${API_BASE}?${queryString}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 删除设备
|
||||
async delete(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 重启设备
|
||||
async restart(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/restart`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 解绑设备
|
||||
async unbind(id: string): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/unbind`, {
|
||||
method: "POST",
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备统计数据
|
||||
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/stats`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 获取设备任务记录
|
||||
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
|
||||
const response = await fetch(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 批量更新设备标签
|
||||
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
|
||||
const response = await fetch(`${API_BASE}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids, tags }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 批量导出设备数据
|
||||
async exportDevices(ids: string[]): Promise<Blob> {
|
||||
const response = await fetch(`${API_BASE}/export`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids }),
|
||||
})
|
||||
return response.blob()
|
||||
},
|
||||
|
||||
// 检查设备在线状态
|
||||
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
|
||||
const response = await fetch(`${API_BASE}/status`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ deviceIds: ids }),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
|
||||
|
||||
// 获客场景路由处理
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: CreateScenarioParams = await request.json()
|
||||
|
||||
// TODO: 实现创建场景的具体逻辑
|
||||
const scenario: ScenarioBase = {
|
||||
id: "generated-id",
|
||||
...body,
|
||||
status: "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
creator: "current-user-id",
|
||||
}
|
||||
|
||||
const response: ApiResponse<ScenarioBase> = {
|
||||
code: 0,
|
||||
message: "创建成功",
|
||||
data: scenario,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "创建失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params: QueryScenarioParams = {
|
||||
type: searchParams.get("type") as any,
|
||||
status: searchParams.get("status") as any,
|
||||
keyword: searchParams.get("keyword") || undefined,
|
||||
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
|
||||
page: Number(searchParams.get("page")) || 1,
|
||||
pageSize: Number(searchParams.get("pageSize")) || 20,
|
||||
}
|
||||
|
||||
// TODO: 实现查询场景列表的具体逻辑
|
||||
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
message: "查询成功",
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "查询失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { request } from "@/lib/api"
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
// 服务器返回的场景数据类型
|
||||
export interface SceneItem {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
status: number;
|
||||
createTime: number;
|
||||
updateTime: number | null;
|
||||
deleteTime: number | null;
|
||||
}
|
||||
|
||||
// 服务器返回的场景列表响应类型
|
||||
export interface ScenesResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: SceneItem[];
|
||||
}
|
||||
|
||||
// 前端使用的场景数据类型
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
stats: {
|
||||
daily: number;
|
||||
growth: number;
|
||||
};
|
||||
link?: string;
|
||||
plans?: Plan[];
|
||||
}
|
||||
|
||||
// 计划类型
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
isNew?: boolean;
|
||||
status: "active" | "paused" | "completed";
|
||||
acquisitionCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取获客场景列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 获客场景列表
|
||||
*/
|
||||
export const fetchScenes = async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ScenesResponse> => {
|
||||
const { page = 1, limit = 10, keyword = "" } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("page", String(page));
|
||||
queryParams.append("limit", String(limit));
|
||||
|
||||
if (keyword) {
|
||||
queryParams.append("keyword", keyword);
|
||||
}
|
||||
|
||||
try {
|
||||
return await request<ScenesResponse>(`/v1/plan/scenes?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scenes:", error);
|
||||
// 返回一个错误响应
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景列表失败",
|
||||
data: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器返回的场景数据转换为前端展示需要的格式
|
||||
*
|
||||
* @param item 服务器返回的场景数据
|
||||
* @returns 前端展示的场景数据
|
||||
*/
|
||||
export const transformSceneItem = (item: SceneItem): Channel => {
|
||||
// 为每个场景生成随机的"今日"数据和"增长百分比"
|
||||
const dailyCount = Math.floor(Math.random() * 100);
|
||||
const growthPercent = Math.floor(Math.random() * 40) - 10; // -10% 到 30% 的随机值
|
||||
|
||||
// 默认图标(如果服务器没有返回)
|
||||
const defaultIcon = "/assets/icons/poster-icon.svg";
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
icon: item.image || defaultIcon,
|
||||
stats: {
|
||||
daily: dailyCount,
|
||||
growth: growthPercent
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取场景详情
|
||||
*
|
||||
* @param id 场景ID
|
||||
* @returns 场景详情
|
||||
*/
|
||||
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
|
||||
try {
|
||||
return await request<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scene detail:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景详情失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
ServerWechatAccountsResponse,
|
||||
QueryWechatAccountParams,
|
||||
} from "@/types/wechat-account";
|
||||
|
||||
// 添加接口返回数据类型定义
|
||||
interface WechatAccountSummary {
|
||||
accountAge: string;
|
||||
activityLevel: {
|
||||
allTimes: number;
|
||||
dayTimes: number;
|
||||
};
|
||||
accountWeight: {
|
||||
scope: number;
|
||||
ageWeight: number;
|
||||
activityWeigth: number;
|
||||
restrictWeight: number;
|
||||
realNameWeight: number;
|
||||
};
|
||||
statistics: {
|
||||
todayAdded: number;
|
||||
addLimit: number;
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface WechatAccountSummaryResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: WechatAccountSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信账号列表
|
||||
* @param params 查询参数
|
||||
* @returns 微信账号列表响应
|
||||
*/
|
||||
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ServerWechatAccountsResponse> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
// 添加查询参数
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.keyword) queryParams.append('nickname', params.keyword); // 使用nickname作为关键词搜索参数
|
||||
if (params.sort) queryParams.append('sort', params.sort);
|
||||
if (params.order) queryParams.append('order', params.order);
|
||||
|
||||
// 发起API请求
|
||||
return api.get<ServerWechatAccountsResponse>(`/v1/wechats?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新微信账号状态
|
||||
* @returns 刷新结果
|
||||
*/
|
||||
export const refreshWechatAccounts = async (): Promise<{ code: number; msg: string; data: any }> => {
|
||||
return api.put<{ code: number; msg: string; data: any }>('/v1/wechats/refresh', {});
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行微信好友转移
|
||||
* @param sourceId 源微信账号ID
|
||||
* @param targetId 目标微信账号ID
|
||||
* @returns 转移结果
|
||||
*/
|
||||
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<{ code: number; msg: string; data: any }> => {
|
||||
return api.post<{ code: number; msg: string; data: any }>('/v1/wechats/transfer-friends', {
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器返回的微信账号数据转换为前端使用的格式
|
||||
* @param serverAccount 服务器返回的微信账号数据
|
||||
* @returns 前端使用的微信账号数据
|
||||
*/
|
||||
export const transformWechatAccount = (serverAccount: any): import("@/types/wechat-account").WechatAccount => {
|
||||
// 从deviceInfo中提取设备信息
|
||||
let deviceId = '';
|
||||
let deviceName = '';
|
||||
|
||||
if (serverAccount.deviceInfo) {
|
||||
// 尝试解析设备信息字符串
|
||||
const deviceInfo = serverAccount.deviceInfo.split(' ');
|
||||
if (deviceInfo.length > 0) {
|
||||
// 提取数字部分作为设备ID,确保是整数
|
||||
const possibleId = deviceInfo[0].trim();
|
||||
// 验证是否为数字
|
||||
deviceId = /^\d+$/.test(possibleId) ? possibleId : '';
|
||||
|
||||
// 提取设备名称
|
||||
if (deviceInfo.length > 1) {
|
||||
deviceName = deviceInfo[1] ? deviceInfo[1].replace(/[()]/g, '').trim() : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果从deviceInfo无法获取有效的设备ID,使用imei作为备选
|
||||
if (!deviceId && serverAccount.imei) {
|
||||
deviceId = serverAccount.imei;
|
||||
}
|
||||
|
||||
// 如果仍然没有设备ID,使用微信账号的ID作为最后的备选
|
||||
if (!deviceId && serverAccount.id) {
|
||||
deviceId = serverAccount.id.toString();
|
||||
}
|
||||
|
||||
// 如果没有设备名称,使用备用名称
|
||||
if (!deviceName) {
|
||||
deviceName = serverAccount.deviceMemo || '未命名设备';
|
||||
}
|
||||
|
||||
// 假设每天最多可添加20个好友
|
||||
const maxDailyAdds = 20;
|
||||
const todayAdded = serverAccount.todayNewFriendCount || 0;
|
||||
|
||||
return {
|
||||
id: serverAccount.id.toString(),
|
||||
avatar: serverAccount.avatar || '',
|
||||
nickname: serverAccount.nickname || serverAccount.accountNickname || '未命名',
|
||||
wechatId: serverAccount.wechatId || '',
|
||||
deviceId,
|
||||
deviceName,
|
||||
friendCount: serverAccount.totalFriend || 0,
|
||||
todayAdded,
|
||||
remainingAdds: serverAccount.canAddFriendCount || (maxDailyAdds - todayAdded),
|
||||
maxDailyAdds,
|
||||
status: serverAccount.wechatAlive === 1 ? "normal" : "abnormal" as "normal" | "abnormal",
|
||||
lastActive: new Date().toLocaleString() // 服务端未提供,使用当前时间
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信好友列表
|
||||
* @param wechatId 微信账号ID
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param searchQuery 搜索关键词
|
||||
* @returns 好友列表数据
|
||||
*/
|
||||
export const fetchWechatFriends = async (wechatId: string, page: number = 1, pageSize: number = 20, searchQuery: string = '') => {
|
||||
try {
|
||||
return api.get(`/v1/wechats/${wechatId}/friends?page=${page}&limit=${pageSize}${searchQuery ? `&search=${searchQuery}` : ''}`, true);
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信账号概览信息
|
||||
* @param id 微信账号ID
|
||||
* @returns 微信账号概览信息
|
||||
*/
|
||||
export const fetchWechatAccountSummary = async (wechatIdid: string): Promise<WechatAccountSummaryResponse> => {
|
||||
try {
|
||||
return api.get<WechatAccountSummaryResponse>(`/v1/wechats/${wechatIdid}/summary`);
|
||||
} catch (error) {
|
||||
console.error("获取账号概览失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取好友详情信息
|
||||
* @param wechatId 微信账号ID
|
||||
* @param friendId 好友ID
|
||||
* @returns 好友详情信息
|
||||
*/
|
||||
export interface WechatFriendDetail {
|
||||
id: number;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
region: string;
|
||||
wechatId: string;
|
||||
addDate: string;
|
||||
tags: string[];
|
||||
playDate: string;
|
||||
memo: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface WechatFriendDetailResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: WechatFriendDetail;
|
||||
}
|
||||
|
||||
export const fetchWechatFriendDetail = async (wechatId: string): Promise<WechatFriendDetailResponse> => {
|
||||
try {
|
||||
return api.get<WechatFriendDetailResponse>(`/v1/wechats/${wechatId}`);
|
||||
} catch (error) {
|
||||
console.error("获取好友详情失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,250 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Pencil, Trash2, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface MobileAccount {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
createdAt: string
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
export default function MobileAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<MobileAccount[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "用户1",
|
||||
phone: "13809076043",
|
||||
createdAt: "2023-01-15",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "用户2",
|
||||
phone: "13819176143",
|
||||
createdAt: "2023-02-15",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "用户3",
|
||||
phone: "13829276243",
|
||||
createdAt: "2023-03-15",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "用户4",
|
||||
phone: "13839376343",
|
||||
createdAt: "2023-04-15",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "用户5",
|
||||
phone: "13849476443",
|
||||
createdAt: "2023-05-15",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [newAccount, setNewAccount] = useState({
|
||||
name: "",
|
||||
password: "",
|
||||
phone: "",
|
||||
role: "",
|
||||
permissions: {
|
||||
notifications: false,
|
||||
dataView: false,
|
||||
remoteControl: false,
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateAccount = () => {
|
||||
// 这里应该有API调用来创建账号
|
||||
const newId = (accounts.length + 1).toString()
|
||||
setAccounts([
|
||||
...accounts,
|
||||
{
|
||||
id: newId,
|
||||
name: newAccount.name,
|
||||
phone: newAccount.phone,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
setIsDialogOpen(false)
|
||||
setNewAccount({
|
||||
name: "",
|
||||
password: "",
|
||||
phone: "",
|
||||
role: "",
|
||||
permissions: {
|
||||
notifications: false,
|
||||
dataView: false,
|
||||
remoteControl: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteAccount = (id: string) => {
|
||||
setAccounts(accounts.filter((account) => account.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold">手机端账号列表</h1>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增手机端账号
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>账号名称</TableHead>
|
||||
<TableHead>手机号码</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>{account.name}</TableCell>
|
||||
<TableCell>{account.phone}</TableCell>
|
||||
<TableCell>{account.createdAt}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={account.status === "active" ? "success" : "secondary"}>
|
||||
{account.status === "active" ? "活跃" : "非活跃"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(account.id)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增手机端账号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">账号名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newAccount.name}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })}
|
||||
placeholder="请输入账号名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">初始密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={newAccount.password}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, password: e.target.value })}
|
||||
placeholder="请输入初始密码"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={newAccount.phone}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, phone: e.target.value })}
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">角色</Label>
|
||||
<Select value={newAccount.role} onValueChange={(value) => setNewAccount({ ...newAccount, role: value })}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue placeholder="选择角色" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="user">普通用<EFBFBD><EFBFBD><EFBFBD></SelectItem>
|
||||
<SelectItem value="guest">访客</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>功能权限</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="notifications"
|
||||
checked={newAccount.permissions.notifications}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewAccount({
|
||||
...newAccount,
|
||||
permissions: { ...newAccount.permissions, notifications: !!checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="notifications">消息通知</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dataView"
|
||||
checked={newAccount.permissions.dataView}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewAccount({
|
||||
...newAccount,
|
||||
permissions: { ...newAccount.permissions, dataView: !!checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="dataView">数据查看</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remoteControl"
|
||||
checked={newAccount.permissions.remoteControl}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewAccount({
|
||||
...newAccount,
|
||||
permissions: { ...newAccount.permissions, remoteControl: !!checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="remoteControl">远程控制</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCreateAccount}>创建账号</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Pencil, Trash2, Plus } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface OperatorAccount {
|
||||
id: string
|
||||
phone: string
|
||||
nickname: string
|
||||
deviceCount: number
|
||||
friendCount: number
|
||||
createdAt: string
|
||||
status: "active" | "inactive"
|
||||
}
|
||||
|
||||
export default function OperatorAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<OperatorAccount[]>([
|
||||
{
|
||||
id: "1",
|
||||
phone: "13809076043",
|
||||
nickname: "操盘手1",
|
||||
deviceCount: 1,
|
||||
friendCount: 25,
|
||||
createdAt: "2023-01-15",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
phone: "13819176143",
|
||||
nickname: "操盘手2",
|
||||
deviceCount: 2,
|
||||
friendCount: 50,
|
||||
createdAt: "2023-02-15",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
phone: "13829276243",
|
||||
nickname: "操盘手3",
|
||||
deviceCount: 3,
|
||||
friendCount: 75,
|
||||
createdAt: "2023-03-15",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
phone: "13839376343",
|
||||
nickname: "操盘手4",
|
||||
deviceCount: 4,
|
||||
friendCount: 100,
|
||||
createdAt: "2023-04-15",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
phone: "13849476443",
|
||||
nickname: "操盘手5",
|
||||
deviceCount: 5,
|
||||
friendCount: 125,
|
||||
createdAt: "2023-05-15",
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [newAccount, setNewAccount] = useState({
|
||||
phone: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const handleCreateAccount = () => {
|
||||
// 这里应该有API调用来创建账号
|
||||
const newId = (accounts.length + 1).toString()
|
||||
setAccounts([
|
||||
...accounts,
|
||||
{
|
||||
id: newId,
|
||||
phone: newAccount.phone,
|
||||
nickname: newAccount.nickname,
|
||||
deviceCount: 0,
|
||||
friendCount: 0,
|
||||
createdAt: new Date().toISOString().split("T")[0],
|
||||
status: "active",
|
||||
},
|
||||
])
|
||||
setIsDialogOpen(false)
|
||||
setNewAccount({
|
||||
phone: "",
|
||||
nickname: "",
|
||||
password: "",
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteAccount = (id: string) => {
|
||||
setAccounts(accounts.filter((account) => account.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-semibold">操盘手账号列表</h1>
|
||||
<Button onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新增操盘手账号
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md shadow">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>登录手机号</TableHead>
|
||||
<TableHead>备注 (昵称)</TableHead>
|
||||
<TableHead>关联设备数量</TableHead>
|
||||
<TableHead>微信好友数量</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>{account.phone}</TableCell>
|
||||
<TableCell>{account.nickname}</TableCell>
|
||||
<TableCell>{account.deviceCount}</TableCell>
|
||||
<TableCell>{account.friendCount}</TableCell>
|
||||
<TableCell>{account.createdAt}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={account.status === "active" ? "success" : "secondary"}>
|
||||
{account.status === "active" ? "活跃" : "非活跃"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(account.id)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增操盘手账号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">登录手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={newAccount.phone}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, phone: e.target.value })}
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nickname">备注 (昵称)</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={newAccount.nickname}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, nickname: e.target.value })}
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">初始密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={newAccount.password}
|
||||
onChange={(e) => setNewAccount({ ...newAccount, password: e.target.value })}
|
||||
placeholder="请输入初始密码"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCreateAccount}>创建账号</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ChevronDown, Users, MessageSquare } from "lucide-react"
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({
|
||||
账号管理: true,
|
||||
})
|
||||
|
||||
const toggleExpand = (key: string) => {
|
||||
setExpandedItems((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}))
|
||||
}
|
||||
|
||||
const isActive = (path: string) => pathname === path
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r flex flex-col h-full">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">存客宝管理系统</h2>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
<ul className="space-y-1">
|
||||
{/* 账号管理 */}
|
||||
<li>
|
||||
<button
|
||||
className="flex items-center justify-between w-full p-3 rounded-md hover:bg-gray-100"
|
||||
onClick={() => toggleExpand("账号管理")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-5 w-5 mr-3" />
|
||||
<span>账号管理</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${expandedItems["账号管理"] ? "transform rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedItems["账号管理"] && (
|
||||
<ul className="pl-10 space-y-1 mt-1">
|
||||
<li>
|
||||
<Link
|
||||
href="/admin/accounts/operators"
|
||||
className={`block p-2 rounded-md ${
|
||||
isActive("/admin/accounts/operators") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
操盘手账号管理
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/admin/accounts/mobile"
|
||||
className={`block p-2 rounded-md ${
|
||||
isActive("/admin/accounts/mobile") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
手机端账号管理
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{/* 聚合聊天 */}
|
||||
<li>
|
||||
<Link
|
||||
href="/admin/chat"
|
||||
className={`flex items-center p-3 rounded-md ${
|
||||
isActive("/admin/chat") ? "bg-blue-50 text-blue-600" : "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5 mr-3" />
|
||||
<span>聚合聊天</span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{/* 其他菜单项可以在这里添加 */}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { Bell, User } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import AdminSidebar from "./components/AdminSidebar"
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<header className="bg-white border-b h-16 flex items-center justify-between px-6">
|
||||
<h1 className="text-xl font-semibold">运营端后台</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<User className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const stats = [
|
||||
{ title: "总账号数", value: "42" },
|
||||
{ title: "手机端账号", value: "28" },
|
||||
{ title: "操盘手账号", value: "14" },
|
||||
{ title: "今日活跃", value: "18" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold mb-6">存客宝管理后台</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-500">{stat.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{stat.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>手机端账号管理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500 mb-4">管理存客宝手机端的用户账号,设置权限和功能。</p>
|
||||
<Link href="/admin/accounts/mobile">
|
||||
<Button variant="outline" className="w-full">
|
||||
查看账号
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>操盘手账号管理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500 mb-4">管理操盘手账号,查看关联设备和微信好友数量。</p>
|
||||
<Link href="/admin/accounts/operators">
|
||||
<Button variant="outline" className="w-full">
|
||||
查看账号
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { OrderFormData } from "@/types/acquisition"
|
||||
|
||||
export async function POST(request: Request, { params }: { params: { planId: string } }) {
|
||||
try {
|
||||
const data: OrderFormData = await request.json()
|
||||
|
||||
// 这里应该添加实际的数据库存储逻辑
|
||||
console.log("Received order:", data, "for plan:", params.planId)
|
||||
|
||||
// 模拟成功响应
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "订单已成功提交",
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ success: false, message: "订单提交失败" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// API请求工具函数
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
|
||||
|
||||
// 带有认证的请求函数
|
||||
export async function authFetch(url: string, options: RequestInit = {}) {
|
||||
const token = localStorage.getItem("token")
|
||||
|
||||
// 合并headers
|
||||
let headers = { ...options.headers }
|
||||
|
||||
// 如果有token,添加到请求头
|
||||
if (token) {
|
||||
headers = {
|
||||
...headers,
|
||||
Token: `${token}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 检查token是否过期(仅当有token时)
|
||||
if (token && (data.code === 401 || data.code === 403)) {
|
||||
// 清除token
|
||||
localStorage.removeItem("token")
|
||||
|
||||
// 暂时不重定向到登录页
|
||||
// if (typeof window !== "undefined") {
|
||||
// window.location.href = "/login"
|
||||
// }
|
||||
|
||||
console.warn("登录已过期")
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("API请求错误:", error)
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请求失败",
|
||||
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 不需要认证的请求函数
|
||||
export async function publicFetch(url: string, options: RequestInit = {}) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${url}`, options)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("API请求错误:", error)
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请求失败",
|
||||
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type {
|
||||
CreateDeviceParams,
|
||||
QueryDeviceParams,
|
||||
Device,
|
||||
ApiResponse,
|
||||
DeviceStatus,
|
||||
DeviceType,
|
||||
} from "@/types/device"
|
||||
|
||||
// 设备管理路由处理
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: CreateDeviceParams = await request.json()
|
||||
|
||||
// TODO: 实现创建设备的具体逻辑
|
||||
const device: Device = {
|
||||
id: "generated-id",
|
||||
...body,
|
||||
status: DeviceStatus.OFFLINE,
|
||||
lastOnlineTime: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const response: ApiResponse<Device> = {
|
||||
code: 0,
|
||||
message: "创建成功",
|
||||
data: device,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "创建失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params: QueryDeviceParams = {
|
||||
keyword: searchParams.get("keyword") || undefined,
|
||||
status: (searchParams.get("status") as DeviceStatus) || undefined,
|
||||
type: (searchParams.get("type") as DeviceType) || undefined,
|
||||
tags: searchParams.get("tags") ? JSON.parse(searchParams.get("tags")!) : undefined,
|
||||
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
|
||||
page: Number(searchParams.get("page")) || 1,
|
||||
pageSize: Number(searchParams.get("pageSize")) || 20,
|
||||
}
|
||||
|
||||
// TODO: 实现查询设备列表的具体逻辑
|
||||
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
message: "查询成功",
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "查询失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Copy, Check, Info } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getApiGuideForScenario } from "@/docs/api-guide"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
|
||||
export default function ApiDocPage({ params }: { params: { channel: string; id: string } }) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [copiedExample, setCopiedExample] = useState<string | null>(null)
|
||||
|
||||
const apiGuide = getApiGuideForScenario(params.id, params.channel)
|
||||
|
||||
const copyToClipboard = (text: string, exampleId: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedExample(exampleId)
|
||||
|
||||
toast({
|
||||
title: "已复制代码",
|
||||
description: "代码示例已复制到剪贴板",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedExample(null)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50/30">
|
||||
<header className="sticky top-0 z-10 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{apiGuide.title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">API接口文档</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(`${window.location.origin}/scenarios/${params.channel}`, "_self")}
|
||||
>
|
||||
返回计划列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container mx-auto py-8 px-6 max-w-5xl">
|
||||
{/* API密钥卡片 */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">API密钥</CardTitle>
|
||||
<CardDescription>用于身份验证,请妥善保管,不要在客户端代码中暴露它</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border">
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-sm font-mono text-gray-800">api_1_xqbint74</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard("api_1_xqbint74", "api-key")}>
|
||||
{copiedExample === "api-key" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Info className="h-5 w-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-800 mb-1">安全提示</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
请妥善保管您的API密钥,不要在客户端代码中暴露它。建议在服务器端使用该接口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 接口地址卡片 */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">接口地址</CardTitle>
|
||||
<CardDescription>使用此接口直接导入客户资料到该获客计划,支持多种编程语言</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4 border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">POST 请求地址</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/post",
|
||||
"api-url",
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedExample === "api-url" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-gray-800 break-all">
|
||||
https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/post
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">必要参数</h4>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-200">
|
||||
name (姓名)
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-200">
|
||||
phone (电话)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">可选参数</h4>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="text-gray-600 border-gray-200">
|
||||
source (来源)
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-gray-600 border-gray-200">
|
||||
remark (备注)
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-gray-600 border-gray-200">
|
||||
tags (标签)
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 接口文档卡片 */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">接口文档</CardTitle>
|
||||
<CardDescription>详细的API规范和集成指南</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Button variant="outline" className="flex-1 h-12 bg-transparent">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">查看代码示例</div>
|
||||
<div className="text-xs text-gray-500">多种语言示例</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1 h-12 bg-transparent">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">查看集成指南</div>
|
||||
<div className="text-xs text-gray-500">详细集成步骤</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 快速测试卡片 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">快速测试</CardTitle>
|
||||
<CardDescription>使用以下URL可以快速测试接口是否正常工作</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">测试URL</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
"https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/poster/1/webhook?name=测试客户&phone=13800138000",
|
||||
"test-url",
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedExample === "test-url" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<code className="text-xs font-mono text-gray-800 break-all">
|
||||
https://kzminfd0rplrm7owmj4b.lite.vusercontent.net/api/scenarios/poster/1/webhook?name=测试客户&phone=13800138000
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-sm text-green-700">💡 点击上方链接或复制到浏览器中访问,即可快速测试接口连通性</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 详细文档手风琴 */}
|
||||
<div className="mt-8">
|
||||
<Accordion type="single" collapsible className="space-y-4">
|
||||
{apiGuide.endpoints.map((endpoint, index) => (
|
||||
<AccordionItem key={index} value={`endpoint-${index}`} className="border rounded-lg">
|
||||
<AccordionTrigger className="px-6 py-4 hover:bg-gray-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge className="bg-green-100 text-green-800 border-green-200">{endpoint.method}</Badge>
|
||||
<span className="font-mono text-sm text-gray-700">{endpoint.url}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-gray-700">{endpoint.description}</p>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 text-gray-900">请求头</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
{endpoint.headers.map((header, i) => (
|
||||
<div key={i} className="flex items-start space-x-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{header.required ? "*" : ""}
|
||||
{header.name}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{header.value}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{header.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 text-gray-900">请求参数</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
{endpoint.parameters.map((param, i) => (
|
||||
<div key={i} className="flex items-start space-x-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{param.required ? "*" : ""}
|
||||
{param.name}
|
||||
</Badge>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">
|
||||
<span className="text-gray-500 font-mono text-xs">{param.type}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{param.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 text-gray-900">响应示例</h4>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 text-xs overflow-auto border">
|
||||
{JSON.stringify(endpoint.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* 代码示例 */}
|
||||
<Card className="mt-8" id="examples">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">代码示例</CardTitle>
|
||||
<CardDescription>以下是不同编程语言的接口调用示例</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue={apiGuide.examples[0].language}>
|
||||
<TabsList className="mb-6">
|
||||
{apiGuide.examples.map((example) => (
|
||||
<TabsTrigger key={example.language} value={example.language}>
|
||||
{example.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{apiGuide.examples.map((example) => (
|
||||
<TabsContent key={example.language} value={example.language}>
|
||||
<div className="relative">
|
||||
<pre className="bg-gray-50 p-6 rounded-lg overflow-auto text-sm border">{example.code}</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-3 right-3 bg-transparent"
|
||||
onClick={() => copyToClipboard(example.code, example.language)}
|
||||
>
|
||||
{copiedExample === example.language ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 集成指南 */}
|
||||
<div className="mt-8 space-y-6" id="integration">
|
||||
<h3 className="text-xl font-semibold text-gray-900">集成指南</h3>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">集简云平台集成</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm">
|
||||
<li>登录集简云平台</li>
|
||||
<li>导航至"应用集成" > "外部接口"</li>
|
||||
<li>选择"添加新接口",输入存客宝接口信息</li>
|
||||
<li>配置回调参数,将"X-API-KEY"设置为您的API密钥</li>
|
||||
<li>
|
||||
设置接口URL为:
|
||||
<code className="bg-gray-100 px-2 py-1 rounded ml-2 text-xs">{apiGuide.endpoints[0].url}</code>
|
||||
</li>
|
||||
<li>映射必要字段(name, phone等)</li>
|
||||
<li>保存并启用集成</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">问题排查</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-900">接口认证失败</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
请确保X-API-KEY正确无误,此密钥区分大小写。如需重置密钥,请联系管理员。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-900">数据格式错误</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
确保所有必填字段已提供,并且字段类型正确。特别是电话号码格式需符合标准。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 text-gray-900">请求频率限制</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
单个API密钥每分钟最多可发送30个请求,超过限制将被暂时限制。对于大批量数据,请使用批量接口。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, Copy, Check, Info } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getApiGuideForScenario } from "@/docs/api-guide"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://yishi.com'
|
||||
|
||||
export default function ApiDocPage({ params }: { params: { channel: string; id: string } }) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [copiedExample, setCopiedExample] = useState<string | null>(null)
|
||||
|
||||
const apiGuide = getApiGuideForScenario(params.id, params.channel)
|
||||
|
||||
// 假设 fullUrl 和 apiKey 可通过 props 或接口获取,这里用演示值
|
||||
const [apiKey] = useState("naxf1-82h2f-vdwcm-rrhpm-q9hd1")
|
||||
const [fullUrl] = useState("/v1/api/scenarios")
|
||||
const testUrl = fullUrl.startsWith("http") ? fullUrl : `${API_BASE_URL}${fullUrl}`
|
||||
|
||||
const copyToClipboard = (text: string, exampleId: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedExample(exampleId)
|
||||
|
||||
toast({
|
||||
title: "已复制代码",
|
||||
description: "代码示例已复制到剪贴板",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedExample(null)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between h-14 px-4">
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">计划接口文档</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container mx-auto py-6 px-4 max-w-4xl">
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>接口说明</CardTitle>
|
||||
<CardDescription>本接口用于将外部客户数据导入到存客宝计划。请使用 <b>apiKey</b> 进行身份认证,建议仅在服务端调用。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<p className="text-sm text-gray-700">
|
||||
支持多种编程语言和平台集成。接口地址和参数请参考下方说明。
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 p-4 border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>安全提示:</strong> 请妥善保管您的API密钥,不要在客户端代码中暴露它。建议在服务器端使用该接口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>签名规则</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm text-gray-700">
|
||||
<div>
|
||||
<b>签名参数:</b> <span className="font-mono">sign</span>
|
||||
</div>
|
||||
<div>
|
||||
<b>签名算法:</b> 将所有请求参数(排除 sign 和 apiKey)按参数名升序排序,<b>直接拼接参数值</b>(不含等号和&),对该字符串进行 <span className="font-mono">MD5</span> 加密,得到中间串,再拼接 apiKey,最后再进行一次 <span className="font-mono">MD5</span> 加密,结果作为 sign 参数传递。
|
||||
</div>
|
||||
<div>
|
||||
<b>签名步骤:</b>
|
||||
<ol className="list-decimal list-inside ml-4">
|
||||
<li>去除 sign 和 apiKey 参数,将其余所有参数(如 name、phone、timestamp、source、remark、tags)按参数名升序排序</li>
|
||||
<li><b>直接拼接参数值</b>,如 value1value2value3...</li>
|
||||
<li>对拼接后的字符串进行 MD5 加密,得到中间串</li>
|
||||
<li>将中间串与 apiKey 直接拼接</li>
|
||||
<li>对拼接后的字符串再进行一次 MD5 加密,结果即为 sign</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<b>示例:</b>
|
||||
<pre className="bg-gray-50 rounded p-2 text-xs overflow-auto">
|
||||
{`参数:
|
||||
name=张三
|
||||
phone=18888888888
|
||||
timestamp=1700000000
|
||||
apiKey=naxf1-82h2f-vdwcm-rrhpm-q9hd1
|
||||
|
||||
排序后拼接(排除apiKey,直接拼接值):
|
||||
张三188888888881700000000
|
||||
|
||||
第一步MD5:
|
||||
md5(张三188888888881700000000) = 123456abcdef...
|
||||
|
||||
拼接apiKey:
|
||||
123456abcdef...naxf1-82h2f-vdwcm-rrhpm-q9hd1
|
||||
|
||||
第二步MD5:
|
||||
sign=md5(123456abcdef...naxf1-82h2f-vdwcm-rrhpm-q9hd1)`}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 mt-2">注意:所有参数均需参与签名(除 sign 和 apiKey),且参数值需为原始值(不可 URL 编码)。</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>接口地址</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2">
|
||||
<span className="font-mono text-sm">{testUrl}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div>必要参数: <b>phone</b> (电话), <b>timestamp</b> (时间戳), <b>apiKey</b> (接口密钥)</div>
|
||||
<div>可选参数: <b>name</b> (姓名), <b>source</b> (来源), <b>remark</b> (备注), <b>tags</b> (标签)</div>
|
||||
<div>请求方式: <b>POST</b> 或 <b>GET</b></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>请求参数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-md p-3 text-xs">
|
||||
<div><b>phone</b> (string, 必填): 客户电话</div>
|
||||
<div><b>timestamp</b> (int, 必填): 当前时间戳,精确到秒(如 1700000000,建议用 Math.floor(Date.now() / 1000) 获取)</div>
|
||||
<div><b>apiKey</b> (string, 必填): 接口密钥</div>
|
||||
<div><b>name</b> (string, 可选): 客户姓名</div>
|
||||
<div><b>source</b> (string, 可选): 来源</div>
|
||||
<div><b>remark</b> (string, 可选): 备注</div>
|
||||
<div><b>tags</b> (string, 可选): 标签</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>响应示例</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-gray-50 rounded-md p-3 text-xs overflow-auto">
|
||||
{`{
|
||||
"code": 200,
|
||||
"msg": "导入成功",
|
||||
"data": {
|
||||
"customerId": "123456"
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>代码示例</CardTitle>
|
||||
<CardDescription>以下是不同编程语言的接口调用示例</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="curl">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="curl">cURL</TabsTrigger>
|
||||
<TabsTrigger value="python">Python</TabsTrigger>
|
||||
<TabsTrigger value="node">Node.js</TabsTrigger>
|
||||
<TabsTrigger value="php">PHP</TabsTrigger>
|
||||
<TabsTrigger value="java">Java</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="curl">
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`curl -X POST 'http://yishi.com/v1/plan/api/scenariosz' \
|
||||
-d "phone=18888888888" \
|
||||
-d "timestamp=1700000000" \
|
||||
-d "name=张三" \
|
||||
-d "apiKey=naxf1-82h2f-vdwcm-rrhpm-q9hd1" \
|
||||
-d "sign=请用签名算法生成"`}</pre>
|
||||
</TabsContent>
|
||||
<TabsContent value="python">
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`import hashlib
|
||||
import time
|
||||
import requests
|
||||
|
||||
def gen_sign(params, api_key):
|
||||
data = {k: v for k, v in params.items() if k not in ('sign', 'apiKey')}
|
||||
s = ''.join([str(data[k]) for k in sorted(data)])
|
||||
first = hashlib.md5(s.encode('utf-8')).hexdigest()
|
||||
return hashlib.md5((first + api_key).encode('utf-8')).hexdigest()
|
||||
|
||||
api_key = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1'
|
||||
params = {
|
||||
'phone': '18888888888',
|
||||
'timestamp': int(time.time()),
|
||||
'name': '张三',
|
||||
}
|
||||
params['apiKey'] = api_key
|
||||
params['sign'] = gen_sign(params, api_key)
|
||||
resp = requests.post('http://yishi.com/v1/plan/api/scenariosz', data=params)
|
||||
print(resp.json())`}</pre>
|
||||
</TabsContent>
|
||||
<TabsContent value="node">
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function genSign(params, apiKey) {
|
||||
const data = {...params};
|
||||
delete data.sign;
|
||||
delete data.apiKey;
|
||||
const keys = Object.keys(data).sort();
|
||||
let str = '';
|
||||
keys.forEach(k => { str += data[k]; });
|
||||
const first = crypto.createHash('md5').update(str).digest('hex');
|
||||
return crypto.createHash('md5').update(first + apiKey).digest('hex');
|
||||
}
|
||||
|
||||
const apiKey = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1';
|
||||
const params = {
|
||||
phone: '18888888888',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
name: '张三',
|
||||
};
|
||||
params.apiKey = apiKey;
|
||||
params.sign = genSign(params, apiKey);
|
||||
|
||||
axios.post('http://yishi.com/v1/plan/api/scenariosz', params)
|
||||
.then(res => console.log(res.data));`}</pre>
|
||||
</TabsContent>
|
||||
<TabsContent value="php">
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`<?php
|
||||
function md5_sign($params, $apiKey) {
|
||||
unset($params['sign']);
|
||||
unset($params['apiKey']);
|
||||
ksort($params);
|
||||
$str = '';
|
||||
foreach ($params as $v) {
|
||||
$str .= $v;
|
||||
}
|
||||
$first = md5($str);
|
||||
$final = md5($first . $apiKey);
|
||||
return $final;
|
||||
}
|
||||
|
||||
$params = [
|
||||
'phone' => '18888888888',
|
||||
'timestamp' => time(),
|
||||
'name' => '张三',
|
||||
// 'source' => '', 'remark' => '', 'tags' => ''
|
||||
];
|
||||
$apiKey = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1';
|
||||
$params['apiKey'] = $apiKey;
|
||||
$params['sign'] = md5_sign($params, $apiKey);
|
||||
|
||||
$url = '${API_BASE_URL}/v1/api/scenarios';
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
echo $response;
|
||||
?>`}</pre>
|
||||
</TabsContent>
|
||||
<TabsContent value="java">
|
||||
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
import java.net.*;
|
||||
import java.io.*;
|
||||
|
||||
public class ApiSignDemo {
|
||||
public static String md5(String s) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] array = md.digest(s.getBytes("UTF-8"));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : array) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("phone", "18888888888");
|
||||
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
|
||||
params.put("name", "张三");
|
||||
// params.put("source", ""); params.put("remark", ""); params.put("tags", "");
|
||||
String apiKey = "naxf1-82h2f-vdwcm-rrhpm-q9hd1";
|
||||
// 排序并拼接
|
||||
List<String> keys = new ArrayList<>(params.keySet());
|
||||
keys.remove("sign");
|
||||
keys.remove("apiKey");
|
||||
Collections.sort(keys);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String k : keys) {
|
||||
sb.append(params.get(k));
|
||||
}
|
||||
String first = md5(sb.toString());
|
||||
String sign = md5(first + apiKey);
|
||||
params.put("apiKey", apiKey);
|
||||
params.put("sign", sign);
|
||||
// 发送POST请求
|
||||
StringBuilder postData = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (postData.length() > 0) postData.append("&");
|
||||
postData.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
|
||||
postData.append("=");
|
||||
postData.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
|
||||
}
|
||||
URL url = new URL("${API_BASE_URL}/v1/api/scenarios");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
OutputStream os = conn.getOutputStream();
|
||||
os.write(postData.toString().getBytes("UTF-8"));
|
||||
os.flush(); os.close();
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
|
||||
String line; StringBuilder resp = new StringBuilder();
|
||||
while ((line = in.readLine()) != null) resp.append(line);
|
||||
in.close();
|
||||
System.out.println(resp.toString());
|
||||
}
|
||||
}`}</pre>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4">集成指南</h3>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">集简云平台集成</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<li>登录集简云平台</li>
|
||||
<li>导航至"应用集成" > "外部接口"</li>
|
||||
<li>选择"添加新接口",输入存客宝接口信息</li>
|
||||
<li>配置回调参数,将"X-API-KEY"设置为您的API密钥</li>
|
||||
<li>
|
||||
设置接口URL为:
|
||||
<code className="bg-gray-100 px-1 py-0.5 rounded">
|
||||
{testUrl}
|
||||
</code>
|
||||
</li>
|
||||
<li>映射必要字段(name, phone等)</li>
|
||||
<li>保存并启用集成</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">问题排查</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">接口认证失败</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
请确保X-API-KEY正确无误,此密钥区分大小写。如需重置密钥,请联系管理员。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">数据格式错误</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
确保所有必填字段已提供,并且字段类型正确。特别是电话号码格式需符合标准。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">请求频率限制</h4>
|
||||
<p className="text-sm text-gray-700">
|
||||
单个API密钥每分钟最多可发送30个请求,超过限制将被暂时限制。对于大批量数据,请使用批量接口。
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
|
||||
|
||||
// 获客场景路由处理
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body: CreateScenarioParams = await request.json()
|
||||
|
||||
// TODO: 实现创建场景的具体逻辑
|
||||
const scenario: ScenarioBase = {
|
||||
id: "generated-id",
|
||||
...body,
|
||||
status: "draft",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
creator: "current-user-id",
|
||||
}
|
||||
|
||||
const response: ApiResponse<ScenarioBase> = {
|
||||
code: 0,
|
||||
message: "创建成功",
|
||||
data: scenario,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "创建失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params: QueryScenarioParams = {
|
||||
type: searchParams.get("type") as any,
|
||||
status: searchParams.get("status") as any,
|
||||
keyword: searchParams.get("keyword") || undefined,
|
||||
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
|
||||
page: Number(searchParams.get("page")) || 1,
|
||||
pageSize: Number(searchParams.get("pageSize")) || 20,
|
||||
}
|
||||
|
||||
// TODO: 实现查询场景列表的具体逻辑
|
||||
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
message: "查询成功",
|
||||
data: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
totalPages: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 500,
|
||||
message: "查询失败",
|
||||
data: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { TrafficUser } from "@/types/traffic"
|
||||
|
||||
// 中文名字生成器数据
|
||||
const familyNames = [
|
||||
"张",
|
||||
"王",
|
||||
"李",
|
||||
"赵",
|
||||
"陈",
|
||||
"刘",
|
||||
"杨",
|
||||
"黄",
|
||||
"周",
|
||||
"吴",
|
||||
"朱",
|
||||
"孙",
|
||||
"马",
|
||||
"胡",
|
||||
"郭",
|
||||
"林",
|
||||
"何",
|
||||
"高",
|
||||
"梁",
|
||||
"郑",
|
||||
"罗",
|
||||
"宋",
|
||||
"谢",
|
||||
"唐",
|
||||
"韩",
|
||||
"曹",
|
||||
"许",
|
||||
"邓",
|
||||
"萧",
|
||||
"冯",
|
||||
]
|
||||
const givenNames1 = [
|
||||
"志",
|
||||
"建",
|
||||
"文",
|
||||
"明",
|
||||
"永",
|
||||
"春",
|
||||
"秀",
|
||||
"金",
|
||||
"水",
|
||||
"玉",
|
||||
"国",
|
||||
"立",
|
||||
"德",
|
||||
"海",
|
||||
"和",
|
||||
"荣",
|
||||
"伟",
|
||||
"新",
|
||||
"英",
|
||||
"佳",
|
||||
]
|
||||
const givenNames2 = [
|
||||
"华",
|
||||
"平",
|
||||
"军",
|
||||
"强",
|
||||
"辉",
|
||||
"敏",
|
||||
"峰",
|
||||
"磊",
|
||||
"超",
|
||||
"艳",
|
||||
"娜",
|
||||
"霞",
|
||||
"燕",
|
||||
"娟",
|
||||
"静",
|
||||
"丽",
|
||||
"涛",
|
||||
"洋",
|
||||
"勇",
|
||||
"龙",
|
||||
]
|
||||
|
||||
// 生成固定的用户数据池
|
||||
const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去7天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 7))
|
||||
|
||||
return {
|
||||
id: `${Date.now()}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
})
|
||||
|
||||
// 计算今日新增数量
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
|
||||
|
||||
// 生成微信好友数据池
|
||||
const generateWechatFriends = (wechatId: string, count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
|
||||
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
|
||||
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
|
||||
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
|
||||
|
||||
// 生成随机时间(在过去30天内)
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 30))
|
||||
|
||||
return {
|
||||
id: `wechat-${wechatId}-${i}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
|
||||
nickname: fullName,
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
|
||||
region: [
|
||||
"广东深圳",
|
||||
"浙江杭州",
|
||||
"江苏苏州",
|
||||
"北京",
|
||||
"上海",
|
||||
"四川成都",
|
||||
"湖北武汉",
|
||||
"福建厦门",
|
||||
"山东青岛",
|
||||
"河南郑州",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
note: [
|
||||
"咨询产品价格",
|
||||
"对产品很感兴趣",
|
||||
"准备购买",
|
||||
"需要更多信息",
|
||||
"想了解优惠活动",
|
||||
"询问产品规格",
|
||||
"要求产品demo",
|
||||
"索要产品目录",
|
||||
"询问售后服务",
|
||||
"要求上门演示",
|
||||
][Math.floor(Math.random() * 10)],
|
||||
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
|
||||
addTime: date.toISOString(),
|
||||
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
|
||||
Math.floor(Math.random() * 7)
|
||||
],
|
||||
assignedTo: "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
|
||||
tags: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 微信好友数据缓存
|
||||
const wechatFriendsCache = new Map<string, TrafficUser[]>()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Number.parseInt(searchParams.get("page") || "1")
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "10")
|
||||
const search = searchParams.get("search") || ""
|
||||
const category = searchParams.get("category") || "all"
|
||||
const source = searchParams.get("source") || "all"
|
||||
const status = searchParams.get("status") || "all"
|
||||
const startDate = searchParams.get("startDate")
|
||||
const endDate = searchParams.get("endDate")
|
||||
const wechatSource = searchParams.get("wechatSource") || ""
|
||||
|
||||
let filteredUsers = [...userPool]
|
||||
|
||||
// 如果有微信来源参数,生成或获取微信好友数据
|
||||
if (wechatSource) {
|
||||
if (!wechatFriendsCache.has(wechatSource)) {
|
||||
// 生成150-300个随机好友
|
||||
const friendCount = Math.floor(Math.random() * (300 - 150)) + 150
|
||||
wechatFriendsCache.set(wechatSource, generateWechatFriends(wechatSource, friendCount))
|
||||
}
|
||||
filteredUsers = wechatFriendsCache.get(wechatSource) || []
|
||||
}
|
||||
|
||||
// 应用过滤条件
|
||||
filteredUsers = filteredUsers.filter((user) => {
|
||||
const matchesSearch = search
|
||||
? user.nickname.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.wechatId.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.phone.includes(search)
|
||||
: true
|
||||
|
||||
const matchesCategory = category === "all" ? true : user.category === category
|
||||
const matchesSource = source === "all" ? true : user.source === source
|
||||
const matchesStatus = status === "all" ? true : user.status === status
|
||||
|
||||
const matchesDate =
|
||||
startDate && endDate
|
||||
? new Date(user.addTime) >= new Date(startDate) && new Date(user.addTime) <= new Date(endDate)
|
||||
: true
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSource && matchesStatus && matchesDate
|
||||
})
|
||||
|
||||
// 按添加时间倒序排序
|
||||
filteredUsers.sort((a, b) => new Date(b.addTime).getTime() - new Date(a.addTime).getTime())
|
||||
|
||||
// 计算分页
|
||||
const total = filteredUsers.length
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const start = (page - 1) * pageSize
|
||||
const end = start + pageSize
|
||||
const users = filteredUsers.slice(start, end)
|
||||
|
||||
// 计算分类统计
|
||||
const categoryStats = {
|
||||
potential: userPool.filter((user) => user.category === "potential").length,
|
||||
customer: userPool.filter((user) => user.category === "customer").length,
|
||||
lost: userPool.filter((user) => user.category === "lost").length,
|
||||
}
|
||||
|
||||
// 模拟网络延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
pagination: {
|
||||
total,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
pageSize,
|
||||
},
|
||||
stats: {
|
||||
total: wechatSource ? filteredUsers.length : userPool.length,
|
||||
todayNew: wechatSource ? Math.floor(filteredUsers.length * 0.1) : todayUsers.length,
|
||||
categoryStats,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// 这个文件不再需要,我们使用app/layout.tsx作为统一布局
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export default function ComponentsDemoLoading() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse flex-1" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
import { CalendarIcon, Code, Copy, Check, Smartphone, Users, TrendingUp, Activity } from "lucide-react"
|
||||
|
||||
/**
|
||||
* 组件库展示页面
|
||||
* 展示所有可用的UI组件和自定义组件
|
||||
*/
|
||||
export default function ComponentsDemo() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">存客宝组件库</h1>
|
||||
<p className="text-gray-600">展示项目中所有可用的UI组件和自定义组件,包含使用示例和代码演示</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="basic" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-6">
|
||||
<TabsTrigger value="basic">基础组件</TabsTrigger>
|
||||
<TabsTrigger value="forms">表单组件</TabsTrigger>
|
||||
<TabsTrigger value="data">数据展示</TabsTrigger>
|
||||
<TabsTrigger value="feedback">反馈组件</TabsTrigger>
|
||||
<TabsTrigger value="navigation">导航组件</TabsTrigger>
|
||||
<TabsTrigger value="custom">自定义组件</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 基础组件 */}
|
||||
<TabsContent value="basic" className="space-y-6">
|
||||
<ComponentSection title="按钮组件" description="各种样式和状态的按钮">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button>默认按钮</Button>
|
||||
<Button variant="secondary">次要按钮</Button>
|
||||
<Button variant="outline">边框按钮</Button>
|
||||
<Button variant="ghost">幽灵按钮</Button>
|
||||
<Button variant="destructive">危险按钮</Button>
|
||||
<Button disabled>禁用按钮</Button>
|
||||
<Button size="sm">小按钮</Button>
|
||||
<Button size="lg">大按钮</Button>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="徽章组件" description="用于标记和分类的徽章">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Badge>默认</Badge>
|
||||
<Badge variant="secondary">次要</Badge>
|
||||
<Badge variant="outline">边框</Badge>
|
||||
<Badge variant="destructive">危险</Badge>
|
||||
<Badge className="bg-green-500">成功</Badge>
|
||||
<Badge className="bg-yellow-500">警告</Badge>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="头像组件" description="用户头像展示">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar>
|
||||
<AvatarImage src="/placeholder.svg?height=40&width=40" alt="用户头像" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarFallback>AB</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarFallback>XY</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="分隔符" description="内容分隔线">
|
||||
<div className="space-y-4">
|
||||
<div>水平分隔符</div>
|
||||
<Separator />
|
||||
<div>垂直分隔符</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>左侧内容</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span>右侧内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* 表单组件 */}
|
||||
<TabsContent value="forms" className="space-y-6">
|
||||
<ComponentSection title="输入框" description="各种类型的输入框">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">文本输入</Label>
|
||||
<Input id="text" placeholder="请输入文本" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱输入</Label>
|
||||
<Input id="email" type="email" placeholder="请输入邮箱" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码输入</Label>
|
||||
<Input id="password" type="password" placeholder="请输入密码" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">禁用输入</Label>
|
||||
<Input id="disabled" placeholder="禁用状态" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="文本域" description="多行文本输入">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="textarea">描述</Label>
|
||||
<Textarea id="textarea" placeholder="请输入详细描述..." rows={4} />
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="选择器" description="下拉选择组件">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>设备类型</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择设备类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="android">Android</SelectItem>
|
||||
<SelectItem value="ios">iOS</SelectItem>
|
||||
<SelectItem value="windows">Windows</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
<SelectItem value="busy">忙碌</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="开关和复选框" description="布尔值输入组件">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="notifications" />
|
||||
<Label htmlFor="notifications">启用通知</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" />
|
||||
<Label htmlFor="terms">同意服务条款</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="marketing" />
|
||||
<Label htmlFor="marketing">接收营销邮件</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="单选按钮" description="单选组件">
|
||||
<RadioGroup defaultValue="option1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="option1" id="option1" />
|
||||
<Label htmlFor="option1">选项 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="option2" id="option2" />
|
||||
<Label htmlFor="option2">选项 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="option3" id="option3" />
|
||||
<Label htmlFor="option3">选项 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="滑块" description="数值范围选择">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>音量: 50%</Label>
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>价格范围: ¥100 - ¥500</Label>
|
||||
<Slider defaultValue={[100, 500]} max={1000} step={10} />
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* 数据展示 */}
|
||||
<TabsContent value="data" className="space-y-6">
|
||||
<ComponentSection title="统计卡片" description="数据统计展示">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCardDemo
|
||||
title="总设备数"
|
||||
value="1,234"
|
||||
icon={<Smartphone className="h-6 w-6" />}
|
||||
trend={{ value: 12, isPositive: true }}
|
||||
/>
|
||||
<StatCardDemo
|
||||
title="在线用户"
|
||||
value="856"
|
||||
icon={<Users className="h-6 w-6" />}
|
||||
trend={{ value: 8, isPositive: true }}
|
||||
/>
|
||||
<StatCardDemo
|
||||
title="今日获客"
|
||||
value="342"
|
||||
icon={<TrendingUp className="h-6 w-6" />}
|
||||
trend={{ value: 5, isPositive: false }}
|
||||
/>
|
||||
<StatCardDemo
|
||||
title="活跃度"
|
||||
value="89%"
|
||||
icon={<Activity className="h-6 w-6" />}
|
||||
trend={{ value: 3, isPositive: true }}
|
||||
/>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="进度条" description="进度展示组件">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>任务进度</Label>
|
||||
<span className="text-sm text-gray-500">75%</span>
|
||||
</div>
|
||||
<Progress value={75} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<Label>存储使用</Label>
|
||||
<span className="text-sm text-gray-500">45%</span>
|
||||
</div>
|
||||
<Progress value={45} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="表格" description="数据表格展示">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>设备名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead className="text-right">好友数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">设备001</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-500">在线</Badge>
|
||||
</TableCell>
|
||||
<TableCell>Android</TableCell>
|
||||
<TableCell className="text-right">1,234</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">设备002</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">离线</Badge>
|
||||
</TableCell>
|
||||
<TableCell>iOS</TableCell>
|
||||
<TableCell className="text-right">856</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">设备003</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-yellow-500">忙碌</Badge>
|
||||
</TableCell>
|
||||
<TableCell>Android</TableCell>
|
||||
<TableCell className="text-right">567</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* 反馈组件 */}
|
||||
<TabsContent value="feedback" className="space-y-6">
|
||||
<ComponentSection title="对话框" description="模态对话框组件">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>打开对话框</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认操作</DialogTitle>
|
||||
<DialogDescription>此操作将永久删除选中的数据,是否继续?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline">取消</Button>
|
||||
<Button variant="destructive">确认删除</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="弹出框" description="悬浮弹出内容">
|
||||
<div className="flex gap-4">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
选择日期
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar mode="single" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline">悬停提示</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>这是一个工具提示</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* 导航组件 */}
|
||||
<TabsContent value="navigation" className="space-y-6">
|
||||
<ComponentSection title="手风琴" description="可折叠内容面板">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>设备管理</AccordionTrigger>
|
||||
<AccordionContent>管理所有连接的设备,包括添加、删除、配置等操作。</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>获客计划</AccordionTrigger>
|
||||
<AccordionContent>创建和管理各种获客计划,设置目标和策略。</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>数据分析</AccordionTrigger>
|
||||
<AccordionContent>查看详细的数据报告和分析结果。</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ComponentSection>
|
||||
|
||||
<ComponentSection title="滚动区域" description="自定义滚动条的内容区域">
|
||||
<ScrollArea className="h-72 w-full rounded-md border p-4">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="p-4 border rounded">
|
||||
<h4 className="font-medium">项目 {i + 1}</h4>
|
||||
<p className="text-sm text-gray-600">这是项目 {i + 1} 的描述内容。</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
|
||||
{/* 自定义组件 */}
|
||||
<TabsContent value="custom" className="space-y-6">
|
||||
<ComponentSection title="文件上传器" description="支持拖拽的文件上传组件">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<p className="text-gray-500">拖拽文件到此处或点击上传</p>
|
||||
<Button className="mt-4">选择文件</Button>
|
||||
</div>
|
||||
</ComponentSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 组件展示区域
|
||||
function ComponentSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [showCode, setShowCode] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowCode(!showCode)}>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
{showCode ? "隐藏代码" : "查看代码"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="p-6 border rounded-lg bg-gray-50">{children}</div>
|
||||
{showCode && (
|
||||
<div className="p-4 bg-gray-900 text-gray-100 rounded-lg text-sm overflow-x-auto">
|
||||
<pre>{`// 示例代码将在这里显示\n// 具体实现请参考组件源码`}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 统计卡片演示组件
|
||||
function StatCardDemo({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
icon: React.ReactNode
|
||||
trend: { value: number; isPositive: boolean }
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</div>
|
||||
<div className="text-gray-400">{icon}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
<span className={`text-sm font-medium ${trend.isPositive ? "text-green-600" : "text-red-600"}`}>
|
||||
{trend.isPositive ? "+" : "-"}
|
||||
{trend.value}%
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 ml-2">较上月</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
|
||||
export default function ComponentsDocsLoading() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-gray-200 rounded animate-pulse flex-1" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded animate-pulse" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
/**
|
||||
* 组件文档页面
|
||||
* 提供详细的组件使用文档和API说明
|
||||
*/
|
||||
export default function ComponentsDocs() {
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-8">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">组件文档</h1>
|
||||
<p className="text-gray-600">详细的组件使用指南、API文档和最佳实践</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="getting-started" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="getting-started">快速开始</TabsTrigger>
|
||||
<TabsTrigger value="basic-components">基础组件</TabsTrigger>
|
||||
<TabsTrigger value="custom-components">自定义组件</TabsTrigger>
|
||||
<TabsTrigger value="best-practices">最佳实践</TabsTrigger>
|
||||
<TabsTrigger value="changelog">更新日志</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 快速开始 */}
|
||||
<TabsContent value="getting-started" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>快速开始</CardTitle>
|
||||
<CardDescription>了解如何在项目中使用组件库</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">安装和导入</h3>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre>{`// 导入基础UI组件
|
||||
import { Button, Card, Input } from "@/components/ui"
|
||||
|
||||
// 导入自定义组件
|
||||
import { PageHeader, StatCard } from "@/app/components/common"
|
||||
|
||||
// 使用组件
|
||||
function MyComponent() {
|
||||
return (
|
||||
<Card>
|
||||
<PageHeader title="标题" description="描述" />
|
||||
<Button>点击我</Button>
|
||||
</Card>
|
||||
)
|
||||
}`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">项目结构</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<pre>{`app/
|
||||
├── components/
|
||||
│ ├── ui/ # 基础UI组件
|
||||
│ │ ├── button.tsx
|
||||
│ │ ├── card.tsx
|
||||
│ │ └── ...
|
||||
│ └── common/ # 自定义业务组件
|
||||
│ ├── PageHeader.tsx
|
||||
│ ├── StatCard.tsx
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ └── utils.ts # 工具函数
|
||||
└── types/ # 类型定义`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">主题配置</h3>
|
||||
<p className="text-gray-600 mb-3">组件库使用 Tailwind CSS 进行样式管理,支持自定义主题配置。</p>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg">
|
||||
<pre>{`// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 基础组件文档 */}
|
||||
<TabsContent value="basic-components" className="space-y-6">
|
||||
<ComponentDoc
|
||||
name="Button"
|
||||
description="按钮组件,支持多种样式和状态"
|
||||
props={[
|
||||
{
|
||||
name: "variant",
|
||||
type: "'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'",
|
||||
default: "'default'",
|
||||
description: "按钮样式变体",
|
||||
},
|
||||
{ name: "size", type: "'sm' | 'default' | 'lg'", default: "'default'", description: "按钮大小" },
|
||||
{ name: "disabled", type: "boolean", default: "false", description: "是否禁用" },
|
||||
{ name: "onClick", type: "() => void", default: "-", description: "点击事件处理函数" },
|
||||
]}
|
||||
example={`<Button variant="primary" size="lg" onClick={() => alert('clicked')}>
|
||||
点击我
|
||||
</Button>`}
|
||||
/>
|
||||
|
||||
<ComponentDoc
|
||||
name="Card"
|
||||
description="卡片容器组件,用于包装内容"
|
||||
props={[
|
||||
{ name: "className", type: "string", default: "-", description: "自定义CSS类名" },
|
||||
{ name: "children", type: "ReactNode", default: "-", description: "卡片内容" },
|
||||
]}
|
||||
example={`<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>标题</CardTitle>
|
||||
<CardDescription>描述</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
内容区域
|
||||
</CardContent>
|
||||
</Card>`}
|
||||
/>
|
||||
|
||||
<ComponentDoc
|
||||
name="Input"
|
||||
description="输入框组件,支持多种类型"
|
||||
props={[
|
||||
{
|
||||
name: "type",
|
||||
type: "'text' | 'email' | 'password' | 'number'",
|
||||
default: "'text'",
|
||||
description: "输入类型",
|
||||
},
|
||||
{ name: "placeholder", type: "string", default: "-", description: "占位符文本" },
|
||||
{ name: "disabled", type: "boolean", default: "false", description: "是否禁用" },
|
||||
{ name: "value", type: "string", default: "-", description: "输入值" },
|
||||
{ name: "onChange", type: "(e: ChangeEvent) => void", default: "-", description: "值变化回调" },
|
||||
]}
|
||||
example={`<Input
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>`}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 自定义组件文档 */}
|
||||
<TabsContent value="custom-components" className="space-y-6">
|
||||
<ComponentDoc
|
||||
name="PageHeader"
|
||||
description="页面头部组件,提供统一的页面标题和描述布局"
|
||||
props={[
|
||||
{ name: "title", type: "string", default: "-", description: "页面标题" },
|
||||
{ name: "description", type: "string", default: "-", description: "页面描述" },
|
||||
{ name: "actions", type: "ReactNode", default: "-", description: "操作按钮区域" },
|
||||
{ name: "breadcrumb", type: "ReactNode", default: "-", description: "面包屑导航" },
|
||||
]}
|
||||
example={`<PageHeader
|
||||
title="设备管理"
|
||||
description="管理所有连接的设备"
|
||||
actions={<Button>添加设备</Button>}
|
||||
/>`}
|
||||
/>
|
||||
|
||||
<ComponentDoc
|
||||
name="StatCard"
|
||||
description="统计卡片组件,用于展示关键指标"
|
||||
props={[
|
||||
{ name: "title", type: "string", default: "-", description: "统计标题" },
|
||||
{ name: "value", type: "string | number", default: "-", description: "统计值" },
|
||||
{ name: "icon", type: "ReactNode", default: "-", description: "图标" },
|
||||
{ name: "trend", type: "{ value: number; isPositive: boolean }", default: "-", description: "趋势信息" },
|
||||
{ name: "description", type: "string", default: "-", description: "描述信息" },
|
||||
]}
|
||||
example={`<StatCard
|
||||
title="总设备数"
|
||||
value="1,234"
|
||||
icon={<Smartphone className="h-6 w-6" />}
|
||||
trend={{ value: 12, isPositive: true }}
|
||||
/>`}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 最佳实践 */}
|
||||
<TabsContent value="best-practices" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>最佳实践</CardTitle>
|
||||
<CardDescription>使用组件库的推荐做法</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">组件使用原则</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li>• 优先使用现有组件,避免重复造轮子</li>
|
||||
<li>• 保持组件的单一职责原则</li>
|
||||
<li>• 使用TypeScript确保类型安全</li>
|
||||
<li>• 遵循统一的命名规范</li>
|
||||
<li>• 添加适当的注释和文档</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">性能优化</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li>• 使用React.memo优化组件渲染</li>
|
||||
<li>• 合理使用useCallback和useMemo</li>
|
||||
<li>• 避免在render中创建新对象</li>
|
||||
<li>• 使用懒加载减少初始包大小</li>
|
||||
<li>• 实现虚拟滚动处理大数据</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">可访问性</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li>• 为所有交互元素添加适当的ARIA标签</li>
|
||||
<li>• 确保键盘导航的可用性</li>
|
||||
<li>• 提供足够的颜色对比度</li>
|
||||
<li>• 为图片添加alt属性</li>
|
||||
<li>• 使用语义化的HTML标签</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 更新日志 */}
|
||||
<TabsContent value="changelog" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>更新日志</CardTitle>
|
||||
<CardDescription>组件库的版本更新记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge>v1.2.0</Badge>
|
||||
<span className="text-sm text-gray-500">2024-01-15</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-2">新增功能</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• 新增 FileUploader 组件</li>
|
||||
<li>• 新增 Wizard 向导组件</li>
|
||||
<li>• 新增 NotificationSystem 通知系统</li>
|
||||
<li>• 新增图表组件库</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="secondary">v1.1.0</Badge>
|
||||
<span className="text-sm text-gray-500">2024-01-10</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-2">改进优化</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• 优化 DeviceSelector 组件性能</li>
|
||||
<li>• 改进 DataTable 组件的响应式设计</li>
|
||||
<li>• 统一组件的样式规范</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline">v1.0.0</Badge>
|
||||
<span className="text-sm text-gray-500">2024-01-01</span>
|
||||
</div>
|
||||
<h4 className="font-medium mb-2">初始版本</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• 基础UI组件库</li>
|
||||
<li>• 核心业务组件</li>
|
||||
<li>• 组件文档系统</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 组件文档展示组件
|
||||
function ComponentDoc({
|
||||
name,
|
||||
description,
|
||||
props,
|
||||
example,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
props: Array<{
|
||||
name: string
|
||||
type: string
|
||||
default: string
|
||||
description: string
|
||||
}>
|
||||
example: string
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{name}
|
||||
<Badge variant="outline">组件</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">属性 (Props)</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>属性名</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>默认值</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.map((prop) => (
|
||||
<TableRow key={prop.name}>
|
||||
<TableCell className="font-mono text-sm">{prop.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-blue-600">{prop.type}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{prop.default}</TableCell>
|
||||
<TableCell>{prop.description}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-3">使用示例</h4>
|
||||
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
||||
<pre className="text-sm">{example}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Card } from "./ui/card"
|
||||
|
||||
interface AIRewriteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
originalContent: string
|
||||
}
|
||||
|
||||
export function AIRewriteModal({ isOpen, onClose, originalContent }: AIRewriteModalProps) {
|
||||
const [rewrittenContent, setRewrittenContent] = useState("")
|
||||
|
||||
const handleRewrite = async () => {
|
||||
// 这里应该调用 AI 改写 API
|
||||
// 为了演示,我们只是简单地反转字符串
|
||||
setRewrittenContent(originalContent.split("").reverse().join(""))
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">AI 内容改写</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">原始内容:</h3>
|
||||
<p className="text-sm text-gray-600">{originalContent}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">改写后内容:</h3>
|
||||
<p className="text-sm text-gray-600">{rewrittenContent || '点击"开始改写"按钮生成内容'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleRewrite}>开始改写</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import BottomNav from "./BottomNav"
|
||||
|
||||
function AdaptiveLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 主内容区域 */}
|
||||
<main className="max-w-[390px] mx-auto">
|
||||
<div className="bg-white min-h-screen pb-16">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* 始终显示底部导航 */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 提供默认导出和命名导出
|
||||
export default AdaptiveLayout
|
||||
export { AdaptiveLayout }
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { login, getUserInfo } from "@/lib/api/auth"
|
||||
|
||||
export function ApiTester() {
|
||||
const [testAccount, setTestAccount] = useState("13800138000")
|
||||
const [testPassword, setTestPassword] = useState("123456")
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const testLogin = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await login({
|
||||
account: testAccount,
|
||||
password: testPassword,
|
||||
typeid: 1,
|
||||
})
|
||||
setTestResult(result)
|
||||
} catch (error) {
|
||||
setTestResult({ error: error instanceof Error ? error.message : "测试失败" })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testUserInfo = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await getUserInfo()
|
||||
setTestResult(result)
|
||||
} catch (error) {
|
||||
setTestResult({ error: error instanceof Error ? error.message : "测试失败" })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
API测试工具
|
||||
<Badge variant="outline">开发模式</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input placeholder="测试账号" value={testAccount} onChange={(e) => setTestAccount(e.target.value)} />
|
||||
<Input
|
||||
placeholder="测试密码"
|
||||
type="password"
|
||||
value={testPassword}
|
||||
onChange={(e) => setTestPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={testLogin} disabled={isLoading} size="sm">
|
||||
测试登录
|
||||
</Button>
|
||||
<Button onClick={testUserInfo} disabled={isLoading} size="sm" variant="outline">
|
||||
测试用户信息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-4 p-3 bg-gray-100 rounded-md">
|
||||
<pre className="text-xs overflow-auto">{JSON.stringify(testResult, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { checkLoginStatus, getCurrentUser, getToken, clearAuthData } from "@/lib/api/auth"
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
phone: string
|
||||
avatar?: string
|
||||
role: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
user: User | null
|
||||
token: string | null
|
||||
login: (token: string) => void
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// 不需要认证的页面路径
|
||||
const PUBLIC_PATHS = ["/login", "/register", "/forgot-password"]
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化认证状态
|
||||
const initAuth = () => {
|
||||
console.log("初始化认证状态...")
|
||||
|
||||
const isLoggedIn = checkLoginStatus()
|
||||
const currentUser = getCurrentUser()
|
||||
const currentToken = getToken()
|
||||
|
||||
console.log("认证检查结果:", { isLoggedIn, hasUser: !!currentUser, hasToken: !!currentToken })
|
||||
|
||||
if (isLoggedIn && currentUser && currentToken) {
|
||||
setToken(currentToken)
|
||||
setUser(currentUser)
|
||||
setIsAuthenticated(true)
|
||||
console.log("用户已登录:", currentUser)
|
||||
} else {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setIsAuthenticated(false)
|
||||
console.log("用户未登录")
|
||||
|
||||
// 如果当前页面需要认证且用户未登录,跳转到登录页
|
||||
if (!PUBLIC_PATHS.includes(pathname)) {
|
||||
console.log("重定向到登录页")
|
||||
router.push("/login")
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [pathname, router])
|
||||
|
||||
// 监听认证错误事件
|
||||
useEffect(() => {
|
||||
const handleAuthError = (event: CustomEvent) => {
|
||||
if (event.detail === "UNAUTHORIZED") {
|
||||
console.log("收到未授权事件,清除认证状态")
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth-error", handleAuthError as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("auth-error", handleAuthError as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = (newToken: string) => {
|
||||
const currentUser = getCurrentUser()
|
||||
console.log("更新认证状态:", { token: newToken.substring(0, 10) + "...", user: currentUser })
|
||||
|
||||
setToken(newToken)
|
||||
setUser(currentUser)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
console.log("执行登出操作")
|
||||
clearAuthData()
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setIsAuthenticated(false)
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">正在加载...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated,
|
||||
user,
|
||||
token,
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { QrCode } from "lucide-react"
|
||||
|
||||
export function BindDouyinQRCode() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(true)}>
|
||||
<QrCode className="h-4 w-4" />
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>绑定抖音号</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center p-4">
|
||||
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<img src="/placeholder.svg?height=256&width=256" alt="抖音二维码" className="w-full h-full" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">请使用抖音APP扫描二维码进行绑定</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { Home, Users, LayoutGrid, User } from "lucide-react"
|
||||
|
||||
export default function BottomNav() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: "首页",
|
||||
href: "/",
|
||||
icon: Home,
|
||||
active: pathname === "/",
|
||||
},
|
||||
{
|
||||
name: "场景获客",
|
||||
href: "/scenarios",
|
||||
icon: Users,
|
||||
active: pathname.startsWith("/scenarios"),
|
||||
},
|
||||
{
|
||||
name: "工作台",
|
||||
href: "/workspace",
|
||||
icon: LayoutGrid,
|
||||
active: pathname.startsWith("/workspace"),
|
||||
},
|
||||
{
|
||||
name: "我的",
|
||||
href: "/profile",
|
||||
icon: User,
|
||||
active: pathname.startsWith("/profile"),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
|
||||
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => router.push(item.href)}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
||||
item.active ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-xs mt-1">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
import { BarChart as RechartsBarChart, Bar } from "recharts"
|
||||
|
||||
const lineData = [
|
||||
{ name: "周一", 新增微信号: 12 },
|
||||
{ name: "周二", 新增微信号: 19 },
|
||||
{ name: "周三", 新增微信号: 3 },
|
||||
{ name: "周四", 新增微信号: 5 },
|
||||
{ name: "周五", 新增微信号: 2 },
|
||||
{ name: "周六", 新增微信号: 3 },
|
||||
{ name: "周日", 新增微信号: 10 },
|
||||
]
|
||||
|
||||
const barData = [
|
||||
{ name: "周一", 新增好友: 120 },
|
||||
{ name: "周二", 新增好友: 190 },
|
||||
{ name: "周三", 新增好友: 30 },
|
||||
{ name: "周四", 新增好友: 50 },
|
||||
{ name: "周五", 新增好友: 20 },
|
||||
{ name: "周六", 新增好友: 30 },
|
||||
{ name: "周日", 新增好友: 100 },
|
||||
]
|
||||
|
||||
export function LineChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsLineChart data={lineData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="新增微信号" stroke="#8884d8" />
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarChart() {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<RechartsBarChart data={barData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="新增好友" fill="#82ca9d" />
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrendChart({ data, dataKey = "value", height = 300 }) {
|
||||
return (
|
||||
<div className="w-full" style={{ height: `${height}px` }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsLineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4, fill: "#3b82f6" }}
|
||||
activeDot={{ r: 6, fill: "#3b82f6" }}
|
||||
isAnimationActive={true}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Avatar } from "@/components/ui/avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChatMessageProps {
|
||||
content: string
|
||||
isUser: boolean
|
||||
timestamp: Date
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export function ChatMessage({ content, isUser, timestamp, avatar }: ChatMessageProps) {
|
||||
return (
|
||||
<div className={cn("flex w-full gap-3 p-4", isUser ? "justify-end" : "justify-start")}>
|
||||
{!isUser && (
|
||||
<Avatar className="h-8 w-8">
|
||||
<div className="bg-primary text-primary-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
|
||||
AI
|
||||
</div>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={cn("rounded-lg p-3 max-w-[80%]", isUser ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
<div className={cn("text-xs mt-1", isUser ? "text-primary-foreground/70" : "text-muted-foreground")}>
|
||||
{timestamp.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && avatar && (
|
||||
<Avatar className="h-8 w-8">
|
||||
<img src={avatar || "/placeholder.svg"} alt="User" className="rounded-full" />
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { ChevronLeft, Search, Plus } from "lucide-react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const mockLibraries: ContentLibrary[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "卡若朋友圈",
|
||||
type: "朋友圈",
|
||||
count: 307,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "业务推广内容",
|
||||
type: "朋友圈",
|
||||
count: 156,
|
||||
},
|
||||
]
|
||||
|
||||
export function ContentSelector({ onPrev, onFinish }) {
|
||||
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">选择内容库</h2>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建内容库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="搜索内容库" className="w-full" prefix={<Search className="w-4 h-4 text-gray-400" />} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">选择</TableHead>
|
||||
<TableHead>内容库名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>内容数量</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockLibraries.map((library) => (
|
||||
<TableRow key={library.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLibraries.includes(library.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedLibraries([...selectedLibraries, library.id])
|
||||
} else {
|
||||
setSelectedLibraries(selectedLibraries.filter((id) => id !== library.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{library.name}</TableCell>
|
||||
<TableCell>{library.type}</TableCell>
|
||||
<TableCell>{library.count}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
预览内容
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onFinish}
|
||||
disabled={selectedLibraries.length === 0}
|
||||
className="bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
完成设置
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
|
||||
import { ChevronLeft, ChevronRight, Search, Plus } from "lucide-react"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
friendStatus: string
|
||||
}
|
||||
|
||||
const mockDevices: Device[] = [
|
||||
{
|
||||
id: "1",
|
||||
imei: "123456789012345",
|
||||
status: "online",
|
||||
friendStatus: "正常",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
imei: "987654321098765",
|
||||
status: "offline",
|
||||
friendStatus: "异常",
|
||||
},
|
||||
]
|
||||
|
||||
export function DeviceSelector({ onNext, onPrev }) {
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold">选择推送设备</h2>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/手机号"
|
||||
className="w-full"
|
||||
prefix={<Search className="w-4 h-4 text-gray-400" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">选择</TableHead>
|
||||
<TableHead>设备IMEI/备注/手机号</TableHead>
|
||||
<TableHead>在线状态</TableHead>
|
||||
<TableHead>加友状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockDevices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDevices([...selectedDevices, device.id])
|
||||
} else {
|
||||
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{device.imei}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
|
||||
device.friendStatus === "正常" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{device.friendStatus}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">
|
||||
查看详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={selectedDevices.length === 0}>
|
||||
下一步
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "../ui/card"
|
||||
import { Button } from "../ui/button"
|
||||
import { Input } from "../ui/input"
|
||||
import { Switch } from "../ui/switch"
|
||||
import { Label } from "../ui/label"
|
||||
import { ChevronLeft, ChevronRight, Plus, Minus } from "lucide-react"
|
||||
|
||||
interface TaskSetupProps {
|
||||
onNext?: () => void
|
||||
onPrev?: () => void
|
||||
step: number
|
||||
}
|
||||
|
||||
export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
|
||||
const [syncCount, setSyncCount] = useState(5)
|
||||
const [startTime, setStartTime] = useState("06:00")
|
||||
const [endTime, setEndTime] = useState("23:59")
|
||||
const [isEnabled, setIsEnabled] = useState(true)
|
||||
const [accountType, setAccountType] = useState("business") // business or personal
|
||||
|
||||
return (
|
||||
<Card className="p-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-semibold">朋友圈同步任务</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="task-enabled">是否启用</Label>
|
||||
<Switch id="task-enabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
<Label>任务名称</Label>
|
||||
<Input placeholder="请输入任务名称" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>允许发布的时间段</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
<span>至</span>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>每日同步数量</Label>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="outline" size="icon" onClick={() => setSyncCount(Math.max(1, syncCount - 1))}>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center">{syncCount}</span>
|
||||
<Button variant="outline" size="icon" onClick={() => setSyncCount(syncCount + 1)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-gray-500">条朋友圈</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<Label>账号类型</Label>
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
variant={accountType === "business" ? "default" : "outline"}
|
||||
onClick={() => setAccountType("business")}
|
||||
className="w-24"
|
||||
>
|
||||
业务号
|
||||
</Button>
|
||||
<Button
|
||||
variant={accountType === "personal" ? "default" : "outline"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
className="w-24"
|
||||
>
|
||||
人设号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-8">
|
||||
{step > 1 ? (
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button onClick={onNext}>
|
||||
下一步
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Filter, Search, RefreshCw, AlertCircle } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
interface WechatAccount {
|
||||
wechatId: string
|
||||
nickname: string
|
||||
remainingAdds: number
|
||||
maxDailyAdds: number
|
||||
todayAdded: number
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
wechatAccounts: WechatAccount[]
|
||||
usedInPlans: number
|
||||
}
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
onSelect: (selectedDevices: string[]) => void
|
||||
initialSelectedDevices?: string[]
|
||||
excludeUsedDevices?: boolean
|
||||
}
|
||||
|
||||
export function DeviceSelector({
|
||||
onSelect,
|
||||
initialSelectedDevices = [],
|
||||
excludeUsedDevices = true,
|
||||
}: DeviceSelectorProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialSelectedDevices)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const devicesPerPage = 10
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟获取设备数据
|
||||
const fetchDevices = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const mockDevices = Array.from({ length: 42 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
imei: `IMEI-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
status: Math.random() > 0.3 ? "online" : "offline",
|
||||
wechatAccounts: Array.from({ length: Math.floor(Math.random() * 2) + 1 }, (_, j) => ({
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
nickname: `微信号 ${j + 1}`,
|
||||
remainingAdds: Math.floor(Math.random() * 10) + 5,
|
||||
maxDailyAdds: 20,
|
||||
todayAdded: Math.floor(Math.random() * 15),
|
||||
})),
|
||||
usedInPlans: Math.floor(Math.random() * 3),
|
||||
}))
|
||||
setDevices(mockDevices)
|
||||
}
|
||||
|
||||
fetchDevices()
|
||||
}, [])
|
||||
|
||||
const handleRefresh = () => {
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesSearch =
|
||||
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
device.wechatAccounts.some((account) => account.wechatId.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
const matchesStatus = statusFilter === "all" || device.status === statusFilter
|
||||
const matchesUsage = !excludeUsedDevices || device.usedInPlans === 0
|
||||
return matchesSearch && matchesStatus && matchesUsage
|
||||
})
|
||||
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === paginatedDevices.length) {
|
||||
setSelectedDevices([])
|
||||
} else {
|
||||
setSelectedDevices(paginatedDevices.map((device) => device.id))
|
||||
}
|
||||
onSelect(selectedDevices)
|
||||
}
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
const updatedSelection = selectedDevices.includes(deviceId)
|
||||
? selectedDevices.filter((id) => id !== deviceId)
|
||||
: [...selectedDevices, deviceId]
|
||||
setSelectedDevices(updatedSelection)
|
||||
onSelect(updatedSelection)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/微信号"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={handleSelectAll}>
|
||||
{selectedDevices.length === paginatedDevices.length ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedDevices.map((device) => (
|
||||
<Card key={device.id} className="p-3 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => handleDeviceSelect(device.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.name}</div>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{account.nickname}</span>
|
||||
<span className="text-gray-500">{account.wechatId}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>今日可添加:</span>
|
||||
<span className="font-medium">{account.remainingAdds}</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircle className="h-4 w-4 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>每日最多添加 {account.maxDailyAdds} 个好友</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{account.todayAdded}/{account.maxDailyAdds}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(account.todayAdded / account.maxDailyAdds) * 100} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!excludeUsedDevices && device.usedInPlans > 0 && (
|
||||
<div className="text-sm text-orange-500 mt-2">已用于 {device.usedInPlans} 个计划</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: Math.ceil(filteredDevices.length / devicesPerPage) }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={currentPage === page}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(page)
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 添加 DeviceSelection 命名导出
|
||||
export function DeviceSelection({ onSelect, initialSelectedDevices = [] }: DeviceSelectorProps) {
|
||||
return <DeviceSelector onSelect={onSelect} initialSelectedDevices={initialSelectedDevices} />
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import React, { type ErrorInfo } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Card className="p-6 max-w-md mx-auto mt-8">
|
||||
<h2 className="text-2xl font-bold mb-4 text-red-600">出错了</h2>
|
||||
<p className="text-gray-600 mb-4">抱歉,应用程序遇到了一个错误。</p>
|
||||
<p className="text-sm text-gray-500 mb-4">{this.state.error?.message}</p>
|
||||
<Button onClick={() => this.setState({ hasError: false })}>重试</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Upload, X } from "lucide-react"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
interface FileUploaderProps {
|
||||
onFileUploaded: (file: File) => void
|
||||
acceptedTypes?: string
|
||||
maxSize?: number // in MB
|
||||
}
|
||||
|
||||
export function FileUploader({
|
||||
onFileUploaded,
|
||||
acceptedTypes = ".pdf,.doc,.docx,.jpg,.jpeg,.png",
|
||||
maxSize = 10, // 10MB default
|
||||
}: FileUploaderProps) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file: File): boolean => {
|
||||
// 检查文件类型
|
||||
const fileType = file.name.split(".").pop()?.toLowerCase() || ""
|
||||
const isValidType = acceptedTypes.includes(fileType)
|
||||
|
||||
// 检查文件大小
|
||||
const isValidSize = file.size <= maxSize * 1024 * 1024
|
||||
|
||||
if (!isValidType) {
|
||||
setError(`不支持的文件类型。请上传 ${acceptedTypes} 格式的文件。`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isValidSize) {
|
||||
setError(`文件过大。最大支持 ${maxSize}MB。`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
const file = e.dataTransfer.files[0]
|
||||
handleFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const file = e.target.files[0]
|
||||
handleFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setError(null)
|
||||
|
||||
if (validateFile(file)) {
|
||||
setSelectedFile(file)
|
||||
simulateUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
const simulateUpload = (file: File) => {
|
||||
// 模拟上传进度
|
||||
setUploadProgress(0)
|
||||
const interval = setInterval(() => {
|
||||
setUploadProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval)
|
||||
onFileUploaded(file)
|
||||
return 100
|
||||
}
|
||||
return prev + 10
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleButtonClick = () => {
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const cancelUpload = () => {
|
||||
setSelectedFile(null)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center ${
|
||||
dragActive ? "border-blue-500 bg-blue-50" : "border-gray-300"
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input ref={inputRef} type="file" className="hidden" onChange={handleChange} accept={acceptedTypes} />
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm font-medium text-gray-700">拖拽文件到此处,或</p>
|
||||
<Button variant="outline" onClick={handleButtonClick} className="mt-2">
|
||||
选择文件
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
支持 {acceptedTypes.replace(/\./g, "")} 格式,最大 {maxSize}MB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="font-medium truncate">{selectedFile.name}</div>
|
||||
<Button variant="ghost" size="icon" onClick={cancelUpload} className="h-6 w-6 min-w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
<div className="text-xs text-right mt-1 text-gray-500">{uploadProgress}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mt-2 text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useState } from "react"
|
||||
import AdaptiveLayout from "./AdaptiveLayout"
|
||||
|
||||
// 创建视图模式上下文
|
||||
const ViewModeContext = createContext<{
|
||||
viewMode: "mobile" | "desktop"
|
||||
setViewMode: (mode: "mobile" | "desktop") => void
|
||||
}>({
|
||||
viewMode: "mobile",
|
||||
setViewMode: () => {},
|
||||
})
|
||||
|
||||
// 导出 useViewMode hook
|
||||
export function useViewMode() {
|
||||
const context = useContext(ViewModeContext)
|
||||
if (!context) {
|
||||
throw new Error("useViewMode must be used within ViewModeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function LayoutWrapper({ children }: LayoutWrapperProps) {
|
||||
const [viewMode, setViewMode] = useState<"mobile" | "desktop">("mobile")
|
||||
|
||||
return (
|
||||
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
|
||||
<AdaptiveLayout>{children}</AdaptiveLayout>
|
||||
</ViewModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutWrapper
|
||||
export { LayoutWrapper }
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface LoginErrorHandlerProps {
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function LoginErrorHandler({ onRetry }: LoginErrorHandlerProps) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
// 监听网络状态
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
toast({
|
||||
title: "网络已恢复",
|
||||
description: "您可以继续登录",
|
||||
})
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "网络连接已断开",
|
||||
description: "请检查您的网络连接后重试",
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("online", handleOnline)
|
||||
window.addEventListener("offline", handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline)
|
||||
window.removeEventListener("offline", handleOffline)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// 处理401错误(未授权)
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = (event: CustomEvent) => {
|
||||
if (event.detail === "UNAUTHORIZED") {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "登录已过期",
|
||||
description: "请重新登录",
|
||||
})
|
||||
|
||||
// 清除本地存储的登录信息
|
||||
localStorage.removeItem("token")
|
||||
localStorage.removeItem("user")
|
||||
|
||||
// 重定向到登录页
|
||||
router.push("/login")
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth-error", handleUnauthorized as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("auth-error", handleUnauthorized as EventListener)
|
||||
}
|
||||
}, [toast, router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
"use client"
|
||||
|
||||
// 由于我们不再使用PC自适应,这个组件可以简化为一个空组件
|
||||
export default function SideNav({ className }: { className?: string }) {
|
||||
return null
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface SpeechToTextProcessorProps {
|
||||
audioUrl: string
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
onQuestionExtracted: (question: string) => void
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function SpeechToTextProcessor({
|
||||
audioUrl,
|
||||
onTranscriptReady,
|
||||
onQuestionExtracted,
|
||||
enabled = true,
|
||||
}: SpeechToTextProcessorProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !audioUrl) return
|
||||
|
||||
const processAudio = async () => {
|
||||
try {
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
|
||||
// 模拟API调用延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// 模拟转录结果
|
||||
const mockTranscript = `
|
||||
客服: 您好,这里是XX公司客服,请问有什么可以帮到您?
|
||||
客户: 请问贵公司的产品有什么特点?
|
||||
客服: 我们的产品主要有以下几个特点:首先,质量非常可靠;其次,价格比较有竞争力;第三,售后服务非常完善。
|
||||
客户: 那你们的价格是怎么样的?
|
||||
客服: 我们有多种套餐可以选择,基础版每月只需99元,高级版每月299元,具体可以根据您的需求来选择。
|
||||
客户: 好的,我了解了,谢谢。
|
||||
客服: 不客气,如果您有兴趣,我可以添加您的微信,给您发送更详细的产品资料。
|
||||
客户: 可以的,谢谢。
|
||||
客服: 好的,稍后我会添加您为好友,再次感谢您的咨询。
|
||||
`
|
||||
onTranscriptReady(mockTranscript)
|
||||
|
||||
// 提取首句问题
|
||||
const questionMatch = mockTranscript.match(/客户: (.*?)\n/)
|
||||
if (questionMatch && questionMatch[1]) {
|
||||
onQuestionExtracted(questionMatch[1])
|
||||
} else {
|
||||
onQuestionExtracted("未识别到有效问题")
|
||||
}
|
||||
|
||||
setIsProcessing(false)
|
||||
} catch (err) {
|
||||
setError("处理音频时出错")
|
||||
setIsProcessing(false)
|
||||
toast({
|
||||
title: "处理失败",
|
||||
description: "语音转文字处理失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
processAudio()
|
||||
}, [audioUrl, enabled, onTranscriptReady, onQuestionExtracted])
|
||||
|
||||
return null // 这是一个功能性组件,不渲染任何UI
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
interface TrafficTeam {
|
||||
id: string
|
||||
name: string
|
||||
commission: number
|
||||
}
|
||||
|
||||
interface TrafficTeamSettingsProps {
|
||||
formData?: any
|
||||
onChange: (data: any) => void
|
||||
}
|
||||
|
||||
export function TrafficTeamSettings({ formData = {}, onChange }: TrafficTeamSettingsProps) {
|
||||
// Initialize teams with an empty array if formData.trafficTeams is undefined
|
||||
const [teams, setTeams] = useState<TrafficTeam[]>([])
|
||||
const [isAddTeamOpen, setIsAddTeamOpen] = useState(false)
|
||||
const [editingTeam, setEditingTeam] = useState<TrafficTeam | null>(null)
|
||||
const [newTeam, setNewTeam] = useState<Partial<TrafficTeam>>({
|
||||
name: "",
|
||||
commission: 0,
|
||||
})
|
||||
|
||||
// Initialize teams state safely
|
||||
useEffect(() => {
|
||||
if (formData && Array.isArray(formData.trafficTeams)) {
|
||||
setTeams(formData.trafficTeams)
|
||||
} else {
|
||||
// If formData.trafficTeams is undefined or not an array, initialize with empty array
|
||||
// Also update the parent formData to include the empty trafficTeams array
|
||||
setTeams([])
|
||||
onChange({ ...formData, trafficTeams: [] })
|
||||
}
|
||||
}, [formData])
|
||||
|
||||
const handleAddTeam = () => {
|
||||
if (!newTeam.name) return
|
||||
|
||||
const updatedTeams = [...teams]
|
||||
|
||||
if (editingTeam) {
|
||||
const index = updatedTeams.findIndex((team) => team.id === editingTeam.id)
|
||||
if (index !== -1) {
|
||||
updatedTeams[index] = {
|
||||
...updatedTeams[index],
|
||||
name: newTeam.name || updatedTeams[index].name,
|
||||
commission: newTeam.commission !== undefined ? newTeam.commission : updatedTeams[index].commission,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedTeams.push({
|
||||
id: Date.now().toString(),
|
||||
name: newTeam.name,
|
||||
commission: newTeam.commission || 0,
|
||||
})
|
||||
}
|
||||
|
||||
setTeams(updatedTeams)
|
||||
setIsAddTeamOpen(false)
|
||||
setNewTeam({ name: "", commission: 0 })
|
||||
setEditingTeam(null)
|
||||
|
||||
// Ensure we're creating a new object for formData to trigger proper updates
|
||||
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
|
||||
onChange(updatedFormData)
|
||||
}
|
||||
|
||||
const handleEditTeam = (team: TrafficTeam) => {
|
||||
setEditingTeam(team)
|
||||
setNewTeam(team)
|
||||
setIsAddTeamOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteTeam = (teamId: string) => {
|
||||
const updatedTeams = teams.filter((team) => team.id !== teamId)
|
||||
setTeams(updatedTeams)
|
||||
|
||||
// Ensure we're creating a new object for formData to trigger proper updates
|
||||
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
|
||||
onChange(updatedFormData)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">打粉团队设置</h2>
|
||||
<Button onClick={() => setIsAddTeamOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加团队
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>团队名称</TableHead>
|
||||
<TableHead>佣金比例</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teams.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8 text-gray-500">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<TableRow key={team.id}>
|
||||
<TableCell>{team.name}</TableCell>
|
||||
<TableCell>{team.commission}%</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditTeam(team)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteTeam(team.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddTeamOpen} onOpenChange={setIsAddTeamOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTeam ? "编辑团队" : "添加团队"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>团队名称</Label>
|
||||
<Input
|
||||
value={newTeam.name}
|
||||
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
|
||||
placeholder="请输入团队名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>佣金比例 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={newTeam.commission}
|
||||
onChange={(e) => setNewTeam({ ...newTeam, commission: Number(e.target.value) })}
|
||||
placeholder="请输入佣金比例"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddTeamOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddTeam}>{editingTeam ? "保存" : "添加"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Play, Video } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { getPageTutorials } from "@/lib/tutorials"
|
||||
|
||||
export function VideoTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const tutorials = getPageTutorials(pathname)
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
className="fixed bottom-20 right-4 h-12 w-12 rounded-full shadow-lg bg-white hover:bg-gray-50 border z-50"
|
||||
onClick={handleOpenDialog}
|
||||
>
|
||||
<Video className="h-5 w-5 text-gray-600" />
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-[640px] p-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="p-4 border-b">视频教程</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
{tutorials.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{tutorials.map((tutorial) => (
|
||||
<div key={tutorial.id} className="flex items-center space-x-4">
|
||||
<div className="w-24 h-16 bg-gray-200 rounded-lg relative overflow-hidden">
|
||||
<img
|
||||
src={tutorial.thumbnailUrl || "/placeholder.svg"}
|
||||
alt={tutorial.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{tutorial.title}</h3>
|
||||
<p className="text-sm text-gray-500">{tutorial.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">暂无该页面的教程视频</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
interface VoiceRecognitionProps {
|
||||
onResult: (text: string) => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export function VoiceRecognition({ onResult, onStop }: VoiceRecognitionProps) {
|
||||
const [isListening, setIsListening] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟语音识别
|
||||
const timer = setTimeout(() => {
|
||||
const mockResults = [
|
||||
"你好,我想了解一下私域运营的基本策略",
|
||||
"请帮我分析一下最近的销售数据",
|
||||
"我需要一份客户画像分析报告",
|
||||
"如何提高朋友圈内容的互动率?",
|
||||
"帮我生成一个营销方案",
|
||||
]
|
||||
|
||||
const randomResult = mockResults[Math.floor(Math.random() * mockResults.length)]
|
||||
onResult(randomResult)
|
||||
setIsListening(false)
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [onResult])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isListening) {
|
||||
onStop()
|
||||
}
|
||||
}, [isListening, onStop])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
<div className="absolute inset-0 bg-blue-100 rounded-full animate-ping opacity-25"></div>
|
||||
<div className="relative bg-blue-500 rounded-full w-20 h-20 flex items-center justify-center">
|
||||
<div className="w-4 h-16 bg-white rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">正在聆听...</h3>
|
||||
<p className="text-gray-500 text-center">请说出您的问题或指令,语音识别将自动结束</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 transition-colors"
|
||||
onClick={onStop}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Area, AreaChart, Legend } from "recharts"
|
||||
|
||||
interface AcquisitionPlanChartProps {
|
||||
data: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
|
||||
const [chartData, setChartData] = useState<any[]>([])
|
||||
|
||||
// 生成更真实的数据
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) return
|
||||
|
||||
// 使用获客计划中的获客数和添加数作为指标,模拟近7天数据
|
||||
const enhancedData = data.map((item) => {
|
||||
// 添加数通常是获客数的一定比例,这里使用70%-90%的随机比例
|
||||
const addRate = 0.7 + Math.random() * 0.2
|
||||
const addedCount = Math.round(item.customers * addRate)
|
||||
|
||||
return {
|
||||
date: item.date,
|
||||
获客数: item.customers,
|
||||
添加数: addedCount,
|
||||
}
|
||||
})
|
||||
|
||||
setChartData(enhancedData)
|
||||
}, [data])
|
||||
|
||||
// 如果没有数据,显示空状态
|
||||
if (!data || data.length === 0 || chartData.length === 0) {
|
||||
return <div className="h-[180px] flex items-center justify-center text-gray-400">暂无数据</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorCustomers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorAdded" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} width={30} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||
border: "none",
|
||||
padding: "8px",
|
||||
}}
|
||||
labelStyle={{ fontWeight: "bold", marginBottom: "4px" }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={36}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value) => <span style={{ color: "#6b7280", fontSize: "12px" }}>{value}</span>}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="获客数"
|
||||
name="获客数"
|
||||
stroke="#3b82f6"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCustomers)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 2 }}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="添加数"
|
||||
name="添加数"
|
||||
stroke="#8b5cf6"
|
||||
fillOpacity={0.5}
|
||||
fill="url(#colorAdded)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, strokeWidth: 2 }}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface DailyAcquisitionData {
|
||||
date: string
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
|
||||
interface DailyAcquisitionChartProps {
|
||||
data: DailyAcquisitionData[]
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionChartProps) {
|
||||
return (
|
||||
<div className="w-full" style={{ height: `${height}px` }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="acquired"
|
||||
name="获客数量"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6" }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="added"
|
||||
name="添加成功"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#10b981" }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
|
||||
// 模拟数据
|
||||
const weeklyData = [
|
||||
{ day: "周一", 获客数: 12, 添加好友: 8 },
|
||||
{ day: "周二", 获客数: 18, 添加好友: 12 },
|
||||
{ day: "周三", 获客数: 15, 添加好友: 10 },
|
||||
{ day: "周四", 获客数: 25, 添加好友: 18 },
|
||||
{ day: "周五", 获客数: 30, 添加好友: 22 },
|
||||
{ day: "周六", 获客数: 18, 添加好友: 14 },
|
||||
{ day: "周日", 获客数: 15, 添加好友: 11 },
|
||||
]
|
||||
|
||||
const monthlyData = [
|
||||
{ day: "1月", 获客数: 120, 添加好友: 85 },
|
||||
{ day: "2月", 获客数: 180, 添加好友: 130 },
|
||||
{ day: "3月", 获客数: 150, 添加好友: 110 },
|
||||
{ day: "4月", 获客数: 250, 添加好友: 180 },
|
||||
{ day: "5月", 获客数: 300, 添加好友: 220 },
|
||||
{ day: "6月", 获客数: 280, 添加好友: 210 },
|
||||
]
|
||||
|
||||
export function DeviceTreeChart() {
|
||||
const [period, setPeriod] = useState("week")
|
||||
|
||||
const data = period === "week" ? weeklyData : monthlyData
|
||||
|
||||
return (
|
||||
<Card className="w-full mt-4">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">获客趋势</CardTitle>
|
||||
<Tabs defaultValue="week" value={period} onValueChange={setPeriod} className="h-9">
|
||||
<TabsList className="grid w-[180px] grid-cols-2">
|
||||
<TabsTrigger value="week">本周</TabsTrigger>
|
||||
<TabsTrigger value="month">本月</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="获客数"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="添加好友"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronUp, MoreVertical, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { DailyAcquisitionChart } from "./DailyAcquisitionChart"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
dailyData?: { date: string; acquired: number; added: number }[]
|
||||
}
|
||||
|
||||
interface ExpandableAcquisitionCardProps {
|
||||
task: Task
|
||||
channel: string
|
||||
onCopy: (taskId: string) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onOpenSettings?: (taskId: string) => void
|
||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
|
||||
}
|
||||
|
||||
// 计算通过率的工具函数
|
||||
function calculatePassRate(acquired: number, added: number): number {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
export function ExpandableAcquisitionCard({
|
||||
task,
|
||||
channel,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onStatusChange,
|
||||
}: ExpandableAcquisitionCardProps) {
|
||||
const router = useRouter()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
|
||||
const passRate = calculatePassRate(acquiredCount, addedCount)
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
router.push(`/scenarios/${channel}/edit/${taskId}`)
|
||||
}
|
||||
|
||||
const toggleTaskStatus = () => {
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Card className="p-6 hover:shadow-lg transition-all bg-white/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Badge
|
||||
variant={task.status === "running" ? "success" : "secondary"}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={toggleTaskStatus}
|
||||
>
|
||||
{task.status === "running" ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => handleEdit(task.id)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onCopy(task.id)}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</DropdownMenuItem>
|
||||
{onOpenSettings && (
|
||||
<DropdownMenuItem onClick={() => onOpenSettings(task.id)}>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
计划接口
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onDelete(task.id)} className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<a href={`/scenarios/${channel}/devices`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/acquired`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/added`}>
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="h-48 bg-white rounded-lg p-4 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={task.trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="customers"
|
||||
name="获客数"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#3b82f6" }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>上次执行:{task.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>下次执行:{task.nextExecutionTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{expanded && task.dailyData && (
|
||||
<div className="mt-4 bg-white p-6 rounded-lg shadow-sm">
|
||||
<h4 className="text-lg font-medium mb-4">每日获客数据</h4>
|
||||
<div className="h-64">
|
||||
<DailyAcquisitionChart data={task.dailyData} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)} className="text-gray-500">
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
展开
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { TrafficTeamSettings } from "@/app/components/TrafficTeamSettings"
|
||||
|
||||
interface NewAcquisitionPlanFormProps {
|
||||
onSubmit: (data: any) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPlanFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
trafficTeams: [], // Initialize trafficTeams as an empty array
|
||||
})
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
|
||||
const handleChange = (data: any) => {
|
||||
setFormData(data)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="steps-container">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="step-indicators flex justify-between mb-8">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`flex flex-col items-center ${currentStep >= step ? "text-primary" : "text-gray-400"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full mb-2 ${
|
||||
currentStep >= step ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{step}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{step === 1 && "基础设置"}
|
||||
{step === 2 && "好友设置"}
|
||||
{step === 3 && "消息设置"}
|
||||
{step === 4 && "流量标签"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">基本信息</h2>
|
||||
<div className="space-y-2">
|
||||
<Label>计划名称</Label>
|
||||
<Input
|
||||
placeholder="输入计划名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>计划描述</Label>
|
||||
<Input
|
||||
placeholder="输入计划描述"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<TrafficTeamSettings formData={formData} onChange={handleChange} />
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">创建计划</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import type React from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { MoreHorizontal, Copy, Pencil, Trash2, Link, Play, Pause } from "lucide-react"
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
status: "running" | "paused" | "completed"
|
||||
stats: {
|
||||
devices: number
|
||||
acquired: number
|
||||
added: number
|
||||
}
|
||||
lastUpdated: string
|
||||
executionTime: string
|
||||
nextExecutionTime: string
|
||||
trend: { date: string; customers: number }[]
|
||||
}
|
||||
|
||||
interface ScenarioAcquisitionCardProps {
|
||||
task: Task
|
||||
channel: string
|
||||
onEdit: (taskId: string) => void
|
||||
onCopy: (taskId: string) => void
|
||||
onDelete: (taskId: string) => void
|
||||
onOpenSettings?: (taskId: string) => void
|
||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
|
||||
}
|
||||
|
||||
// 计算通过率的工具函数
|
||||
function calculatePassRate(acquired: number, added: number): number {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
export function ScenarioAcquisitionCard({
|
||||
task,
|
||||
channel,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onStatusChange,
|
||||
}: ScenarioAcquisitionCardProps) {
|
||||
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
|
||||
const passRate = calculatePassRate(acquiredCount, addedCount)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleStatusChange = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onEdit(task.id)
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onCopy(task.id)
|
||||
}
|
||||
|
||||
const handleOpenSettings = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
if (onOpenSettings) {
|
||||
onOpenSettings(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(false)
|
||||
onDelete(task.id)
|
||||
}
|
||||
|
||||
const toggleMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setMenuOpen(!menuOpen)
|
||||
}
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
||||
<Badge
|
||||
variant={task.status === "running" ? "default" : "secondary"}
|
||||
className={`cursor-pointer hover:opacity-80 ${
|
||||
task.status === "running"
|
||||
? "bg-green-500 hover:bg-green-600 text-white"
|
||||
: "bg-gray-500 hover:bg-gray-600 text-white"
|
||||
}`}
|
||||
onClick={handleStatusChange}
|
||||
>
|
||||
{task.status === "running" ? (
|
||||
<>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
进行中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="h-3 w-3 mr-1" />
|
||||
已暂停
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative z-20" ref={menuRef}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
计划接口
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<a href={`/scenarios/${channel}/devices`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/acquired`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<a href={`/scenarios/${channel}/added`} className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 border-t pt-4">
|
||||
<div>上次执行:{task.lastUpdated}</div>
|
||||
<div>下次执行:{task.nextExecutionTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
|
||||
export function AuthCheck({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
// 排除不需要登录的页面
|
||||
const publicPaths = ['/login', '/register', '/forgot-password']
|
||||
if (publicPaths.includes(pathname)) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
// 如果没有token,重定向到登录页面,并携带当前页面URL作为回调
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
||||
return
|
||||
}
|
||||
}, [router, pathname])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { QrCode, Upload } from "lucide-react"
|
||||
import { DeviceType, DeviceCategory } from "@/types/device"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface AddDeviceDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDeviceAdded?: (device: any) => void
|
||||
}
|
||||
|
||||
export function AddDeviceDialog({ open, onOpenChange, onDeviceAdded }: AddDeviceDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState("qr")
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
imei: "",
|
||||
type: DeviceType.ANDROID,
|
||||
category: DeviceCategory.ACQUISITION,
|
||||
model: "",
|
||||
remark: "",
|
||||
tags: [] as string[],
|
||||
location: "",
|
||||
operator: "",
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.imei) {
|
||||
toast({
|
||||
title: "请填写必填信息",
|
||||
description: "设备名称和IMEI是必填项",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
const newDevice = {
|
||||
id: `device-${Date.now()}`,
|
||||
...formData,
|
||||
status: "offline",
|
||||
battery: 100,
|
||||
friendCount: 0,
|
||||
todayAdded: 0,
|
||||
lastActive: new Date().toLocaleString(),
|
||||
addFriendStatus: "normal",
|
||||
totalTasks: 0,
|
||||
completedTasks: 0,
|
||||
activePlans: [],
|
||||
planNames: [],
|
||||
}
|
||||
|
||||
onDeviceAdded?.(newDevice)
|
||||
toast({
|
||||
title: "设备添加成功",
|
||||
description: `设备 ${formData.name} 已成功添加`,
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
setFormData({
|
||||
name: "",
|
||||
imei: "",
|
||||
type: DeviceType.ANDROID,
|
||||
category: DeviceCategory.ACQUISITION,
|
||||
model: "",
|
||||
remark: "",
|
||||
tags: [],
|
||||
location: "",
|
||||
operator: "",
|
||||
})
|
||||
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "添加失败",
|
||||
description: "设备添加失败,请重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="qr">扫码添加</TabsTrigger>
|
||||
<TabsTrigger value="manual">手动添加</TabsTrigger>
|
||||
<TabsTrigger value="batch">批量导入</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="qr" className="space-y-4">
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<div className="w-48 h-48 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<QrCode className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center">请使用设备扫描二维码进行添加</p>
|
||||
<Input placeholder="或输入设备ID" className="max-w-[200px]" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">设备名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="输入设备名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="imei">IMEI *</Label>
|
||||
<Input
|
||||
id="imei"
|
||||
value={formData.imei}
|
||||
onChange={(e) => setFormData({ ...formData, imei: e.target.value })}
|
||||
placeholder="输入IMEI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>设备类型</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) => setFormData({ ...formData, type: value as DeviceType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DeviceType.ANDROID}>Android</SelectItem>
|
||||
<SelectItem value={DeviceType.IOS}>iOS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>设备分类</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) => setFormData({ ...formData, category: value as DeviceCategory })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DeviceCategory.ACQUISITION}>获客设备</SelectItem>
|
||||
<SelectItem value={DeviceCategory.MAINTENANCE}>维护设备</SelectItem>
|
||||
<SelectItem value={DeviceCategory.TESTING}>测试设备</SelectItem>
|
||||
<SelectItem value={DeviceCategory.BACKUP}>备用设备</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">设备型号</Label>
|
||||
<Input
|
||||
id="model"
|
||||
value={formData.model}
|
||||
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
|
||||
placeholder="输入设备型号"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">设备位置</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="输入设备位置"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Textarea
|
||||
id="remark"
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
placeholder="输入备注信息"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batch" className="space-y-4">
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<div className="w-48 h-32 bg-gray-100 rounded-lg flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||
<Upload className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
拖拽Excel文件到此处或点击上传
|
||||
<br />
|
||||
<a href="#" className="text-blue-500 hover:underline">
|
||||
下载模板文件
|
||||
</a>
|
||||
</p>
|
||||
<Button variant="outline">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || activeTab !== "manual"}>
|
||||
{loading ? "添加中..." : "确认添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/components/ui/card"
|
||||
import { Badge } from "@/app/components/ui/badge"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { Skeleton } from "@/app/components/ui/skeleton"
|
||||
|
||||
interface CardAction {
|
||||
label: string
|
||||
onClick: (e?: React.MouseEvent) => void
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
interface CardItem {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
tags?: string[]
|
||||
status?: {
|
||||
label: string
|
||||
variant: "default" | "secondary" | "destructive" | "outline" | "success"
|
||||
}
|
||||
metadata?: Array<{
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
}>
|
||||
onClick?: () => void
|
||||
actions?: CardAction[]
|
||||
}
|
||||
|
||||
interface CardGridProps {
|
||||
items: CardItem[]
|
||||
loading?: boolean
|
||||
columns?: 1 | 2 | 3 | 4
|
||||
emptyText?: string
|
||||
emptyAction?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export function CardGrid({ items, loading = false, columns = 3, emptyText = "暂无数据", emptyAction }: CardGridProps) {
|
||||
const gridCols = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-1 md:grid-cols-2",
|
||||
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`grid ${gridCols[columns]} gap-6`}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-32 w-full mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground mb-4">{emptyText}</p>
|
||||
{emptyAction && <Button onClick={emptyAction.onClick}>{emptyAction.label}</Button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols[columns]} gap-6`}>
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`overflow-hidden transition-all hover:shadow-md ${item.onClick ? "cursor-pointer" : ""}`}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.image && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img src={item.image || "/placeholder.svg"} alt={item.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<CardTitle className="text-lg">{item.title}</CardTitle>
|
||||
{item.description && <CardDescription>{item.description}</CardDescription>}
|
||||
</div>
|
||||
{item.status && <Badge variant={item.status.variant as any}>{item.status.label}</Badge>}
|
||||
</div>
|
||||
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-2">
|
||||
{item.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{item.metadata && item.metadata.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{item.metadata.map((meta, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-sm">
|
||||
{meta.icon}
|
||||
<span className="text-muted-foreground">{meta.label}:</span>
|
||||
<span className="font-medium">{meta.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.actions && item.actions.length > 0 && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
{item.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || "outline"}
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/components/ui/card"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/app/components/ui/chart"
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts"
|
||||
|
||||
export interface ChartData {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
[key: string]: {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BaseChartProps {
|
||||
title?: string
|
||||
description?: string
|
||||
data: ChartData[]
|
||||
config: ChartConfig
|
||||
className?: string
|
||||
height?: number
|
||||
}
|
||||
|
||||
// 折线图组件
|
||||
export function LineChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className={`h-[${height}px]`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
{Object.entries(config).map(([key, { color }]) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: color, strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 面积图组件
|
||||
export function AreaChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className={`h-[${height}px]`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
{Object.entries(config).map(([key, { color }]) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stackId="1"
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.6}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 柱状图组件
|
||||
export function BarChartComponent({ title, description, data, config, className, height = 300 }: BaseChartProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className={`h-[${height}px]`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
{Object.entries(config).map(([key, { color }]) => (
|
||||
<Bar key={key} dataKey={key} fill={color} radius={[4, 4, 0, 0]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 饼图组件
|
||||
export function PieChartComponent({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
config,
|
||||
className,
|
||||
height = 300,
|
||||
}: BaseChartProps & { dataKey?: string; nameKey?: string }) {
|
||||
const COLORS = Object.values(config).map((item) => item.color)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className={`h-[${height}px]`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 组合图表组件
|
||||
export function ComboChartComponent({
|
||||
title,
|
||||
description,
|
||||
data,
|
||||
config,
|
||||
className,
|
||||
height = 300,
|
||||
lineKeys = [],
|
||||
barKeys = [],
|
||||
}: BaseChartProps & { lineKeys?: string[]; barKeys?: string[] }) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className={`h-[${height}px]`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Legend />
|
||||
{barKeys.map((key) => (
|
||||
<Bar key={key} dataKey={key} fill={config[key]?.color} radius={[4, 4, 0, 0]} />
|
||||
))}
|
||||
{lineKeys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={config[key]?.color}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: config[key]?.color, strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Search, Plus, Trash2 } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
export interface ContentTarget {
|
||||
id: string
|
||||
avatar: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
type?: string
|
||||
count?: number
|
||||
targets?: ContentTarget[]
|
||||
}
|
||||
|
||||
export interface ContentSelectorProps {
|
||||
/** 已选择的内容库 */
|
||||
selectedLibraries: ContentLibrary[]
|
||||
/** 内容库变更回调 */
|
||||
onLibrariesChange: (libraries: ContentLibrary[]) => void
|
||||
/** 上一步回调 */
|
||||
onPrevious?: () => void
|
||||
/** 下一步回调 */
|
||||
onNext?: () => void
|
||||
/** 保存回调 */
|
||||
onSave?: () => void
|
||||
/** 取消回调 */
|
||||
onCancel?: () => void
|
||||
/** 自定义内容库列表,不传则使用模拟数据 */
|
||||
contentLibraries?: ContentLibrary[]
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 是否使用卡片包装 */
|
||||
withCard?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的内容选择器组件
|
||||
*/
|
||||
export function ContentSelector({
|
||||
selectedLibraries = [],
|
||||
onLibrariesChange,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSave,
|
||||
onCancel,
|
||||
contentLibraries: propContentLibraries,
|
||||
className,
|
||||
withCard = true,
|
||||
}: ContentSelectorProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
|
||||
// 模拟内容库数据
|
||||
const defaultContentLibraries: ContentLibrary[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "卡若朋友圈",
|
||||
type: "朋友圈",
|
||||
count: 307,
|
||||
targets: [{ id: "t1", avatar: "/placeholder.svg?height=40&width=40&query=avatar1" }],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "业务推广内容",
|
||||
type: "朋友圈",
|
||||
count: 156,
|
||||
targets: [
|
||||
{ id: "t2", avatar: "/placeholder.svg?height=40&width=40&query=avatar2" },
|
||||
{ id: "t3", avatar: "/placeholder.svg?height=40&width=40&query=avatar3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "产品介绍",
|
||||
type: "群发",
|
||||
count: 42,
|
||||
targets: [
|
||||
{ id: "t4", avatar: "/placeholder.svg?height=40&width=40&query=avatar4" },
|
||||
{ id: "t5", avatar: "/placeholder.svg?height=40&width=40&query=avatar5" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const contentLibraries = propContentLibraries || defaultContentLibraries
|
||||
|
||||
const handleAddLibrary = (library: ContentLibrary) => {
|
||||
if (!selectedLibraries.some((l) => l.id === library.id)) {
|
||||
onLibrariesChange([...selectedLibraries, library])
|
||||
}
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleRemoveLibrary = (libraryId: string) => {
|
||||
onLibrariesChange(selectedLibraries.filter((library) => library.id !== libraryId))
|
||||
}
|
||||
|
||||
const filteredLibraries = contentLibraries.filter((library) =>
|
||||
library.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
|
||||
const ContentSelectorContent = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
<span className="font-medium">选择内容库:</span>
|
||||
</div>
|
||||
<Button variant="default" size="sm" onClick={() => setIsDialogOpen(true)} className="flex items-center">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
选择内容库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{selectedLibraries.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">序号</TableHead>
|
||||
<TableHead>内容库名称</TableHead>
|
||||
{contentLibraries[0]?.type && <TableHead>类型</TableHead>}
|
||||
{contentLibraries[0]?.count && <TableHead>内容数量</TableHead>}
|
||||
<TableHead>采集对象</TableHead>
|
||||
<TableHead className="w-20">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedLibraries.map((library, index) => (
|
||||
<TableRow key={library.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{library.name}</TableCell>
|
||||
{contentLibraries[0]?.type && <TableCell>{library.type}</TableCell>}
|
||||
{contentLibraries[0]?.count && <TableCell>{library.count}</TableCell>}
|
||||
<TableCell>
|
||||
<div className="flex -space-x-2 flex-wrap">
|
||||
{library.targets?.map((target) => (
|
||||
<div key={target.id} className="w-10 h-10 rounded-md overflow-hidden border-2 border-white">
|
||||
<img
|
||||
src={target.avatar || "/placeholder.svg"}
|
||||
alt={target.name || "Target"}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveLibrary(library.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="border rounded-md p-8 text-center text-gray-500">请点击"选择内容库"按钮添加内容库</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(onPrevious || onNext || onSave || onCancel) && (
|
||||
<div className="flex space-x-2 justify-end mt-4">
|
||||
{onPrevious && (
|
||||
<Button type="button" variant="outline" onClick={onPrevious}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button type="button" onClick={onNext} disabled={selectedLibraries.length === 0}>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
{onSave && (
|
||||
<Button type="button" variant="outline" onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{withCard ? (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<ContentSelectorContent />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<ContentSelectorContent />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择内容库</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索内容库名称"
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">序号</TableHead>
|
||||
<TableHead>内容库名称</TableHead>
|
||||
{contentLibraries[0]?.type && <TableHead>类型</TableHead>}
|
||||
{contentLibraries[0]?.count && <TableHead>内容数量</TableHead>}
|
||||
<TableHead>采集对象</TableHead>
|
||||
<TableHead className="w-20">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredLibraries.map((library, index) => (
|
||||
<TableRow key={library.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{library.name}</TableCell>
|
||||
{contentLibraries[0]?.type && <TableCell>{library.type}</TableCell>}
|
||||
{contentLibraries[0]?.count && <TableCell>{library.count}</TableCell>}
|
||||
<TableCell>
|
||||
<div className="flex -space-x-2 flex-wrap">
|
||||
{library.targets?.map((target) => (
|
||||
<div key={target.id} className="w-10 h-10 rounded-md overflow-hidden border-2 border-white">
|
||||
<img
|
||||
src={target.avatar || "/placeholder.svg"}
|
||||
alt={target.name || "Target"}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAddLibrary(library)}
|
||||
disabled={selectedLibraries.some((l) => l.id === library.id)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{selectedLibraries.some((l) => l.id === library.id) ? "已选择" : "选择"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Pagination } from "@/components/ui/pagination"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Search, RefreshCw, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
// 列定义接口
|
||||
export interface Column<T> {
|
||||
id: string
|
||||
header: string | ReactNode
|
||||
accessorKey?: keyof T
|
||||
cell?: (item: T) => ReactNode
|
||||
sortable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// DataTable 属性接口
|
||||
export interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
pageSize?: number
|
||||
loading?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
emptyMessage?: string
|
||||
withCard?: boolean
|
||||
showSearch?: boolean
|
||||
showRefresh?: boolean
|
||||
showSelection?: boolean
|
||||
onRowClick?: (item: T) => void
|
||||
onSelectionChange?: (selectedItems: T[]) => void
|
||||
onRefresh?: () => void
|
||||
onSearch?: (query: string) => void
|
||||
onSort?: (columnId: string, direction: "asc" | "desc") => void
|
||||
rowActions?: {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: (item: T) => void
|
||||
className?: string
|
||||
}[]
|
||||
batchActions?: {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: (selectedItems: T[]) => void
|
||||
className?: string
|
||||
}[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 经过性能优化的数据表格组件
|
||||
* - 使用 React.memo 避免不必要的重渲染
|
||||
* - 使用 useMemo 缓存计算结果
|
||||
* - 使用 useCallback 稳定化事件处理器
|
||||
*/
|
||||
function DataTableComponent<T extends { id: string | number }>({
|
||||
data,
|
||||
columns,
|
||||
pageSize = 10,
|
||||
loading = false,
|
||||
title,
|
||||
description,
|
||||
emptyMessage = "暂无数据",
|
||||
withCard = true,
|
||||
showSearch = true,
|
||||
showRefresh = true,
|
||||
showSelection = false,
|
||||
onRowClick,
|
||||
onSelectionChange,
|
||||
onRefresh,
|
||||
onSearch,
|
||||
onSort,
|
||||
rowActions,
|
||||
batchActions,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedItems, setSelectedItems] = useState<T[]>([])
|
||||
const [sortConfig, setSortConfig] = useState<{ columnId: string; direction: "asc" | "desc" } | null>(null)
|
||||
|
||||
// 当外部数据变化时,重置分页和选择
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
setSelectedItems([])
|
||||
}, [data])
|
||||
|
||||
// 使用 useMemo 缓存过滤和排序后的数据
|
||||
const filteredData = useMemo(() => {
|
||||
let filtered = [...data]
|
||||
if (searchQuery && !onSearch) {
|
||||
filtered = data.filter((item) =>
|
||||
columns.some((col) => {
|
||||
if (!col.accessorKey) return false
|
||||
const value = item[col.accessorKey]
|
||||
return String(value).toLowerCase().includes(searchQuery.toLowerCase())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (sortConfig && !onSort) {
|
||||
const { columnId, direction } = sortConfig
|
||||
const column = columns.find((c) => c.id === columnId)
|
||||
if (column?.accessorKey) {
|
||||
filtered.sort((a, b) => {
|
||||
const valA = a[column.accessorKey!]
|
||||
const valB = b[column.accessorKey!]
|
||||
if (valA < valB) return direction === "asc" ? -1 : 1
|
||||
if (valA > valB) return direction === "asc" ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}, [data, searchQuery, sortConfig, columns, onSearch, onSort])
|
||||
|
||||
// 使用 useMemo 缓存当前页的数据
|
||||
const currentPageItems = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
return filteredData.slice(startIndex, startIndex + pageSize)
|
||||
}, [filteredData, currentPage, pageSize])
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize)
|
||||
const isAllCurrentPageSelected =
|
||||
currentPageItems.length > 0 && currentPageItems.every((item) => selectedItems.some((s) => s.id === item.id))
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = useCallback(
|
||||
(query: string) => {
|
||||
setSearchQuery(query)
|
||||
setCurrentPage(1)
|
||||
if (onSearch) {
|
||||
onSearch(query)
|
||||
}
|
||||
},
|
||||
[onSearch],
|
||||
)
|
||||
|
||||
// 排序处理
|
||||
const handleSort = useCallback(
|
||||
(columnId: string) => {
|
||||
const newDirection = sortConfig?.columnId === columnId && sortConfig.direction === "asc" ? "desc" : "asc"
|
||||
setSortConfig({ columnId, direction: newDirection })
|
||||
if (onSort) {
|
||||
onSort(columnId, newDirection)
|
||||
}
|
||||
},
|
||||
[sortConfig, onSort],
|
||||
)
|
||||
|
||||
// 刷新处理
|
||||
const handleRefresh = useCallback(() => {
|
||||
setSearchQuery("")
|
||||
setCurrentPage(1)
|
||||
setSortConfig(null)
|
||||
setSelectedItems([])
|
||||
onRefresh?.()
|
||||
}, [onRefresh])
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = useCallback(
|
||||
(checked: boolean) => {
|
||||
const newSelectedItems = checked ? currentPageItems : []
|
||||
setSelectedItems(newSelectedItems)
|
||||
onSelectionChange?.(newSelectedItems)
|
||||
},
|
||||
[currentPageItems, onSelectionChange],
|
||||
)
|
||||
|
||||
// 单行选择
|
||||
const handleSelectItem = useCallback(
|
||||
(item: T, checked: boolean) => {
|
||||
const newSelectedItems = checked
|
||||
? [...selectedItems, item]
|
||||
: selectedItems.filter((selected) => selected.id !== item.id)
|
||||
setSelectedItems(newSelectedItems)
|
||||
onSelectionChange?.(newSelectedItems)
|
||||
},
|
||||
[selectedItems, onSelectionChange],
|
||||
)
|
||||
|
||||
const TableContent = (
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-2">
|
||||
<div className="flex flex-1 gap-2">
|
||||
{showSearch && (
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showRefresh && (
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{batchActions && selectedItems.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">已选 {selectedItems.length} 项</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
批量操作 <ChevronDown className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{batchActions.map((action, i) => (
|
||||
<DropdownMenuItem key={i} onClick={() => action.onClick(selectedItems)} className={action.className}>
|
||||
{action.icon} {action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showSelection && (
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox checked={isAllCurrentPageSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
className={cn(column.sortable && "cursor-pointer select-none", column.className)}
|
||||
onClick={() => column.sortable && handleSort(column.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{column.header}
|
||||
{column.sortable &&
|
||||
sortConfig?.columnId === column.id &&
|
||||
(sortConfig.direction === "asc" ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
))}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{rowActions && <TableHead className="w-[80px] text-right">操作</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: pageSize }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell colSpan={columns.length + (showSelection ? 1 : 0) + (rowActions ? 1 : 0)}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : currentPageItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + (showSelection ? 1 : 0) + (rowActions ? 1 : 0)}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentPageItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(onRowClick && "cursor-pointer")}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
>
|
||||
{showSelection && (
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedItems.some((s) => s.id === item.id)}
|
||||
onCheckedChange={(checked) => handleSelectItem(item, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.id} className={column.className}>
|
||||
{column.cell
|
||||
? column.cell(item)
|
||||
: column.accessorKey
|
||||
? String(item[column.accessorKey] ?? "")
|
||||
: null}
|
||||
</TableCell>
|
||||
))}
|
||||
{rowActions && (
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{rowActions.map((action, i) => (
|
||||
<DropdownMenuItem key={i} onClick={() => action.onClick(item)} className={action.className}>
|
||||
{action.icon} {action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-500">共 {filteredData.length} 条记录</div>
|
||||
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (withCard) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<TableContent />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={className}>{TableContent}</div>
|
||||
}
|
||||
|
||||
export const DataTable = React.memo(DataTableComponent) as typeof DataTableComponent
|
||||
@@ -1,320 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { ChevronDown, Filter, X, Search } from "lucide-react"
|
||||
import { DeviceStatus, DeviceType, DeviceCategory, type DeviceFilterParams } from "@/types/device"
|
||||
|
||||
interface DeviceFilterProps {
|
||||
filters: DeviceFilterParams
|
||||
onFiltersChange: (filters: DeviceFilterParams) => void
|
||||
availableModels?: string[]
|
||||
availableTags?: string[]
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function DeviceFilter({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
availableModels = [],
|
||||
availableTags = [],
|
||||
compact = false,
|
||||
}: DeviceFilterProps) {
|
||||
const [isOpen, setIsOpen] = useState(!compact)
|
||||
|
||||
const updateFilter = (key: keyof DeviceFilterParams, value: any) => {
|
||||
onFiltersChange({ ...filters, [key]: value })
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
onFiltersChange({})
|
||||
}
|
||||
|
||||
const getActiveFilterCount = () => {
|
||||
let count = 0
|
||||
if (filters.keyword) count++
|
||||
if (filters.status?.length) count++
|
||||
if (filters.type?.length) count++
|
||||
if (filters.category?.length) count++
|
||||
if (filters.tags?.length) count++
|
||||
if (filters.models?.length) count++
|
||||
if (filters.hasActivePlans !== undefined) count++
|
||||
return count
|
||||
}
|
||||
|
||||
const FilterContent = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 关键词搜索 */}
|
||||
<div className="space-y-2">
|
||||
<Label>关键词搜索</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备名称、IMEI、微信号..."
|
||||
value={filters.keyword || ""}
|
||||
onChange={(e) => updateFilter("keyword", e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备状态 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设备状态</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(DeviceStatus).map((status) => (
|
||||
<div key={status} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`status-${status}`}
|
||||
checked={filters.status?.includes(status) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentStatus = filters.status || []
|
||||
if (checked) {
|
||||
updateFilter("status", [...currentStatus, status])
|
||||
} else {
|
||||
updateFilter(
|
||||
"status",
|
||||
currentStatus.filter((s) => s !== status),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`status-${status}`} className="text-sm">
|
||||
{status === DeviceStatus.ONLINE
|
||||
? "在线"
|
||||
: status === DeviceStatus.OFFLINE
|
||||
? "离线"
|
||||
: status === DeviceStatus.BUSY
|
||||
? "忙碌"
|
||||
: "错误"}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备类型 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设备类型</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(DeviceType).map((type) => (
|
||||
<div key={type} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`type-${type}`}
|
||||
checked={filters.type?.includes(type) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentType = filters.type || []
|
||||
if (checked) {
|
||||
updateFilter("type", [...currentType, type])
|
||||
} else {
|
||||
updateFilter(
|
||||
"type",
|
||||
currentType.filter((t) => t !== type),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`type-${type}`} className="text-sm">
|
||||
{type === DeviceType.ANDROID ? "Android" : "iOS"}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备分类 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设备分类</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(DeviceCategory).map((category) => (
|
||||
<div key={category} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`category-${category}`}
|
||||
checked={filters.category?.includes(category) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentCategory = filters.category || []
|
||||
if (checked) {
|
||||
updateFilter("category", [...currentCategory, category])
|
||||
} else {
|
||||
updateFilter(
|
||||
"category",
|
||||
currentCategory.filter((c) => c !== category),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`category-${category}`} className="text-sm">
|
||||
{category === DeviceCategory.ACQUISITION
|
||||
? "获客设备"
|
||||
: category === DeviceCategory.MAINTENANCE
|
||||
? "维护设备"
|
||||
: category === DeviceCategory.TESTING
|
||||
? "测试设备"
|
||||
: "备用设备"}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备型号 */}
|
||||
{availableModels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>设备型号</Label>
|
||||
<Select
|
||||
value={filters.models?.[0] || ""}
|
||||
onValueChange={(value) => updateFilter("models", value ? [value] : [])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择设备型号" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部型号</SelectItem>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 电量范围 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
电量范围: {filters.batteryRange?.[0] || 0}% - {filters.batteryRange?.[1] || 100}%
|
||||
</Label>
|
||||
<Slider
|
||||
value={filters.batteryRange || [0, 100]}
|
||||
onValueChange={(value) => updateFilter("batteryRange", value as [number, number])}
|
||||
max={100}
|
||||
min={0}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 好友数量范围 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
好友数量范围: {filters.friendCountRange?.[0] || 0} - {filters.friendCountRange?.[1] || 5000}
|
||||
</Label>
|
||||
<Slider
|
||||
value={filters.friendCountRange || [0, 5000]}
|
||||
onValueChange={(value) => updateFilter("friendCountRange", value as [number, number])}
|
||||
max={5000}
|
||||
min={0}
|
||||
step={50}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备标签 */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>设备标签</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.map((tag) => (
|
||||
<div key={tag} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`tag-${tag}`}
|
||||
checked={filters.tags?.includes(tag) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentTags = filters.tags || []
|
||||
if (checked) {
|
||||
updateFilter("tags", [...currentTags, tag])
|
||||
} else {
|
||||
updateFilter(
|
||||
"tags",
|
||||
currentTags.filter((t) => t !== tag),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`tag-${tag}`} className="text-sm">
|
||||
{tag}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 是否有活跃计划 */}
|
||||
<div className="space-y-2">
|
||||
<Label>计划状态</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="hasActivePlans"
|
||||
checked={filters.hasActivePlans || false}
|
||||
onCheckedChange={(checked) => updateFilter("hasActivePlans", checked || undefined)}
|
||||
/>
|
||||
<Label htmlFor="hasActivePlans" className="text-sm">
|
||||
仅显示有活跃计划的设备
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 清除过滤器 */}
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
清除过滤器
|
||||
</Button>
|
||||
<div className="text-sm text-gray-500">{getActiveFilterCount()} 个活跃过滤器</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<div className="flex items-center">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
过滤器
|
||||
{getActiveFilterCount() > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{getActiveFilterCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-4">
|
||||
<FilterContent />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Filter className="h-5 w-5 mr-2" />
|
||||
<h3 className="font-medium">过滤器</h3>
|
||||
{getActiveFilterCount() > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{getActiveFilterCount()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FilterContent />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Smartphone, CheckCircle2, Loader2, Plus, Battery, Users, MapPin, Activity } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DeviceFilter } from "./DeviceFilter"
|
||||
import { AddDeviceDialog } from "./AddDeviceDialog"
|
||||
import type { Device, DeviceFilterParams } from "@/types/device"
|
||||
|
||||
export interface DeviceSelectorProps {
|
||||
/** 是否使用对话框模式 */
|
||||
dialogMode?: boolean
|
||||
/** 对话框是否打开 */
|
||||
open?: boolean
|
||||
/** 对话框打开状态变更回调 */
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** 是否支持多选 */
|
||||
multiple?: boolean
|
||||
/** 已选择的设备ID */
|
||||
selectedDevices?: string[]
|
||||
/** 设备选择变更回调 */
|
||||
onDevicesChange: (deviceIds: string[]) => void
|
||||
/** 是<><E698AF><EFBFBD>排除已用于其他计划的设备 */
|
||||
devices?: Device[]
|
||||
/** 是否显示下一步按钮 */
|
||||
showNextButton?: boolean
|
||||
/** 下一步按钮点击回调 */
|
||||
onNext?: () => void
|
||||
/** 上一步按钮点击回调 */
|
||||
onPrevious?: () => void
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 页面标题 */
|
||||
title?: string
|
||||
/** 最大选择数量 */
|
||||
maxSelection?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的设备选择器组件
|
||||
* 支持对话框模式和内嵌模式,支持单选和多选,样式与设备管理页面一致
|
||||
*/
|
||||
export function DeviceSelector({
|
||||
dialogMode = false,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
multiple = true,
|
||||
selectedDevices = [],
|
||||
onDevicesChange,
|
||||
devices: propDevices,
|
||||
showNextButton = false,
|
||||
onNext,
|
||||
onPrevious,
|
||||
className,
|
||||
title = "选择设备",
|
||||
maxSelection = 10,
|
||||
}: DeviceSelectorProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<string[]>(selectedDevices)
|
||||
const [filters, setFilters] = useState<DeviceFilterParams>({})
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const devicesPerPage = 10
|
||||
|
||||
// 如果外部selectedDevices变化,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setSelected(selectedDevices)
|
||||
}, [selectedDevices])
|
||||
|
||||
// 加载设备数据
|
||||
useEffect(() => {
|
||||
const fetchDevices = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (propDevices) {
|
||||
setDevices(propDevices)
|
||||
} else {
|
||||
// 模拟设备数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const mockDevices: Device[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `device-${i + 1}`,
|
||||
name: `设备 ${i + 1}`,
|
||||
imei: `IMEI-${Math.random().toString(36).substr(2, 8)}`,
|
||||
type: i % 2 === 0 ? "android" : "ios",
|
||||
status: i < 20 ? "online" : i < 23 ? "offline" : "busy",
|
||||
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
|
||||
friendCount: Math.floor(Math.random() * 1000) + 100,
|
||||
battery: Math.floor(Math.random() * 100) + 1,
|
||||
lastActive: i < 5 ? "刚刚" : i < 10 ? "5分钟前" : i < 15 ? "1小时前" : "2小时前",
|
||||
addFriendStatus: Math.random() > 0.2 ? "normal" : "abnormal",
|
||||
remark: `${title}设备 ${i + 1}`,
|
||||
model: i % 3 === 0 ? "iPhone 14" : i % 3 === 1 ? "Samsung S23" : "Xiaomi 13",
|
||||
category: i % 4 === 0 ? "acquisition" : i % 4 === 1 ? "maintenance" : i % 4 === 2 ? "testing" : "backup",
|
||||
todayAdded: Math.floor(Math.random() * 50),
|
||||
totalTasks: Math.floor(Math.random() * 100) + 10,
|
||||
completedTasks: Math.floor(Math.random() * 80) + 5,
|
||||
activePlans: i < 15 ? [`plan-${i + 1}`, `plan-${i + 2}`] : [],
|
||||
planNames: i < 15 ? [`计划 ${i + 1}`, `计划 ${i + 2}`] : [],
|
||||
tags: i % 2 === 0 ? ["高效", "稳定"] : ["测试", "备用"],
|
||||
location: i % 3 === 0 ? "北京" : i % 3 === 1 ? "上海" : "深圳",
|
||||
operator: `操作员${(i % 5) + 1}`,
|
||||
}))
|
||||
setDevices(mockDevices)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备失败:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!dialogMode || open) {
|
||||
fetchDevices()
|
||||
}
|
||||
}, [dialogMode, open, propDevices, title])
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (deviceId: string) => {
|
||||
let newSelected: string[]
|
||||
|
||||
if (multiple) {
|
||||
if (selected.includes(deviceId)) {
|
||||
newSelected = selected.filter((id) => id !== deviceId)
|
||||
} else {
|
||||
if (selected.length >= maxSelection) {
|
||||
return // 达到最大选择数量
|
||||
}
|
||||
newSelected = [...selected, deviceId]
|
||||
}
|
||||
} else {
|
||||
newSelected = [deviceId]
|
||||
}
|
||||
|
||||
setSelected(newSelected)
|
||||
onDevicesChange(newSelected)
|
||||
}
|
||||
|
||||
// 处理全选/取消全选
|
||||
const handleSelectAll = () => {
|
||||
if (selected.length === Math.min(filteredDevices.length, maxSelection)) {
|
||||
setSelected([])
|
||||
onDevicesChange([])
|
||||
} else {
|
||||
const newSelected = filteredDevices.slice(0, maxSelection).map((device) => device.id)
|
||||
setSelected(newSelected)
|
||||
onDevicesChange(newSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对话框确认
|
||||
const handleConfirm = () => {
|
||||
onDevicesChange(selected)
|
||||
if (onOpenChange) {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理设备添加
|
||||
const handleDeviceAdded = (newDevice: Device) => {
|
||||
setDevices([newDevice, ...devices])
|
||||
}
|
||||
|
||||
// 过滤设备
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
// 关键词搜索
|
||||
if (filters.keyword) {
|
||||
const keyword = filters.keyword.toLowerCase()
|
||||
const matchesKeyword =
|
||||
device.name.toLowerCase().includes(keyword) ||
|
||||
device.imei.toLowerCase().includes(keyword) ||
|
||||
device.wechatId.toLowerCase().includes(keyword) ||
|
||||
(device.remark && device.remark.toLowerCase().includes(keyword)) ||
|
||||
(device.model && device.model.toLowerCase().includes(keyword))
|
||||
|
||||
if (!matchesKeyword) return false
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filters.status?.length && !filters.status.includes(device.status)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 类型过滤
|
||||
if (filters.type?.length && !filters.type.includes(device.type)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 分类过滤
|
||||
if (filters.category?.length && device.category && !filters.category.includes(device.category)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 型号过滤
|
||||
if (filters.models?.length && device.model && !filters.models.includes(device.model)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 电量范围过滤
|
||||
if (filters.batteryRange) {
|
||||
const [min, max] = filters.batteryRange
|
||||
if (device.battery < min || device.battery > max) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 好友数量范围过滤
|
||||
if (filters.friendCountRange) {
|
||||
const [min, max] = filters.friendCountRange
|
||||
if (device.friendCount < min || device.friendCount > max) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 标签过滤
|
||||
if (filters.tags?.length && device.tags) {
|
||||
const hasMatchingTag = filters.tags.some((tag) => device.tags?.includes(tag))
|
||||
if (!hasMatchingTag) return false
|
||||
}
|
||||
|
||||
// 活跃计划过滤
|
||||
if (filters.hasActivePlans !== undefined) {
|
||||
const hasActivePlans = device.activePlans && device.activePlans.length > 0
|
||||
if (filters.hasActivePlans !== hasActivePlans) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 分页数据
|
||||
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
|
||||
|
||||
// 获取可用的型号和标签
|
||||
const availableModels = [...new Set(devices.map((d) => d.model).filter(Boolean))]
|
||||
const availableTags = [...new Set(devices.flatMap((d) => d.tags || []))]
|
||||
|
||||
// 设备卡片组件
|
||||
const DeviceCard = ({ device }: { device: Device }) => {
|
||||
const isSelected = selected.includes(device.id)
|
||||
const canSelect = !isSelected && (selected.length < maxSelection || !multiple)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md",
|
||||
isSelected ? "ring-2 ring-blue-500 bg-blue-50" : "hover:bg-gray-50",
|
||||
!canSelect && !isSelected && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => (canSelect || isSelected ? handleDeviceToggle(device.id) : undefined)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="mt-1">
|
||||
{multiple ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="data-[state=checked]:bg-blue-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : isSelected ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-500" />
|
||||
) : (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium truncate">{device.name}</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
device.status === "online"
|
||||
? "bg-green-500/10 text-green-600 border-green-200"
|
||||
: device.status === "busy"
|
||||
? "bg-yellow-500/10 text-yellow-600 border-yellow-200"
|
||||
: "bg-gray-500/10 text-gray-600 border-gray-200",
|
||||
)}
|
||||
>
|
||||
{device.status === "online" ? "在线" : device.status === "busy" ? "忙碌" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Smartphone className={cn("h-4 w-4", device.type === "android" ? "text-green-500" : "text-gray-500")} />
|
||||
<span className="text-xs text-gray-500">{device.type === "android" ? "Android" : "iOS"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
{device.model && <div>型号: {device.model}</div>}
|
||||
{device.remark && <div>备注: {device.remark}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 text-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Battery
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
device.battery > 50 ? "text-green-500" : device.battery > 20 ? "text-yellow-500" : "text-red-500",
|
||||
)}
|
||||
/>
|
||||
<span>{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<span>{device.friendCount}</span>
|
||||
</div>
|
||||
{device.todayAdded !== undefined && <div className="text-green-600">+{device.todayAdded}</div>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{device.lastActive}</div>
|
||||
</div>
|
||||
|
||||
{/* 计划和任务信息 */}
|
||||
{device.activePlans && device.activePlans.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>活跃计划: {device.activePlans.length}</span>
|
||||
</div>
|
||||
{device.planNames && (
|
||||
<div className="text-xs text-gray-500 truncate">{device.planNames.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 任务完成情况 */}
|
||||
{device.totalTasks !== undefined && device.completedTasks !== undefined && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
任务完成: {device.completedTasks}/{device.totalTasks}(
|
||||
{Math.round((device.completedTasks / device.totalTasks) * 100)}%)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{device.tags && device.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{device.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 位置和操作员 */}
|
||||
{(device.location || device.operator) && (
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||
{device.location && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{device.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{device.operator && <span>操作员: {device.operator}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 设备列表内容
|
||||
const DeviceListContent = () => (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="list" className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="list">列表视图</TabsTrigger>
|
||||
<TabsTrigger value="filter">过滤器</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>添加设备</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="list" className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selected.length} / {Math.min(filteredDevices.length, maxSelection)} 个设备
|
||||
{multiple && maxSelection < filteredDevices.length && (
|
||||
<span className="text-orange-500 ml-2">(最多可选 {maxSelection} 个)</span>
|
||||
)}
|
||||
</div>
|
||||
{multiple && (
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={filteredDevices.length === 0}>
|
||||
{selected.length === Math.min(filteredDevices.length, maxSelection) && filteredDevices.length > 0
|
||||
? "取消全选"
|
||||
: "全选"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500 mr-2" />
|
||||
<span>正在加载设备列表...</span>
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Smartphone className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>未找到匹配的设备</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setFilters({})}>
|
||||
清除过滤器
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{paginatedDevices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{filteredDevices.length > devicesPerPage && (
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
第 {currentPage} / {Math.ceil(filteredDevices.length / devicesPerPage)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
|
||||
}
|
||||
disabled={currentPage >= Math.ceil(filteredDevices.length / devicesPerPage)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="filter">
|
||||
<DeviceFilter
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
availableModels={availableModels}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 对话框模式
|
||||
if (dialogMode) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto py-4">
|
||||
<DeviceListContent />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange && onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={selected.length === 0}>
|
||||
确认选择 ({selected.length})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AddDeviceDialog open={showAddDialog} onOpenChange={setShowAddDialog} onDeviceAdded={handleDeviceAdded} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 内嵌模式
|
||||
return (
|
||||
<>
|
||||
<Card className={cn("p-6", className)}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<div className="text-sm text-gray-500">{filteredDevices.length} 个设备可用</div>
|
||||
</div>
|
||||
|
||||
<DeviceListContent />
|
||||
|
||||
{showNextButton && (
|
||||
<div className="flex justify-between mt-6 pt-6 border-t">
|
||||
{onPrevious && (
|
||||
<Button variant="outline" onClick={onPrevious}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{onNext && (
|
||||
<Button onClick={onNext} disabled={selected.length === 0} className="bg-blue-500 hover:bg-blue-600">
|
||||
下一步 ({selected.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<AddDeviceDialog open={showAddDialog} onOpenChange={setShowAddDialog} onDeviceAdded={handleDeviceAdded} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useCallback } from "react"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { Card, CardContent } from "@/app/components/ui/card"
|
||||
import { Progress } from "@/app/components/ui/progress"
|
||||
import { Badge } from "@/app/components/ui/badge"
|
||||
import { Upload, X, File, ImageIcon, Video, FileText, Download, Eye } from "lucide-react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
url?: string
|
||||
progress?: number
|
||||
status: "uploading" | "success" | "error"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface FileUploaderProps {
|
||||
/** 允许的文件类型 */
|
||||
accept?: string
|
||||
/** 是否支持多文件上传 */
|
||||
multiple?: boolean
|
||||
/** 最大文件大小(字节) */
|
||||
maxSize?: number
|
||||
/** 最大文件数量 */
|
||||
maxFiles?: number
|
||||
/** 已上传的文件列表 */
|
||||
files?: UploadedFile[]
|
||||
/** 文件变更回调 */
|
||||
onFilesChange?: (files: UploadedFile[]) => void
|
||||
/** 文件上传处理函数 */
|
||||
onUpload?: (file: File) => Promise<{ url: string; id: string }>
|
||||
/** 文件删除回调 */
|
||||
onDelete?: (fileId: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 上传区域提示文本 */
|
||||
placeholder?: string
|
||||
/** 是否显示预览 */
|
||||
showPreview?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的文件上传组件
|
||||
* 支持拖拽上传、多文件上传、进度显示、预览等功能
|
||||
*/
|
||||
export function FileUploader({
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize = 10 * 1024 * 1024, // 10MB
|
||||
maxFiles = 10,
|
||||
files = [],
|
||||
onFilesChange,
|
||||
onUpload,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
className,
|
||||
placeholder = "点击或拖拽文件到此处上传",
|
||||
showPreview = true,
|
||||
}: FileUploaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadedFile[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 获取文件图标
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith("image/")) return <ImageIcon className="h-8 w-8 text-blue-500" />
|
||||
if (type.startsWith("video/")) return <Video className="h-8 w-8 text-purple-500" />
|
||||
if (type.includes("pdf") || type.includes("document")) return <FileText className="h-8 w-8 text-red-500" />
|
||||
return <File className="h-8 w-8 text-gray-500" />
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
// 验证文件
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (maxSize && file.size > maxSize) {
|
||||
return `文件大小不能超过 ${formatFileSize(maxSize)}`
|
||||
}
|
||||
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(",").map((type) => type.trim())
|
||||
const isAccepted = acceptedTypes.some((type) => {
|
||||
if (type.startsWith(".")) {
|
||||
return file.name.toLowerCase().endsWith(type.toLowerCase())
|
||||
}
|
||||
return file.type.match(type.replace("*", ".*"))
|
||||
})
|
||||
|
||||
if (!isAccepted) {
|
||||
return `不支持的文件类型: ${file.type}`
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length + uploadingFiles.length >= maxFiles) {
|
||||
return `最多只能上传 ${maxFiles} 个文件`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
if (disabled || !onUpload) return
|
||||
|
||||
const filesToUpload = Array.from(fileList)
|
||||
const newUploadingFiles: UploadedFile[] = []
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
// 显示错误通知
|
||||
console.error(error)
|
||||
continue
|
||||
}
|
||||
|
||||
const uploadFile: UploadedFile = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
progress: 0,
|
||||
status: "uploading",
|
||||
}
|
||||
|
||||
newUploadingFiles.push(uploadFile)
|
||||
}
|
||||
|
||||
setUploadingFiles((prev) => [...prev, ...newUploadingFiles])
|
||||
|
||||
// 逐个上传文件
|
||||
for (let i = 0; i < newUploadingFiles.length; i++) {
|
||||
const uploadFile = newUploadingFiles[i]
|
||||
const file = filesToUpload[i]
|
||||
|
||||
try {
|
||||
// 模拟上传进度
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => (f.id === uploadFile.id ? { ...f, progress: Math.min((f.progress || 0) + 10, 90) } : f)),
|
||||
)
|
||||
}, 200)
|
||||
|
||||
const result = await onUpload(file)
|
||||
|
||||
clearInterval(progressInterval)
|
||||
|
||||
// 上传成功
|
||||
const successFile: UploadedFile = {
|
||||
...uploadFile,
|
||||
url: result.url,
|
||||
id: result.id,
|
||||
progress: 100,
|
||||
status: "success",
|
||||
}
|
||||
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.id !== uploadFile.id))
|
||||
|
||||
if (onFilesChange) {
|
||||
onFilesChange([...files, successFile])
|
||||
}
|
||||
} catch (error) {
|
||||
// 上传失败
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === uploadFile.id
|
||||
? { ...f, status: "error", error: error instanceof Error ? error.message : "上传失败" }
|
||||
: f,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onUpload, files, onFilesChange, maxSize, maxFiles, accept, uploadingFiles.length],
|
||||
)
|
||||
|
||||
// 处理拖拽
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
if (droppedFiles.length > 0) {
|
||||
handleFileUpload(droppedFiles)
|
||||
}
|
||||
},
|
||||
[disabled, handleFileUpload],
|
||||
)
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = e.target.files
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
handleFileUpload(selectedFiles)
|
||||
}
|
||||
// 清空input值,允许重复选择同一文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
},
|
||||
[handleFileUpload],
|
||||
)
|
||||
|
||||
// 删除文件
|
||||
const handleDeleteFile = (fileId: string) => {
|
||||
if (onDelete) {
|
||||
onDelete(fileId)
|
||||
}
|
||||
if (onFilesChange) {
|
||||
onFilesChange(files.filter((file) => file.id !== fileId))
|
||||
}
|
||||
}
|
||||
|
||||
// 删除上传中的文件
|
||||
const handleDeleteUploadingFile = (fileId: string) => {
|
||||
setUploadingFiles((prev) => prev.filter((file) => file.id !== fileId))
|
||||
}
|
||||
|
||||
// 重试上传
|
||||
const handleRetryUpload = (fileId: string) => {
|
||||
const failedFile = uploadingFiles.find((f) => f.id === fileId)
|
||||
if (failedFile) {
|
||||
// 这里需要重新获取原始文件,实际实现中可能需要保存原始文件引用
|
||||
console.log("重试上传:", failedFile.name)
|
||||
}
|
||||
}
|
||||
|
||||
const allFiles = [...files, ...uploadingFiles]
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 上传区域 */}
|
||||
<Card
|
||||
className={cn(
|
||||
"border-2 border-dashed transition-colors cursor-pointer",
|
||||
isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium mb-2">{placeholder}</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{accept && `支持格式: ${accept}`}
|
||||
{maxSize && ` • 最大 ${formatFileSize(maxSize)}`}
|
||||
{multiple && ` • 最多 ${maxFiles} 个文件`}
|
||||
</p>
|
||||
<Button variant="outline" disabled={disabled}>
|
||||
选择文件
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* 文件列表 */}
|
||||
{allFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">已上传文件 ({allFiles.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{allFiles.map((file) => (
|
||||
<Card key={file.id} className="p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{showPreview && getFileIcon(file.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium truncate">{file.name}</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant={
|
||||
file.status === "success"
|
||||
? "success"
|
||||
: file.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{file.status === "success" ? "已上传" : file.status === "error" ? "失败" : "上传中"}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file.status === "uploading" && <Progress value={file.progress || 0} className="mt-2" />}
|
||||
|
||||
{file.status === "error" && file.error && <p className="text-sm text-red-500 mt-1">{file.error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{file.status === "success" && file.url && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => window.open(file.url, "_blank")}>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{file.status === "error" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRetryUpload(file.id)}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
file.status === "success" ? handleDeleteFile(file.id) : handleDeleteUploadingFile(file.id)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface FormSection {
|
||||
title?: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export interface FormLayoutProps {
|
||||
/** 表单标题 */
|
||||
title?: string
|
||||
/** 表单描述 */
|
||||
description?: string
|
||||
/** 表单部分 */
|
||||
sections?: FormSection[]
|
||||
/** 表单内容 */
|
||||
children?: ReactNode
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string
|
||||
/** 是否显示取消按钮 */
|
||||
showCancel?: boolean
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean
|
||||
/** 重置按钮文本 */
|
||||
resetText?: string
|
||||
/** 提交处理函数 */
|
||||
onSubmit?: () => void
|
||||
/** 取消处理函数 */
|
||||
onCancel?: () => void
|
||||
/** 重置处理函数 */
|
||||
onReset?: () => void
|
||||
/** 是否禁用提交按钮 */
|
||||
submitDisabled?: boolean
|
||||
/** 是否显示加载状态 */
|
||||
loading?: boolean
|
||||
/** 自定义底部内容 */
|
||||
footer?: ReactNode
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 是否使用卡片包装 */
|
||||
withCard?: boolean
|
||||
/** 表单布局方向 */
|
||||
direction?: "vertical" | "horizontal"
|
||||
/** 表单标签宽度 (仅在水平布局时有效) */
|
||||
labelWidth?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的表单布局组件
|
||||
*/
|
||||
export function FormLayout({
|
||||
title,
|
||||
description,
|
||||
sections = [],
|
||||
children,
|
||||
submitText = "提交",
|
||||
cancelText = "取消",
|
||||
showCancel = true,
|
||||
showReset = false,
|
||||
resetText = "重置",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onReset,
|
||||
submitDisabled = false,
|
||||
loading = false,
|
||||
footer,
|
||||
className,
|
||||
withCard = true,
|
||||
direction = "vertical",
|
||||
labelWidth = "120px",
|
||||
}: FormLayoutProps) {
|
||||
const FormContent = () => (
|
||||
<>
|
||||
{/* 表单内容 */}
|
||||
<div className={cn("space-y-6", direction === "horizontal" && "form-horizontal")}>
|
||||
{/* 如果有sections,渲染sections */}
|
||||
{sections.length > 0
|
||||
? sections.map((section, index) => (
|
||||
<div key={index} className="space-y-4">
|
||||
{(section.title || section.description) && (
|
||||
<div className="mb-4">
|
||||
{section.title && <h3 className="text-lg font-medium">{section.title}</h3>}
|
||||
{section.description && <p className="text-sm text-gray-500">{section.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div>{section.children}</div>
|
||||
</div>
|
||||
))
|
||||
: // 否则直接渲染children
|
||||
children}
|
||||
</div>
|
||||
|
||||
{/* 表单底部 */}
|
||||
{(onSubmit || onCancel || onReset || footer) && (
|
||||
<div className="flex justify-end space-x-2 pt-6">
|
||||
{footer || (
|
||||
<>
|
||||
{showReset && onReset && (
|
||||
<Button type="button" variant="outline" onClick={onReset}>
|
||||
{resetText}
|
||||
</Button>
|
||||
)}
|
||||
{showCancel && onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
{onSubmit && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitDisabled || loading}
|
||||
onClick={onSubmit}
|
||||
className={loading ? "opacity-70" : ""}
|
||||
>
|
||||
{loading ? "处理中..." : submitText}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
// 添加水平布局的样式
|
||||
if (direction === "horizontal") {
|
||||
const style = document.createElement("style")
|
||||
style.textContent = `
|
||||
.form-horizontal .form-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-horizontal .form-label {
|
||||
width: ${labelWidth};
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.form-horizontal .form-field {
|
||||
flex: 1;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.form-horizontal .form-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.form-horizontal .form-label {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
// 根据是否需要卡片包装返回不同的渲染结果
|
||||
if (withCard) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent>
|
||||
<FormContent />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{(title || description) && (
|
||||
<div className="mb-6">
|
||||
{title && <h2 className="text-xl font-semibold">{title}</h2>}
|
||||
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<FormContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单项组件 - 用于水平布局
|
||||
*/
|
||||
export function FormItem({ label, required, children }: { label: string; required?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="form-item">
|
||||
<div className="form-label">
|
||||
{required && <span className="text-red-500 mr-1">*</span>}
|
||||
<span>{label}:</span>
|
||||
</div>
|
||||
<div className="form-field">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface LazyLoadProps {
|
||||
/** 子组件 */
|
||||
children: ReactNode
|
||||
/** 占位符 */
|
||||
placeholder?: ReactNode
|
||||
/** 根边距 */
|
||||
rootMargin?: string
|
||||
/** 阈值 */
|
||||
threshold?: number
|
||||
/** 是否只加载一次 */
|
||||
once?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 加载完成回调 */
|
||||
onLoad?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒加载组件
|
||||
* 当元素进入视口时才渲染内容
|
||||
*/
|
||||
export function LazyLoad({
|
||||
children,
|
||||
placeholder,
|
||||
rootMargin = "50px",
|
||||
threshold = 0.1,
|
||||
once = true,
|
||||
className,
|
||||
onLoad,
|
||||
}: LazyLoadProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
if (once) {
|
||||
setHasLoaded(true)
|
||||
observer.unobserve(element)
|
||||
}
|
||||
if (onLoad) {
|
||||
onLoad()
|
||||
}
|
||||
} else if (!once) {
|
||||
setIsVisible(false)
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}, [rootMargin, threshold, once, onLoad])
|
||||
|
||||
const shouldRender = isVisible || hasLoaded
|
||||
|
||||
return (
|
||||
<div ref={elementRef} className={cn(className)}>
|
||||
{shouldRender ? children : placeholder}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 懒加载图片组件
|
||||
export interface LazyImageProps {
|
||||
src: string
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
placeholder?: ReactNode
|
||||
onLoad?: () => void
|
||||
onError?: () => void
|
||||
}
|
||||
|
||||
export function LazyImage({ src, alt, width, height, className, placeholder, onLoad, onError }: LazyImageProps) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoaded(true)
|
||||
if (onLoad) onLoad()
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
if (onError) onError()
|
||||
}
|
||||
|
||||
const defaultPlaceholder = (
|
||||
<div
|
||||
className={cn("bg-gray-200 animate-pulse flex items-center justify-center", className)}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<span className="text-gray-400 text-sm">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn("bg-gray-100 flex items-center justify-center", className)} style={{ width, height }}>
|
||||
<span className="text-gray-400 text-sm">加载失败</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LazyLoad placeholder={placeholder || defaultPlaceholder}>
|
||||
<img
|
||||
src={src || "/placeholder.svg"}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={cn("transition-opacity duration-300", loaded ? "opacity-100" : "opacity-0", className)}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</LazyLoad>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, createContext, useContext, type ReactNode } from "react"
|
||||
import { Card, CardContent } from "@/app/components/ui/card"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
export type NotificationType = "success" | "error" | "warning" | "info"
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
persistent?: boolean
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: "default" | "outline"
|
||||
}>
|
||||
}
|
||||
|
||||
interface NotificationContextType {
|
||||
notifications: Notification[]
|
||||
addNotification: (notification: Omit<Notification, "id">) => void
|
||||
removeNotification: (id: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
|
||||
|
||||
export function useNotifications() {
|
||||
const context = useContext(NotificationContext)
|
||||
if (!context) {
|
||||
throw new Error("useNotifications must be used within a NotificationProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
|
||||
const addNotification = (notification: Omit<Notification, "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id,
|
||||
duration: notification.duration ?? 5000,
|
||||
}
|
||||
|
||||
setNotifications((prev) => [newNotification, ...prev])
|
||||
|
||||
// 自动移除非持久化通知
|
||||
if (!notification.persistent && newNotification.duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, newNotification.duration)
|
||||
}
|
||||
}
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
setNotifications((prev) => prev.filter((notification) => notification.id !== id))
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
setNotifications([])
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, addNotification, removeNotification, clearAll }}>
|
||||
{children}
|
||||
<NotificationContainer />
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationContainer() {
|
||||
const { notifications, removeNotification } = useNotifications()
|
||||
|
||||
if (notifications.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm w-full">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} onRemove={removeNotification} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onRemove,
|
||||
}: {
|
||||
notification: Notification
|
||||
onRemove: (id: string) => void
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
switch (notification.type) {
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
case "info":
|
||||
return <Info className="h-5 w-5 text-blue-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getBorderColor = () => {
|
||||
switch (notification.type) {
|
||||
case "success":
|
||||
return "border-l-green-500"
|
||||
case "error":
|
||||
return "border-l-red-500"
|
||||
case "warning":
|
||||
return "border-l-yellow-500"
|
||||
case "info":
|
||||
return "border-l-blue-500"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("border-l-4 shadow-lg animate-in slide-in-from-right", getBorderColor())}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{getIcon()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">{notification.title}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => onRemove(notification.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{notification.message && <p className="text-sm text-gray-600 mt-1">{notification.message}</p>}
|
||||
{notification.actions && notification.actions.length > 0 && (
|
||||
<div className="flex space-x-2 mt-3">
|
||||
{notification.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
action.onClick()
|
||||
onRemove(notification.id)
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 便捷的通知钩子
|
||||
export function useNotify() {
|
||||
const { addNotification } = useNotifications()
|
||||
|
||||
return {
|
||||
success: (title: string, message?: string, options?: Partial<Notification>) =>
|
||||
addNotification({ type: "success", title, message, ...options }),
|
||||
error: (title: string, message?: string, options?: Partial<Notification>) =>
|
||||
addNotification({ type: "error", title, message, ...options }),
|
||||
warning: (title: string, message?: string, options?: Partial<Notification>) =>
|
||||
addNotification({ type: "warning", title, message, ...options }),
|
||||
info: (title: string, message?: string, options?: Partial<Notification>) =>
|
||||
addNotification({ type: "info", title, message, ...options }),
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
primaryAction?: {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
}
|
||||
secondaryActions?: Array<{
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
}>
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, primaryAction, secondaryActions = [] }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-6 border-b">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
|
||||
{(primaryAction || secondaryActions.length > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{secondaryActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || "outline"}
|
||||
onClick={action.onClick}
|
||||
className="flex items-center"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant={primaryAction.variant || "default"}
|
||||
onClick={primaryAction.onClick}
|
||||
className="flex items-center"
|
||||
>
|
||||
{primaryAction.icon}
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Input } from "@/app/components/ui/input"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/components/ui/select"
|
||||
import { Calendar } from "@/app/components/ui/calendar"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/app/components/ui/popover"
|
||||
import { Badge } from "@/app/components/ui/badge"
|
||||
import { Search, Filter, X, CalendarIcon } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
import { zhCN } from "date-fns/locale"
|
||||
|
||||
interface FilterField {
|
||||
id: string
|
||||
label: string
|
||||
type: "text" | "select" | "dateRange" | "multiSelect"
|
||||
options?: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SearchFilterProps {
|
||||
fields: FilterField[]
|
||||
onFilterChange: (filters: Record<string, any>) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchFilter({ fields, onFilterChange, placeholder = "搜索..." }: SearchFilterProps) {
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [filters, setFilters] = useState<Record<string, any>>({})
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchValue(value)
|
||||
const newFilters = { ...filters, search: value }
|
||||
setFilters(newFilters)
|
||||
onFilterChange(newFilters)
|
||||
}
|
||||
|
||||
const handleFilterChange = (fieldId: string, value: any) => {
|
||||
const newFilters = { ...filters, [fieldId]: value }
|
||||
setFilters(newFilters)
|
||||
onFilterChange(newFilters)
|
||||
}
|
||||
|
||||
const clearFilter = (fieldId: string) => {
|
||||
const newFilters = { ...filters }
|
||||
delete newFilters[fieldId]
|
||||
setFilters(newFilters)
|
||||
onFilterChange(newFilters)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSearchValue("")
|
||||
setFilters({})
|
||||
onFilterChange({})
|
||||
}
|
||||
|
||||
const activeFiltersCount = Object.keys(filters).filter(
|
||||
(key) => key !== "search" && filters[key] !== undefined && filters[key] !== "",
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
筛选
|
||||
{activeFiltersCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{activeFiltersCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
{showFilters && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<label className="text-sm font-medium">{field.label}</label>
|
||||
{field.type === "select" && (
|
||||
<Select
|
||||
value={filters[field.id] || ""}
|
||||
onValueChange={(value) => handleFilterChange(field.id, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || `选择${field.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{field.type === "text" && (
|
||||
<Input
|
||||
placeholder={field.placeholder || `输入${field.label}`}
|
||||
value={filters[field.id] || ""}
|
||||
onChange={(e) => handleFilterChange(field.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "dateRange" && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters[field.id]
|
||||
? `${format(filters[field.id].from, "yyyy-MM-dd", { locale: zhCN })} - ${format(filters[field.id].to, "yyyy-MM-dd", { locale: zhCN })}`
|
||||
: field.placeholder || "选择日期范围"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={filters[field.id]}
|
||||
onSelect={(range) => handleFilterChange(field.id, range)}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 活跃筛选器显示 */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t">
|
||||
<span className="text-sm text-muted-foreground">活跃筛选器:</span>
|
||||
{Object.entries(filters).map(([key, value]) => {
|
||||
if (key === "search" || !value) return null
|
||||
const field = fields.find((f) => f.id === key)
|
||||
if (!field) return null
|
||||
|
||||
return (
|
||||
<Badge key={key} variant="secondary" className="flex items-center gap-1">
|
||||
{field.label}: {typeof value === "object" ? "已选择" : value}
|
||||
<X className="h-3 w-3 cursor-pointer" onClick={() => clearFilter(key)} />
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
<Button variant="ghost" size="sm" onClick={clearAllFilters} className="h-6 px-2 text-xs">
|
||||
清除所有
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui/card"
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
change?: {
|
||||
value: string | number
|
||||
type: "increase" | "decrease" | "neutral"
|
||||
label?: string
|
||||
}
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon, change, description }: StatCardProps) {
|
||||
const getChangeIcon = () => {
|
||||
switch (change?.type) {
|
||||
case "increase":
|
||||
return <TrendingUp className="h-3 w-3" />
|
||||
case "decrease":
|
||||
return <TrendingDown className="h-3 w-3" />
|
||||
default:
|
||||
return <Minus className="h-3 w-3" />
|
||||
}
|
||||
}
|
||||
|
||||
const getChangeColor = () => {
|
||||
switch (change?.type) {
|
||||
case "increase":
|
||||
return "text-green-600"
|
||||
case "decrease":
|
||||
return "text-red-600"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{change && (
|
||||
<div className={`flex items-center text-xs ${getChangeColor()}`}>
|
||||
{getChangeIcon()}
|
||||
<span className="ml-1">
|
||||
{change.value} {change.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatCardGroupProps {
|
||||
cards: StatCardProps[]
|
||||
}
|
||||
|
||||
export function StatCardGroup({ cards }: StatCardGroupProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((card, index) => (
|
||||
<StatCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Plus, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface TagManagerProps {
|
||||
/** 已选择的标签 */
|
||||
selectedTags: Tag[]
|
||||
/** 标签变更回调 */
|
||||
onTagsChange: (tags: Tag[]) => void
|
||||
/** 预设标签列表 */
|
||||
presetTags?: Tag[]
|
||||
/** 是否允许创建自定义标签 */
|
||||
allowCustomTags?: boolean
|
||||
/** 标签最大数量,0表示不限制 */
|
||||
maxTags?: number
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 是否使用卡片包装 */
|
||||
withCard?: boolean
|
||||
/** 标题 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的标签管理器组件
|
||||
*/
|
||||
export function TagManager({
|
||||
selectedTags = [],
|
||||
onTagsChange,
|
||||
presetTags = [],
|
||||
allowCustomTags = true,
|
||||
maxTags = 0,
|
||||
className,
|
||||
withCard = true,
|
||||
title = "标签管理",
|
||||
}: TagManagerProps) {
|
||||
const [newTagName, setNewTagName] = useState("")
|
||||
|
||||
// 默认预设标签
|
||||
const defaultPresetTags: Tag[] = [
|
||||
{ id: "tag1", name: "重要客户", color: "bg-red-100 text-red-800 hover:bg-red-200" },
|
||||
{ id: "tag2", name: "潜在客户", color: "bg-blue-100 text-blue-800 hover:bg-blue-200" },
|
||||
{ id: "tag3", name: "已成交", color: "bg-green-100 text-green-800 hover:bg-green-200" },
|
||||
{ id: "tag4", name: "待跟进", color: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200" },
|
||||
{ id: "tag5", name: "高意向", color: "bg-purple-100 text-purple-800 hover:bg-purple-200" },
|
||||
{ id: "tag6", name: "低意向", color: "bg-gray-100 text-gray-800 hover:bg-gray-200" },
|
||||
{ id: "tag7", name: "已流失", color: "bg-pink-100 text-pink-800 hover:bg-pink-200" },
|
||||
{ id: "tag8", name: "新客户", color: "bg-indigo-100 text-indigo-800 hover:bg-indigo-200" },
|
||||
]
|
||||
|
||||
const availablePresetTags = presetTags.length > 0 ? presetTags : defaultPresetTags
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (tag: Tag) => {
|
||||
if (maxTags > 0 && selectedTags.length >= maxTags) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedTags.some((t) => t.id === tag.id)) {
|
||||
onTagsChange([...selectedTags, tag])
|
||||
}
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
const handleRemoveTag = (tagId: string) => {
|
||||
onTagsChange(selectedTags.filter((tag) => tag.id !== tagId))
|
||||
}
|
||||
|
||||
// 添加自定义标签
|
||||
const handleAddCustomTag = () => {
|
||||
if (!newTagName.trim() || (maxTags > 0 && selectedTags.length >= maxTags)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成随机颜色
|
||||
const colors = [
|
||||
"bg-red-100 text-red-800 hover:bg-red-200",
|
||||
"bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
"bg-green-100 text-green-800 hover:bg-green-200",
|
||||
"bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
"bg-purple-100 text-purple-800 hover:bg-purple-200",
|
||||
"bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||
"bg-pink-100 text-pink-800 hover:bg-pink-200",
|
||||
"bg-indigo-100 text-indigo-800 hover:bg-indigo-200",
|
||||
]
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
|
||||
const newTag: Tag = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: newTagName.trim(),
|
||||
color: randomColor,
|
||||
}
|
||||
|
||||
onTagsChange([...selectedTags, newTag])
|
||||
setNewTagName("")
|
||||
}
|
||||
|
||||
const TagManagerContent = () => (
|
||||
<div className="space-y-4">
|
||||
{title && <h3 className="text-lg font-medium">{title}</h3>}
|
||||
|
||||
{/* 已选标签 */}
|
||||
<div>
|
||||
<Label className="mb-2 block">已选标签</Label>
|
||||
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 border rounded-md">
|
||||
{selectedTags.length > 0 ? (
|
||||
selectedTags.map((tag) => (
|
||||
<Badge key={tag.id} className={cn("flex items-center gap-1 px-3 py-1", tag.color)}>
|
||||
{tag.name}
|
||||
<X className="h-3 w-3 cursor-pointer" onClick={() => handleRemoveTag(tag.id)} />
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">暂无标签</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设标签 */}
|
||||
<div>
|
||||
<Label className="mb-2 block">预设标签</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availablePresetTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-1",
|
||||
tag.color,
|
||||
selectedTags.some((t) => t.id === tag.id) ? "opacity-50" : "",
|
||||
)}
|
||||
onClick={() => handleAddTag(tag)}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义标签 */}
|
||||
{allowCustomTags && (
|
||||
<div>
|
||||
<Label className="mb-2 block">添加自定义标签</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
placeholder="输入标签名称"
|
||||
className="flex-1"
|
||||
maxLength={10}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustomTag}
|
||||
disabled={!newTagName.trim() || (maxTags > 0 && selectedTags.length >= maxTags)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{maxTags > 0 && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
已添加 {selectedTags.length}/{maxTags} 个标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return withCard ? (
|
||||
<Card className={className}>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<TagManagerContent />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<TagManagerContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useMemo, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface VirtualizedListProps<T> {
|
||||
/** 数据列表 */
|
||||
items: T[]
|
||||
/** 每项的高度 */
|
||||
itemHeight: number
|
||||
/** 容器高度 */
|
||||
height: number
|
||||
/** 渲染函数 */
|
||||
renderItem: (item: T, index: number) => ReactNode
|
||||
/** 缓冲区大小(额外渲染的项目数) */
|
||||
overscan?: number
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
/** 加载更多回调 */
|
||||
onLoadMore?: () => void
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
/** 空状态渲染 */
|
||||
emptyState?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟化列表组件
|
||||
* 用于高性能渲染大量数据
|
||||
*/
|
||||
export function VirtualizedList<T>({
|
||||
items,
|
||||
itemHeight,
|
||||
height,
|
||||
renderItem,
|
||||
overscan = 5,
|
||||
className,
|
||||
onLoadMore,
|
||||
loading = false,
|
||||
emptyState,
|
||||
}: VirtualizedListProps<T>) {
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 计算可见范围
|
||||
const visibleRange = useMemo(() => {
|
||||
const containerHeight = height
|
||||
const startIndex = Math.floor(scrollTop / itemHeight)
|
||||
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight), items.length - 1)
|
||||
|
||||
return {
|
||||
start: Math.max(0, startIndex - overscan),
|
||||
end: Math.min(items.length - 1, endIndex + overscan),
|
||||
}
|
||||
}, [scrollTop, itemHeight, height, items.length, overscan])
|
||||
|
||||
// 可见项目
|
||||
const visibleItems = useMemo(() => {
|
||||
return items.slice(visibleRange.start, visibleRange.end + 1)
|
||||
}, [items, visibleRange])
|
||||
|
||||
// 处理滚动
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = e.currentTarget.scrollTop
|
||||
setScrollTop(scrollTop)
|
||||
|
||||
// 检查是否需要加载更多
|
||||
if (onLoadMore && !loading) {
|
||||
const { scrollHeight, clientHeight } = e.currentTarget
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
onLoadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总高度
|
||||
const totalHeight = items.length * itemHeight
|
||||
|
||||
// 偏移量
|
||||
const offsetY = visibleRange.start * itemHeight
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)} style={{ height }}>
|
||||
{emptyState || <div className="text-gray-500">暂无数据</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("overflow-auto", className)} style={{ height }} onScroll={handleScroll}>
|
||||
<div style={{ height: totalHeight, position: "relative" }}>
|
||||
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<div key={visibleRange.start + index} style={{ height: itemHeight }} className="flex items-center">
|
||||
{renderItem(item, visibleRange.start + index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="text-sm text-gray-500">加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ReactNode, createContext, useContext } from "react"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { Card, CardContent } from "@/app/components/ui/card"
|
||||
import { Progress } from "@/app/components/ui/progress"
|
||||
import { CheckCircle, Circle, ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
content: ReactNode
|
||||
optional?: boolean
|
||||
validation?: () => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
interface WizardContextType {
|
||||
currentStep: number
|
||||
steps: WizardStep[]
|
||||
goToStep: (step: number) => void
|
||||
nextStep: () => Promise<void>
|
||||
previousStep: () => void
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
canGoNext: boolean
|
||||
canGoPrevious: boolean
|
||||
}
|
||||
|
||||
const WizardContext = createContext<WizardContextType | undefined>(undefined)
|
||||
|
||||
export function useWizard() {
|
||||
const context = useContext(WizardContext)
|
||||
if (!context) {
|
||||
throw new Error("useWizard must be used within a Wizard component")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export interface WizardProps {
|
||||
steps: WizardStep[]
|
||||
onComplete?: () => void
|
||||
onCancel?: () => void
|
||||
className?: string
|
||||
showProgress?: boolean
|
||||
showStepNumbers?: boolean
|
||||
allowStepNavigation?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的向导组件
|
||||
* 支持步骤导航、验证、进度显示等功能
|
||||
*/
|
||||
export function Wizard({
|
||||
steps,
|
||||
onComplete,
|
||||
onCancel,
|
||||
className,
|
||||
showProgress = true,
|
||||
showStepNumbers = true,
|
||||
allowStepNavigation = false,
|
||||
children,
|
||||
}: WizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set())
|
||||
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === steps.length - 1
|
||||
const canGoPrevious = !isFirstStep
|
||||
const canGoNext = currentStep < steps.length - 1
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
if (step >= 0 && step < steps.length) {
|
||||
if (allowStepNavigation || step <= Math.max(...Array.from(completedSteps)) + 1) {
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = async () => {
|
||||
const step = steps[currentStep]
|
||||
|
||||
// 验证当前步骤
|
||||
if (step.validation) {
|
||||
const isValid = await step.validation()
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 标记当前步骤为已完成
|
||||
setCompletedSteps((prev) => new Set([...prev, currentStep]))
|
||||
|
||||
if (isLastStep) {
|
||||
// 完成向导
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
} else {
|
||||
// 进入下一步
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const previousStep = () => {
|
||||
if (canGoPrevious) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: WizardContextType = {
|
||||
currentStep,
|
||||
steps,
|
||||
goToStep,
|
||||
nextStep,
|
||||
previousStep,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
canGoNext,
|
||||
canGoPrevious,
|
||||
}
|
||||
|
||||
const progressPercentage = ((currentStep + 1) / steps.length) * 100
|
||||
|
||||
return (
|
||||
<WizardContext.Provider value={contextValue}>
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* 进度条 */}
|
||||
{showProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
步骤 {currentStep + 1} / {steps.length}
|
||||
</span>
|
||||
<span>{Math.round(progressPercentage)}% 完成</span>
|
||||
</div>
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors",
|
||||
index === currentStep
|
||||
? "border-blue-500 bg-blue-500 text-white"
|
||||
: completedSteps.has(index)
|
||||
? "border-green-500 bg-green-500 text-white"
|
||||
: "border-gray-300 bg-white text-gray-500",
|
||||
allowStepNavigation && "cursor-pointer hover:border-blue-400",
|
||||
)}
|
||||
onClick={() => allowStepNavigation && goToStep(index)}
|
||||
>
|
||||
{completedSteps.has(index) ? (
|
||||
<CheckCircle className="h-6 w-6" />
|
||||
) : showStepNumbers ? (
|
||||
index + 1
|
||||
) : (
|
||||
<Circle className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 h-0.5 mx-4 transition-colors",
|
||||
completedSteps.has(index) ? "bg-green-500" : "bg-gray-200",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 当前步骤标题 */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">{steps[currentStep].title}</h2>
|
||||
{steps[currentStep].description && <p className="text-gray-600 mt-2">{steps[currentStep].description}</p>}
|
||||
{steps[currentStep].optional && <span className="text-sm text-gray-500 mt-1 block">(可选步骤)</span>}
|
||||
</div>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<Card>
|
||||
<CardContent className="p-6">{steps[currentStep].content}</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
{onCancel && (
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
{canGoPrevious && (
|
||||
<Button variant="outline" onClick={previousStep}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={nextStep} disabled={!canGoNext && !isLastStep}>
|
||||
{isLastStep ? "完成" : "下一步"}
|
||||
{!isLastStep && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 自定义内容 */}
|
||||
{children}
|
||||
</div>
|
||||
</WizardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// 向导步骤组件
|
||||
export function WizardStep({ children }: { children: ReactNode }) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
// 向导导航组件
|
||||
export function WizardNavigation() {
|
||||
const { currentStep, steps, goToStep, canGoPrevious, canGoNext, nextStep, previousStep } = useWizard()
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" onClick={previousStep} disabled={!canGoPrevious}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
上一步
|
||||
</Button>
|
||||
|
||||
<Button onClick={nextStep} disabled={!canGoNext}>
|
||||
下一步
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Battery, Smartphone, MessageCircle, Users, Clock } from "lucide-react"
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
wechatId: string
|
||||
friendCount: number
|
||||
todayAdded: number
|
||||
messageCount: number
|
||||
lastActive: string
|
||||
addFriendStatus: "normal" | "abnormal"
|
||||
}
|
||||
|
||||
interface DeviceGridProps {
|
||||
devices: Device[]
|
||||
selectable?: boolean
|
||||
selectedDevices?: string[]
|
||||
onSelect?: (deviceIds: string[]) => void
|
||||
itemsPerRow?: number
|
||||
}
|
||||
|
||||
export function DeviceGrid({
|
||||
devices,
|
||||
selectable = false,
|
||||
selectedDevices = [],
|
||||
onSelect,
|
||||
itemsPerRow = 2,
|
||||
}: DeviceGridProps) {
|
||||
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === devices.length) {
|
||||
onSelect?.([])
|
||||
} else {
|
||||
onSelect?.(devices.map((d) => d.id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectable && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.length === devices.length && devices.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">已选择 {selectedDevices.length} 个设备</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid grid-cols-${itemsPerRow} gap-4`}>
|
||||
{devices.map((device) => (
|
||||
<Card
|
||||
key={device.id}
|
||||
className={`p-4 hover:shadow-md transition-all cursor-pointer ${
|
||||
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (selectable) {
|
||||
const newSelection = selectedDevices.includes(device.id)
|
||||
? selectedDevices.filter((id) => id !== device.id)
|
||||
: [...selectedDevices, device.id]
|
||||
onSelect?.(newSelection)
|
||||
} else {
|
||||
setSelectedDevice(device)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
className="mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium">{device.name}</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span>{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{device.friendCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>{device.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>+{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={device.addFriendStatus === "normal" ? "outline" : "destructive"} className="mt-2">
|
||||
{device.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>设备详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedDevice && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-gray-100 rounded-lg">
|
||||
<Smartphone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{selectedDevice.name}</h3>
|
||||
<p className="text-sm text-gray-500">IMEI: {selectedDevice.imei}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={selectedDevice.status === "online" ? "success" : "secondary"}>
|
||||
{selectedDevice.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">电池电量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Battery className={`w-5 h-5 ${selectedDevice.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="font-medium">{selectedDevice.battery}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">好友数量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium">{selectedDevice.friendCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">今日新增</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-5 h-5 text-green-500" />
|
||||
<span className="font-medium">+{selectedDevice.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-500">消息数量</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<MessageCircle className="w-5 h-5 text-purple-500" />
|
||||
<span className="font-medium">{selectedDevice.messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">微信账号</div>
|
||||
<div className="font-medium">{selectedDevice.wechatId}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">最后活跃</div>
|
||||
<div className="font-medium">{selectedDevice.lastActive}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-500">加友状态</div>
|
||||
<Badge variant={selectedDevice.addFriendStatus === "normal" ? "outline" : "destructive"}>
|
||||
{selectedDevice.addFriendStatus === "normal" ? "加友正常" : "加友异常"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Search, Smartphone } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
// 模拟设备数据
|
||||
const mockDevices = [
|
||||
{ id: "dev1", name: "iPhone 13", status: "online", lastActive: "2023-05-20T10:30:00Z" },
|
||||
{ id: "dev2", name: "Xiaomi 12", status: "online", lastActive: "2023-05-20T09:15:00Z" },
|
||||
{ id: "dev3", name: "Huawei P40", status: "offline", lastActive: "2023-05-19T18:45:00Z" },
|
||||
{ id: "dev4", name: "OPPO Find X3", status: "online", lastActive: "2023-05-20T11:20:00Z" },
|
||||
{ id: "dev5", name: "Samsung S21", status: "offline", lastActive: "2023-05-19T14:10:00Z" },
|
||||
{ id: "dev6", name: "iPhone 12", status: "online", lastActive: "2023-05-20T08:30:00Z" },
|
||||
{ id: "dev7", name: "Xiaomi 11", status: "online", lastActive: "2023-05-20T10:45:00Z" },
|
||||
{ id: "dev8", name: "Huawei Mate 40", status: "offline", lastActive: "2023-05-18T16:20:00Z" },
|
||||
]
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedDevices: string[]
|
||||
onSelect: (selectedDevices: string[]) => void
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({ open, onOpenChange, selectedDevices, onSelect }: DeviceSelectionDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [devices, setDevices] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<string[]>(selectedDevices)
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟API请求
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 实际项目中应从API获取数据
|
||||
// const response = await fetch('/api/devices')
|
||||
// const data = await response.json()
|
||||
// setDevices(data)
|
||||
|
||||
// 使用模拟数据
|
||||
setTimeout(() => {
|
||||
setDevices(mockDevices)
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
console.error("获取设备失败:", error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
fetchData()
|
||||
setSelected(selectedDevices)
|
||||
}
|
||||
}, [open, selectedDevices])
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selected.length === filteredDevices.length) {
|
||||
setSelected([])
|
||||
} else {
|
||||
setSelected(filteredDevices.map((device) => device.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selected)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => device.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md md:max-w-lg lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input placeholder="搜索设备名称" className="pl-9" value={searchQuery} onChange={handleSearch} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selected.length} / {filteredDevices.length} 个设备
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleSelectAll}>
|
||||
{selected.length === filteredDevices.length ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px] rounded-md border p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">未找到匹配的设备</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredDevices.map((device) => (
|
||||
<div key={device.id} className="flex items-center space-x-3 p-2 rounded-md hover:bg-gray-100">
|
||||
<Checkbox
|
||||
id={`device-${device.id}`}
|
||||
checked={selected.includes(device.id)}
|
||||
onCheckedChange={() => handleToggleSelect(device.id)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor={`device-${device.id}`} className="flex items-center justify-between cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<Smartphone className="h-4 w-4 mr-2 text-gray-500" />
|
||||
<div>
|
||||
<div className="font-medium">{device.name}</div>
|
||||
<div className="text-xs text-gray-500">ID: {device.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={device.status === "online" ? "success" : "secondary"}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>确认</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Check, Plus } from "lucide-react"
|
||||
|
||||
interface PosterTemplate {
|
||||
id: string
|
||||
title: string
|
||||
type: "领取" | "了解"
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
interface PosterSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect: (template: PosterTemplate) => void
|
||||
}
|
||||
|
||||
const templates: PosterTemplate[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "点击领取",
|
||||
type: "领取",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "点击了解",
|
||||
type: "了解",
|
||||
imageUrl: "/placeholder.svg?height=400&width=300",
|
||||
},
|
||||
// ... 其他模板
|
||||
]
|
||||
|
||||
export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择海报</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm text-gray-500 mb-4">点击下方海报使用该模板</h3>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="group relative cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(template)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={template.imageUrl || "/placeholder.svg"}
|
||||
alt={template.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
|
||||
<Check className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="font-medium">{template.title}</div>
|
||||
<div className="text-sm text-gray-500">{template.type}类型</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 添加自定义海报按钮 */}
|
||||
<div className="group relative cursor-pointer">
|
||||
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center text-gray-400">
|
||||
<Plus className="w-12 h-12" />
|
||||
<p className="mt-2 text-sm">上传自定义海报</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button>新建海报</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
// 模拟流量池数据
|
||||
const mockTrafficPools = [
|
||||
{ id: "1", name: "抖音流量池", source: "抖音", count: 1200 },
|
||||
{ id: "2", name: "微信流量池", source: "微信", count: 850 },
|
||||
{ id: "3", name: "小红书流量池", source: "小红书", count: 650 },
|
||||
{ id: "4", name: "知乎流量池", source: "知乎", count: 320 },
|
||||
{ id: "5", name: "百度流量池", source: "百度", count: 480 },
|
||||
]
|
||||
|
||||
interface TrafficPoolSelectorProps {
|
||||
onSelect: (poolId: string, poolName: string) => void
|
||||
selectedPoolId: string | null
|
||||
selectedPoolName: string
|
||||
}
|
||||
|
||||
export function TrafficPoolSelector({ onSelect, selectedPoolId, selectedPoolName }: TrafficPoolSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [filteredPools, setFilteredPools] = useState(mockTrafficPools)
|
||||
|
||||
// 搜索过滤
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === "") {
|
||||
setFilteredPools(mockTrafficPools)
|
||||
} else {
|
||||
const query = searchQuery.toLowerCase()
|
||||
const filtered = mockTrafficPools.filter(
|
||||
(pool) => pool.name.toLowerCase().includes(query) || pool.source.toLowerCase().includes(query),
|
||||
)
|
||||
setFilteredPools(filtered)
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
// 处理选择
|
||||
const handleSelect = (poolId: string) => {
|
||||
const pool = mockTrafficPools.find((p) => p.id === poolId)
|
||||
if (pool) {
|
||||
onSelect(pool.id, pool.name)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||
{selectedPoolId ? selectedPoolName : "选择流量池"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择流量池</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索流量池..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={selectedPoolId || ""} onValueChange={handleSelect}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{filteredPools.map((pool) => (
|
||||
<Card
|
||||
key={pool.id}
|
||||
className={`cursor-pointer transition-all ${selectedPoolId === pool.id ? "ring-2 ring-primary" : ""}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<RadioGroupItem value={pool.id} id={`pool-${pool.id}`} className="absolute right-4 top-4" />
|
||||
<div className="space-y-2" onClick={() => handleSelect(pool.id)}>
|
||||
<div className="font-medium">{pool.name}</div>
|
||||
<div className="text-sm text-gray-500">来源: {pool.source}</div>
|
||||
<div className="text-sm">流量数量: {pool.count.toLocaleString()}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface CardGridProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
columns?: {
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
}
|
||||
gap?: "none" | "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应卡片网格组件
|
||||
* 根据屏幕尺寸自动调整卡片布局
|
||||
*/
|
||||
export function CardGrid({ children, className, columns = { sm: 1, md: 2, lg: 3, xl: 4 }, gap = "md" }: CardGridProps) {
|
||||
// 根据gap参数设置间距
|
||||
const gapClasses = {
|
||||
none: "gap-0",
|
||||
sm: "gap-2",
|
||||
md: "gap-4",
|
||||
lg: "gap-6",
|
||||
}
|
||||
|
||||
// 根据columns参数设置网格列数
|
||||
const getGridCols = () => {
|
||||
const cols = []
|
||||
if (columns.sm) cols.push(`grid-cols-${columns.sm}`)
|
||||
if (columns.md) cols.push(`md:grid-cols-${columns.md}`)
|
||||
if (columns.lg) cols.push(`lg:grid-cols-${columns.lg}`)
|
||||
if (columns.xl) cols.push(`xl:grid-cols-${columns.xl}`)
|
||||
return cols.join(" ")
|
||||
}
|
||||
|
||||
return <div className={cn("grid w-full", getGridCols(), gapClasses[gap], className)}>{children}</div>
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Clock, TrendingUp, Users } from "lucide-react"
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
description?: string
|
||||
trend?: number
|
||||
trendLabel?: string
|
||||
className?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计数据卡片
|
||||
* 用于展示关键指标数据
|
||||
*/
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
trend,
|
||||
trendLabel,
|
||||
className = "",
|
||||
valueClassName = "text-xl font-bold text-blue-600",
|
||||
}: StatsCardProps) {
|
||||
return (
|
||||
<Card className={`p-3 ${className}`}>
|
||||
<div className="text-sm text-gray-500">{title}</div>
|
||||
<div className={valueClassName}>{value}</div>
|
||||
{description && <div className="text-xs text-gray-500 mt-1">{description}</div>}
|
||||
{trend !== undefined && (
|
||||
<div className={`flex items-center text-xs mt-1 ${trend >= 0 ? "text-green-600" : "text-red-600"}`}>
|
||||
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}% {trendLabel || ""}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface DistributionPlanCardProps {
|
||||
id: string
|
||||
name: string
|
||||
status: "active" | "paused" | "completed"
|
||||
source: string
|
||||
sourceIcon: string
|
||||
targetGroups: string[]
|
||||
totalUsers: number
|
||||
dailyAverage: number
|
||||
lastUpdated: string
|
||||
createTime: string
|
||||
creator: string
|
||||
onView?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
onToggleStatus?: (id: string, status: "active" | "paused") => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 流量分发计划卡片
|
||||
* 用于展示流量分发计划信息
|
||||
*/
|
||||
export function DistributionPlanCard({
|
||||
id,
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
sourceIcon,
|
||||
targetGroups,
|
||||
totalUsers,
|
||||
dailyAverage,
|
||||
lastUpdated,
|
||||
createTime,
|
||||
creator,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: DistributionPlanCardProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xl mr-1">{sourceIcon}</span>
|
||||
<h3 className="font-medium">{name}</h3>
|
||||
<Badge variant={status === "active" ? "success" : status === "completed" ? "outline" : "secondary"}>
|
||||
{status === "active" ? "进行中" : status === "completed" ? "已完成" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{onToggleStatus && status !== "completed" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(id, status === "active" ? "paused" : "active")}
|
||||
>
|
||||
{status === "active" ? "暂停" : "启动"}
|
||||
</Button>
|
||||
)}
|
||||
{onView && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onView(id)}>
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
查看
|
||||
</Button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(id)}>
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>目标人群:{targetGroups.join(", ")}</div>
|
||||
<div>总流量:{totalUsers} 人</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>日均获取:{dailyAverage} 人</div>
|
||||
<div>创建人:{creator}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
上次更新:{lastUpdated}
|
||||
</div>
|
||||
<div>创建时间:{createTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface FormLayoutProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
layout?: "vertical" | "horizontal" | "responsive"
|
||||
labelWidth?: string
|
||||
gap?: "none" | "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应表单布局组件
|
||||
* 支持垂直、水平和响应式布局
|
||||
*/
|
||||
export function FormLayout({
|
||||
children,
|
||||
className,
|
||||
layout = "responsive",
|
||||
labelWidth = "w-32",
|
||||
gap = "md",
|
||||
}: FormLayoutProps) {
|
||||
// 根据gap参数设置间距
|
||||
const gapClasses = {
|
||||
none: "space-y-0",
|
||||
sm: "space-y-2",
|
||||
md: "space-y-4",
|
||||
lg: "space-y-6",
|
||||
}
|
||||
|
||||
// 根据layout参数设置布局类
|
||||
const getLayoutClasses = () => {
|
||||
switch (layout) {
|
||||
case "horizontal":
|
||||
return "form-horizontal"
|
||||
case "vertical":
|
||||
return "form-vertical"
|
||||
case "responsive":
|
||||
return "form-vertical md:form-horizontal"
|
||||
default:
|
||||
return "form-vertical"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full", getLayoutClasses(), gapClasses[gap], className, {
|
||||
[`[--label-width:${labelWidth}]`]: layout !== "vertical",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormLayout
|
||||
|
||||
interface FormItemProps {
|
||||
children: React.ReactNode
|
||||
label?: React.ReactNode
|
||||
className?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单项组件
|
||||
* 配合FormLayout使用
|
||||
*/
|
||||
export function FormItem({ children, label, className, required, error }: FormItemProps) {
|
||||
return (
|
||||
<div className={cn("form-item", className)}>
|
||||
{label && (
|
||||
<div className="form-label">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-control">
|
||||
{children}
|
||||
{error && <div className="text-red-500 text-sm mt-1">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 表单组件模板
|
||||
*
|
||||
* 包含项目中常用的各种表单组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单字段容器
|
||||
* 用于包装表单控件
|
||||
*/
|
||||
export function FormField({ label, htmlFor, required = false, description, error, children }: FormFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextInputFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本输入字段
|
||||
* 用于文本输入
|
||||
*/
|
||||
export function TextInputField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
type = "text",
|
||||
}: TextInputFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 多行文本输入字段
|
||||
* 用于多行文本输入
|
||||
*/
|
||||
export function TextareaField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: TextareaFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Textarea id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 下拉选择字段
|
||||
* 用于从选项中选择
|
||||
*/
|
||||
export function SelectField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "请选择",
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: SelectFieldProps) {
|
||||
return (
|
||||
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
interface RadioFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string; description?: string }>
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 单选按钮组字段
|
||||
* 用于从选项中单选
|
||||
*/
|
||||
export function RadioField({
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: RadioFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<RadioGroup value={value} onValueChange={onChange} className="space-y-3">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${name}-${option.value}`} />
|
||||
<Label htmlFor={`${name}-${option.value}`} className="cursor-pointer">
|
||||
{option.label}
|
||||
</Label>
|
||||
{option.description && <span className="text-xs text-gray-500 ml-2">({option.description})</span>}
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SliderFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
unit?: string
|
||||
required?: boolean
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 滑块字段
|
||||
* 用于数值范围选择
|
||||
*/
|
||||
export function SliderField({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
unit = "",
|
||||
required = false,
|
||||
description,
|
||||
error,
|
||||
}: SliderFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<span className="text-sm font-medium">
|
||||
{value} {unit}
|
||||
</span>
|
||||
</div>
|
||||
<Slider id={id} min={min} max={max} step={step} value={[value]} onValueChange={(values) => onChange(values[0])} />
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SwitchFieldProps {
|
||||
label: string
|
||||
id: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关字段
|
||||
* 用于布尔值选择
|
||||
*/
|
||||
export function SwitchField({ label, id, checked, onCheckedChange, description, disabled = false }: SwitchFieldProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||
</div>
|
||||
{description && <p className="text-xs text-gray-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 存客宝UI组件模板库
|
||||
*
|
||||
* 这个文件导出所有可重用的UI组件模板,方便在项目中快速引用
|
||||
*/
|
||||
|
||||
export * from "./cards"
|
||||
export * from "./forms"
|
||||
export * from "./layouts"
|
||||
export * from "./tables"
|
||||
export * from "./stats"
|
||||
export * from "./selectors"
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 布局组件模板
|
||||
*
|
||||
* 包含项目中常用的各种布局组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
onBack?: () => void
|
||||
actionButton?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面头部组件
|
||||
* 用于页面顶部的标题和操作区
|
||||
*/
|
||||
export function PageHeader({ title, onBack, actionButton }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-lg font-medium">{title}</h1>
|
||||
</div>
|
||||
{actionButton}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: Array<{
|
||||
step: number
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
}>
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤指示器组件
|
||||
* 用于多步骤流程的导航
|
||||
*/
|
||||
export function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map(({ step, title, icon }) => (
|
||||
<div key={step} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
step === currentStep
|
||||
? "bg-blue-600 text-white"
|
||||
: step < currentStep
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{step < currentStep ? "✓" : icon}
|
||||
</div>
|
||||
<span className="text-xs mt-1">{title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative mt-2">
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-200"></div>
|
||||
<div
|
||||
className="absolute top-0 left-0 h-1 bg-blue-600 transition-all"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepNavigationProps {
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
nextLabel?: string
|
||||
backLabel?: string
|
||||
isLastStep?: boolean
|
||||
isNextDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 步骤导航组件
|
||||
* 用于多步骤流程的前进后退按钮
|
||||
*/
|
||||
export function StepNavigation({
|
||||
onBack,
|
||||
onNext,
|
||||
nextLabel = "下一步",
|
||||
backLabel = "上一步",
|
||||
isLastStep = false,
|
||||
isNextDisabled = false,
|
||||
}: StepNavigationProps) {
|
||||
return (
|
||||
<div className="pt-4 flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={isNextDisabled}>
|
||||
{isLastStep ? "完成" : nextLabel}
|
||||
{!isLastStep && <span className="ml-2">→</span>}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
import { Button } from "@/app/components/ui/button"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
backButton?: boolean
|
||||
backUrl?: string
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面标题组件
|
||||
* 支持返回按钮和操作按钮
|
||||
*/
|
||||
export function PageHeader({ title, subtitle, backButton = false, backUrl, actions, className }: PageHeaderProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
if (backUrl) {
|
||||
router.push(backUrl)
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{backButton && (
|
||||
<Button variant="ghost" size="icon" onClick={handleBack} className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
{subtitle && <p className="text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/app/components/ui/table"
|
||||
|
||||
interface Column<T> {
|
||||
key: string
|
||||
title: React.ReactNode
|
||||
render?: (value: any, record: T, index: number) => React.ReactNode
|
||||
width?: number | string
|
||||
className?: string
|
||||
responsive?: boolean // 是否在小屏幕上显示
|
||||
}
|
||||
|
||||
interface ResponsiveTableProps<T> {
|
||||
columns: Column<T>[]
|
||||
dataSource: T[]
|
||||
rowKey?: string | ((record: T) => string)
|
||||
className?: string
|
||||
loading?: boolean
|
||||
emptyText?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式表格组件
|
||||
* 在小屏幕上自动隐藏不重要的列
|
||||
*/
|
||||
export function ResponsiveTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
dataSource,
|
||||
rowKey = "id",
|
||||
className,
|
||||
loading = false,
|
||||
emptyText = "暂无数据",
|
||||
}: ResponsiveTableProps<T>) {
|
||||
// 根据屏幕尺寸过滤列
|
||||
const [visibleColumns, setVisibleColumns] = React.useState(columns)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
// 在小屏幕上只显示responsive不为false的列
|
||||
setVisibleColumns(columns.filter((col) => col.responsive !== false))
|
||||
} else {
|
||||
setVisibleColumns(columns)
|
||||
}
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [columns])
|
||||
|
||||
// 获取行的唯一键
|
||||
const getRowKey = (record: T, index: number) => {
|
||||
if (typeof rowKey === "function") {
|
||||
return rowKey(record)
|
||||
}
|
||||
return record[rowKey] || index
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="responsive-table-container">
|
||||
<Table className={cn("w-full", className)}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableHead key={column.key} className={column.className} style={{ width: column.width }}>
|
||||
{column.title}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dataSource.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dataSource.map((record, index) => (
|
||||
<TableRow key={getRowKey(record, index)}>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableCell key={column.key} className={column.className}>
|
||||
{column.render ? column.render(record[column.key], record, index) : record[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface CustomerServiceRepProps {
|
||||
id: string
|
||||
name: string
|
||||
deviceId: string
|
||||
status: "online" | "offline"
|
||||
avatar?: string
|
||||
selected: boolean
|
||||
disabled?: boolean
|
||||
onSelect: (id: string, checked: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服代表选择卡片
|
||||
* 用于选择客服代表
|
||||
*/
|
||||
export function CustomerServiceRepCard({
|
||||
id,
|
||||
name,
|
||||
deviceId,
|
||||
status,
|
||||
avatar,
|
||||
selected,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
}: CustomerServiceRepProps) {
|
||||
return (
|
||||
<Card
|
||||
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selected ? "border-primary bg-blue-50" : ""
|
||||
} ${disabled ? "opacity-50 pointer-events-none" : ""}`}
|
||||
onClick={() => !disabled && onSelect(id, !selected)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={() => !disabled && onSelect(id, !selected)}
|
||||
disabled={disabled}
|
||||
id={`rep-${id}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`rep-${id}`} className="font-medium truncate cursor-pointer">
|
||||
{name}
|
||||
</label>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
status === "online" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">设备ID: {deviceId}</div>
|
||||
<div className="text-sm text-gray-500">客服ID: {id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceSelectProps {
|
||||
allDevices: boolean
|
||||
newDevices: boolean
|
||||
targetDevices: string[]
|
||||
onAllDevicesChange: (checked: boolean) => void
|
||||
onNewDevicesChange: (checked: boolean) => void
|
||||
onShowDeviceSelector: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备选择组件
|
||||
* 用于选择设备
|
||||
*/
|
||||
export function DeviceSelectSection({
|
||||
allDevices,
|
||||
newDevices,
|
||||
targetDevices,
|
||||
onAllDevicesChange,
|
||||
onNewDevicesChange,
|
||||
onShowDeviceSelector,
|
||||
}: DeviceSelectProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allDevices"
|
||||
checked={allDevices}
|
||||
onCheckedChange={(checked) => onAllDevicesChange(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="allDevices" className="font-medium">
|
||||
所有设备
|
||||
</Label>
|
||||
<span className="text-xs text-gray-500">(系统自动分配流量到所有在线设备)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newDevices"
|
||||
checked={newDevices}
|
||||
onCheckedChange={(checked) => onNewDevicesChange(checked === true)}
|
||||
disabled={allDevices}
|
||||
/>
|
||||
<Label htmlFor="newDevices" className="font-medium">
|
||||
新添加设备
|
||||
</Label>
|
||||
<span className="text-xs text-gray-500">(仅针对新添加设备进行流量分发)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4">
|
||||
<Label className="font-medium">指定设备</Label>
|
||||
<p className="text-xs text-gray-500 mb-2">选择特定的设备进行流量分发</p>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center justify-center space-x-2 border border-gray-300 rounded-md py-2 px-4 hover:bg-gray-50 transition-colors"
|
||||
disabled={allDevices}
|
||||
onClick={onShowDeviceSelector}
|
||||
>
|
||||
<span>选择设备</span>
|
||||
{targetDevices?.length > 0 && <Badge variant="secondary">已选 {targetDevices.length} 台</Badge>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 统计组件模板
|
||||
*
|
||||
* 包含项目中常用的各种统计和数据展示组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface StatsSummaryProps {
|
||||
stats: Array<{
|
||||
title: string
|
||||
value: string | number
|
||||
color?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计摘要组件
|
||||
* 用于展示多个统计数据
|
||||
*/
|
||||
export function StatsSummary({ stats }: StatsSummaryProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="text-sm text-gray-500">{stat.title}</div>
|
||||
<div className={`text-xl font-bold ${stat.color || "text-blue-600"}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataCardProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
headerAction?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据卡片组件
|
||||
* 用于包装数据展示内容
|
||||
*/
|
||||
export function DataCard({ title, children, className = "", headerAction }: DataCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
{headerAction}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: { id: number; title: string; subtitle?: string }[]
|
||||
currentStep: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepIndicator({ steps, currentStep, className }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn("flex flex-col items-center", currentStep === step.id ? "text-blue-600" : "text-gray-400")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium mb-1",
|
||||
currentStep === step.id
|
||||
? "bg-blue-600 text-white"
|
||||
: currentStep > step.id
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "bg-gray-200 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
<div className="text-xs">{step.title}</div>
|
||||
{step.subtitle && <div className="text-xs font-medium">{step.subtitle}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative h-1 bg-gray-200 mt-2">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* 表格组件模板
|
||||
*
|
||||
* 包含项目中常用的各种表格组件
|
||||
*/
|
||||
|
||||
import type React from "react"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface Column<T> {
|
||||
header: string
|
||||
accessorKey: keyof T | ((row: T) => React.ReactNode)
|
||||
cell?: (row: T) => React.ReactNode
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
keyField: keyof T
|
||||
selectable?: boolean
|
||||
selectedRows?: (string | number)[]
|
||||
onSelectRow?: (id: string | number, checked: boolean) => void
|
||||
onSelectAll?: (checked: boolean) => void
|
||||
onRowClick?: (row: T) => void
|
||||
emptyMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据表格
|
||||
* 用于展示表格数据
|
||||
*/
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
keyField,
|
||||
selectable = false,
|
||||
selectedRows = [],
|
||||
onSelectRow,
|
||||
onSelectAll,
|
||||
onRowClick,
|
||||
emptyMessage = "没有数据",
|
||||
}: DataTableProps<T>) {
|
||||
const handleRowClick = (row: T) => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selectable && onSelectRow && onSelectAll && (
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && selectedRows.length === data.length}
|
||||
onCheckedChange={(checked) => onSelectAll(checked === true)}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<TableHead key={index}>{column.header}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="text-center py-6 text-gray-500">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
<TableRow
|
||||
key={String(row[keyField])}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
onClick={() => handleRowClick(row)}
|
||||
>
|
||||
{selectable && onSelectRow && (
|
||||
<TableCell className="w-12" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(row[keyField])}
|
||||
onCheckedChange={(checked) => onSelectRow(row[keyField], checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((column, index) => (
|
||||
<TableCell key={index}>
|
||||
{column.cell
|
||||
? column.cell(row)
|
||||
: typeof column.accessorKey === "function"
|
||||
? column.accessorKey(row)
|
||||
: row[column.accessorKey]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DeviceTableProps {
|
||||
devices: Array<{
|
||||
id: string
|
||||
name: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
battery?: number
|
||||
wechatId?: string
|
||||
lastActive?: string
|
||||
}>
|
||||
selectedDevices: string[]
|
||||
onSelectDevice: (deviceId: string, checked: boolean) => void
|
||||
onSelectAll: (checked: boolean) => void
|
||||
onDeviceClick: (deviceId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备表格
|
||||
* 用于展示设备列表
|
||||
*/
|
||||
export function DeviceTableTemplate({
|
||||
devices,
|
||||
selectedDevices,
|
||||
onSelectDevice,
|
||||
onSelectAll,
|
||||
onDeviceClick,
|
||||
}: DeviceTableProps) {
|
||||
const columns: Column<(typeof devices)[0]>[] = [
|
||||
{
|
||||
header: "设备名称",
|
||||
accessorKey: "name",
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.name}</div>
|
||||
<div className="text-xs text-gray-500">IMEI: {row.imei}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "状态",
|
||||
accessorKey: "status",
|
||||
cell: (row) => (
|
||||
<Badge variant={row.status === "online" ? "success" : "secondary"}>
|
||||
{row.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "微信号",
|
||||
accessorKey: "wechatId",
|
||||
cell: (row) => row.wechatId || "-",
|
||||
},
|
||||
{
|
||||
header: "电量",
|
||||
accessorKey: "battery",
|
||||
cell: (row) => (
|
||||
<div className="flex items-center">
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden mr-2">
|
||||
<div
|
||||
className={`h-full ${(row.battery || 0) > 20 ? "bg-green-500" : "bg-red-500"}`}
|
||||
style={{ width: `${row.battery || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span>{row.battery || 0}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "最后活跃",
|
||||
accessorKey: "lastActive",
|
||||
cell: (row) => row.lastActive || "-",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={devices}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
selectable={true}
|
||||
selectedRows={selectedDevices}
|
||||
onSelectRow={onSelectDevice}
|
||||
onSelectAll={onSelectAll}
|
||||
onRowClick={(row) => onDeviceClick(row.id)}
|
||||
emptyMessage="没有找到设备"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -1,30 +0,0 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-100 text-green-700 hover:bg-green-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, children, ...props }, ref) => {
|
||||
// If asChild is true and the first child is a valid element, clone it with the button props
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children, {
|
||||
className: cn(buttonVariants({ variant, size, className })),
|
||||
ref,
|
||||
...props,
|
||||
...children.props,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside: "text-muted-foreground opacity-50",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
config: Record<string, { label: string; color: string }>
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||
({ className, config, children, ...props }, ref) => {
|
||||
const colorVars = Object.entries(config).reduce((acc, [key, value], index) => {
|
||||
acc[`--color-${key}`] = value.color
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)} style={colorVars} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
ChartContainer.displayName = "ChartContainer"
|
||||
|
||||
interface ChartTooltipProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const ChartTooltip = React.forwardRef<HTMLDivElement, ChartTooltipProps>(({ className, children, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn("rounded-md border bg-card p-2 shadow-md", className)} {...props} />
|
||||
})
|
||||
ChartTooltip.displayName = "ChartTooltip"
|
||||
|
||||
interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
active?: boolean
|
||||
payload?: any[]
|
||||
label?: string
|
||||
labelFormatter?: (value: any) => string
|
||||
hideLabel?: boolean
|
||||
}
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
|
||||
({ active, payload, label, labelFormatter, hideLabel, className, ...props }, ref) => {
|
||||
if (!active || !payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("rounded-lg border bg-background p-2 shadow-sm", className)} {...props}>
|
||||
{!hideLabel && label && (
|
||||
<div className="mb-1 text-xs font-medium">{labelFormatter ? labelFormatter(label) : label}</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{payload.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="font-medium">{entry.name}:</span>
|
||||
<span>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltipContent"
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent }
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user